From 1d64b92b4123f449217cdf4afcddafe242d22951 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:25:59 -0700 Subject: [PATCH] feat(desktop): desktop app (#5998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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) 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) 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 `` 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) 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) 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 * 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 * 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 * 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 * 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 * 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 * 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 Co-authored-by: Claude Fable 5 Co-authored-by: Waleed Co-authored-by: Theodore Li --- .github/workflows/ci.yml | 211 + .github/workflows/desktop-e2e.yml | 85 + .github/workflows/desktop-release.yml | 212 + .github/workflows/test-build.yml | 9 + .gitignore | 2 + apps/desktop/.gitignore | 5 + apps/desktop/README.md | 194 + apps/desktop/build/entitlements.mac.plist | 8 + apps/desktop/build/icon-dev.icns | Bin 0 -> 124879 bytes apps/desktop/build/icon-local.icns | Bin 0 -> 124854 bytes apps/desktop/build/icon-staging.icns | Bin 0 -> 119897 bytes apps/desktop/build/icon.icns | Bin 0 -> 118583 bytes .../docs/electron-upgrade-checklist.md | 17 + apps/desktop/e2e/smoke.spec.ts | 114 + apps/desktop/electron-builder.yml | 69 + apps/desktop/package.json | 58 + apps/desktop/playwright.config.ts | 9 + apps/desktop/scripts/build.ts | 95 + apps/desktop/scripts/ensure-pty-prebuilds.ts | 94 + apps/desktop/scripts/install-local.ts | 181 + apps/desktop/src/main/app-routes.test.ts | 19 + apps/desktop/src/main/app-routes.ts | 41 + apps/desktop/src/main/atomic-json-file.ts | 72 + .../src/main/browser-agent/cdp.test.ts | 36 + apps/desktop/src/main/browser-agent/cdp.ts | 176 + .../main/browser-agent/context-menu.test.ts | 260 + .../src/main/browser-agent/context-menu.ts | 185 + .../src/main/browser-agent/driver.test.ts | 313 + apps/desktop/src/main/browser-agent/driver.ts | 913 + apps/desktop/src/main/browser-agent/errors.ts | 2 + .../src/main/browser-agent/keyboard.test.ts | 86 + .../src/main/browser-agent/keyboard.ts | 191 + .../src/main/browser-agent/known-sessions.ts | 212 + .../main/browser-agent/page-functions.test.ts | 423 + .../src/main/browser-agent/page-functions.ts | 672 + .../src/main/browser-agent/panel.test.ts | 155 + apps/desktop/src/main/browser-agent/panel.ts | 518 + .../src/main/browser-agent/registry.ts | 20 + .../src/main/browser-agent/session.test.ts | 1435 ++ .../desktop/src/main/browser-agent/session.ts | 1121 + .../src/main/browser-agent/url-guard.test.ts | 122 + .../src/main/browser-agent/url-guard.ts | 141 + .../src/main/browser-credentials/fill.test.ts | 327 + .../src/main/browser-credentials/fill.ts | 197 + .../src/main/browser-credentials/index.ts | 145 + .../main/browser-credentials/origin.test.ts | 51 + .../src/main/browser-credentials/origin.ts | 38 + .../main/browser-credentials/os-auth.test.ts | 165 + .../src/main/browser-credentials/os-auth.ts | 112 + .../main/browser-credentials/vault.test.ts | 211 + .../src/main/browser-credentials/vault.ts | 286 + .../main/browser-import/browser-sources.ts | 99 + .../browser-import/chromium-cookies.test.ts | 249 + .../main/browser-import/chromium-cookies.ts | 86 + .../browser-import/chromium-crypto.test.ts | 88 + .../main/browser-import/chromium-crypto.ts | 157 + .../browser-import/chromium-favicons.test.ts | 117 + .../main/browser-import/chromium-favicons.ts | 87 + .../browser-import/chromium-passwords.test.ts | 152 + .../main/browser-import/chromium-passwords.ts | 70 + .../browser-import/chromium-profiles.test.ts | 225 + .../main/browser-import/chromium-profiles.ts | 178 + .../chromium-site-names.test.ts | 314 + .../browser-import/chromium-site-names.ts | 205 + .../browser-import/cookie-translate.test.ts | 124 + .../main/browser-import/cookie-translate.ts | 109 + .../browser-import/import-service.test.ts | 928 + .../src/main/browser-import/import-service.ts | 511 + apps/desktop/src/main/browser-import/index.ts | 68 + .../src/main/browser-import/sqlite-source.ts | 76 + apps/desktop/src/main/browser-import/types.ts | 103 + .../src/main/browser-sites/directory.test.ts | 256 + .../src/main/browser-sites/directory.ts | 197 + apps/desktop/src/main/browser-sites/index.ts | 30 + apps/desktop/src/main/config.test.ts | 191 + apps/desktop/src/main/config.ts | 305 + apps/desktop/src/main/context-menu.test.ts | 93 + apps/desktop/src/main/context-menu.ts | 112 + apps/desktop/src/main/csp.test.ts | 83 + apps/desktop/src/main/csp.ts | 47 + .../desktop/src/main/desktop-settings.test.ts | 122 + apps/desktop/src/main/desktop-settings.ts | 147 + apps/desktop/src/main/downloads.test.ts | Bin 0 -> 1390 bytes apps/desktop/src/main/downloads.ts | 78 + apps/desktop/src/main/handoff.test.ts | 272 + apps/desktop/src/main/handoff.ts | 469 + apps/desktop/src/main/index.ts | 545 + apps/desktop/src/main/ipc.test.ts | 805 + apps/desktop/src/main/ipc.ts | 1048 + apps/desktop/src/main/load-health.test.ts | 34 + apps/desktop/src/main/load-health.ts | 154 + .../main/local-filesystem-grant-store.test.ts | 55 + .../src/main/local-filesystem-grant-store.ts | 94 + .../desktop/src/main/local-filesystem.test.ts | 536 + apps/desktop/src/main/local-filesystem.ts | 1140 + apps/desktop/src/main/menu.test.ts | 155 + apps/desktop/src/main/menu.ts | 156 + apps/desktop/src/main/navigation.test.ts | 251 + apps/desktop/src/main/navigation.ts | 232 + apps/desktop/src/main/observability.test.ts | 42 + apps/desktop/src/main/observability.ts | 83 + apps/desktop/src/main/security-guards.test.ts | 148 + apps/desktop/src/main/security-guards.ts | 96 + .../src/main/session-lifecycle.test.ts | 315 + apps/desktop/src/main/session-lifecycle.ts | 411 + .../desktop/src/main/telemetry-policy.test.ts | 23 + apps/desktop/src/main/telemetry-policy.ts | 50 + apps/desktop/src/main/terminal/index.ts | 951 + .../src/main/terminal/process-cwd.test.ts | 41 + apps/desktop/src/main/terminal/process-cwd.ts | 91 + .../src/main/terminal/selection.test.ts | 136 + .../desktop/src/main/terminal/service.test.ts | 451 + .../desktop/src/main/terminal/session.test.ts | 95 + apps/desktop/src/main/terminal/session.ts | 990 + .../main/terminal/shell-integration.test.ts | 95 + .../src/main/terminal/shell-integration.ts | 297 + apps/desktop/src/main/terminal/tmux.test.ts | 164 + apps/desktop/src/main/terminal/tmux.ts | 423 + apps/desktop/src/main/tray.test.ts | 359 + apps/desktop/src/main/tray.ts | 507 + apps/desktop/src/main/updater.test.ts | 424 + apps/desktop/src/main/updater.ts | 630 + apps/desktop/src/main/window.test.ts | 203 + apps/desktop/src/main/window.ts | 352 + apps/desktop/src/main/windows.test.ts | 93 + apps/desktop/src/main/windows.ts | 90 + apps/desktop/src/preload/browser/index.ts | 176 + apps/desktop/src/preload/index.ts | 352 + apps/desktop/src/test/electron-mock.ts | 242 + apps/desktop/static/dock-icon-dev.png | Bin 0 -> 15211 bytes apps/desktop/static/dock-icon-local.png | Bin 0 -> 15053 bytes apps/desktop/static/dock-icon-staging.png | Bin 0 -> 14730 bytes apps/desktop/static/dock-icon.png | Bin 0 -> 18721 bytes apps/desktop/static/offline.html | 175 + apps/desktop/static/tray/simTemplate.png | Bin 0 -> 504 bytes apps/desktop/static/tray/simTemplate@2x.png | Bin 0 -> 926 bytes apps/desktop/tsconfig.json | 11 + apps/desktop/vitest.config.ts | 21 + apps/docs/package.json | 2 +- apps/sim/app/(auth)/auth-layout-client.tsx | 13 +- apps/sim/app/(auth)/components/auth-shell.tsx | 15 +- .../_shell/desktop-title-bar-surfaces.test.ts | 68 + apps/sim/app/_shell/desktop-title-bar.test.ts | 29 + apps/sim/app/_shell/desktop-title-bar.tsx | 67 + apps/sim/app/_shell/desktop-update-gate.tsx | 100 + .../_shell/providers/get-query-client.test.ts | 26 + .../app/_shell/providers/get-query-client.ts | 15 +- apps/sim/app/_styles/globals.css | 88 +- apps/sim/app/api/auth/socket-token/route.ts | 7 + apps/sim/app/api/copilot/chat/queries.ts | 2 - .../app/api/copilot/chat/resources/route.ts | 17 +- .../api/copilot/chat/update-messages/route.ts | 7 +- apps/sim/app/api/copilot/chats/route.ts | 18 +- .../app/api/copilot/tool-permission/route.ts | 175 + .../api/desktop/auth/handoff/route.test.ts | 147 + .../sim/app/api/desktop/auth/handoff/route.ts | 63 + .../api/desktop/tool/authorize/route.test.ts | 155 + .../app/api/desktop/tool/authorize/route.ts | 82 + .../desktop/update/latest-mac.yml/route.ts | 93 + apps/sim/app/api/function/execute/route.ts | 1 - apps/sim/app/api/invitations/route.ts | 53 + .../chats/[chatId]/fork/route.test.ts | 1 - .../mothership/chats/[chatId]/fork/route.ts | 2 - .../app/api/mothership/execute/route.test.ts | 50 + apps/sim/app/api/mothership/execute/route.ts | 21 +- .../local-files/stage/route.test.ts | 154 + .../api/mothership/local-files/stage/route.ts | 101 + .../api/superuser/import-workflow/route.ts | 2 - apps/sim/app/api/tools/onepassword/utils.ts | 11 +- apps/sim/app/api/tools/sap_concur/shared.ts | 30 +- apps/sim/app/api/tools/zoominfo/shared.ts | 18 +- .../app/desktop/auth/authorize-handoff.tsx | 67 + apps/sim/app/desktop/auth/page.test.tsx | 97 + apps/sim/app/desktop/auth/page.tsx | 63 + apps/sim/app/desktop/auth/validation.test.ts | 57 + apps/sim/app/desktop/auth/validation.ts | 55 + .../desktop-handoff-shell.tsx | 32 + .../components/desktop-handoff-shell/index.ts | 1 + .../sim/app/desktop/connect/complete/page.tsx | 48 + .../app/desktop/connect/connect-launcher.tsx | 66 + apps/sim/app/desktop/connect/page.tsx | 118 + .../app/desktop/connect/switch-account.tsx | 45 + .../app/desktop/connect/validation.test.ts | 61 + apps/sim/app/desktop/connect/validation.ts | 80 + apps/sim/app/desktop/done/page.tsx | 40 + apps/sim/app/layout.tsx | 18 +- apps/sim/app/oauth-error/page.tsx | 49 + .../impersonation-expired.tsx | 67 - .../components/impersonation-banner/index.ts | 1 - .../components/session-expired/index.ts | 1 + .../session-expired/session-expired.test.tsx | 131 + .../session-expired/session-expired.tsx | 76 + .../workspace-chrome/workspace-chrome.tsx | 73 +- .../chat-context-kind-registry.tsx | 10 + .../components/agent-group/agent-group.tsx | 18 +- .../components/agent-group/tool-call-item.tsx | 129 +- .../agent-group/tool-permission-card.tsx | 207 + .../components/chat-content/external-link.tsx | 15 + .../components/special-tags/index.ts | 1 + .../components/special-tags/special-tags.tsx | 238 +- .../message-content/message-content.tsx | 1 + .../home/components/message-content/utils.ts | 34 +- .../mothership-chat/mothership-chat.tsx | 8 +- .../add-resource-dropdown.tsx | 46 + .../browser-session/browser-find-bar.tsx | 170 + .../browser-panel-occlusion.test.ts | 57 + .../browser-panel-occlusion.ts | 174 + .../browser-session/browser-session.test.ts | 65 + .../browser-session/browser-session.tsx | 635 + .../browser-session/browser-tab-strip.test.ts | 18 + .../browser-session/browser-tab-strip.tsx | 169 + .../browser-session/url-suggestions.test.ts | 419 + .../browser-session/url-suggestions.ts | 261 + .../resource-content/components/index.ts | 1 + .../terminal-context-menu.tsx | 102 + .../terminal-session/terminal-session.tsx | 755 + .../resource-content/resource-content.tsx | 18 + .../resource-registry/resource-registry.tsx | 30 + .../resource-tabs/resource-tabs.tsx | 41 +- .../mothership-view/mothership-view.tsx | 57 +- .../user-input/components/constants.ts | 6 + .../plus-menu-dropdown/plus-menu-dropdown.tsx | 18 +- .../components/resource-context.test.ts | 39 + .../app/workspace/[workspaceId]/home/home.tsx | 174 +- .../hooks/stream/handle-tool-event.test.ts | 149 + .../home/hooks/stream/handle-tool-event.ts | 54 +- .../home/hooks/stream/stream-context.ts | 13 + .../home/hooks/stream/stream-helpers.ts | 96 +- .../home/hooks/stream/stream-test-helpers.ts | 3 + .../hooks/stream/turn-model-serialize.test.ts | 7 +- .../home/hooks/stream/turn-model-serialize.ts | 13 +- .../home/hooks/stream/turn-model.ts | 31 +- .../[workspaceId]/home/hooks/use-chat.test.ts | 63 +- .../[workspaceId]/home/hooks/use-chat.ts | 268 +- .../home/hooks/use-mothership-resize.test.ts | 106 + .../home/hooks/use-mothership-resize.ts | 170 +- .../app/workspace/[workspaceId]/home/types.ts | 10 + .../workspace/[workspaceId]/layout.test.tsx | 9 +- .../app/workspace/[workspaceId]/layout.tsx | 10 +- .../desktop-oauth-connect-listener.tsx | 12 + .../settings/[section]/settings.tsx | 14 + .../components/browser/browser.test.tsx | 404 + .../settings/components/browser/browser.tsx | 217 + .../import-modal/import-modal.test.tsx | 211 + .../components/import-modal/import-modal.tsx | 115 + .../password-detail/password-detail.test.tsx | 291 + .../password-detail/password-detail.tsx | 236 + .../passwords-view/passwords-view.test.tsx | 302 + .../passwords-view/passwords-view.tsx | 270 + .../settings/components/browser/index.ts | 1 + .../settings/components/desktop/desktop.tsx | 353 + .../settings/components/desktop/index.ts | 1 + .../settings-resource-row.tsx | 75 +- .../settings/components/terminal/index.ts | 1 + .../settings/components/terminal/terminal.tsx | 69 + .../[workspaceId]/settings/navigation.test.ts | 4 + .../[workspaceId]/settings/navigation.ts | 1 + .../settings-sidebar/settings-sidebar.tsx | 29 + .../components/context-menu/context-menu.tsx | 23 +- .../view-invitations-menu-item.tsx | 29 + .../view-invitations-modal.tsx | 131 + .../workspace-header/workspace-header.tsx | 27 +- .../w/components/sidebar/sidebar.tsx | 10 +- .../workspace/providers/socket-provider.tsx | 33 +- apps/sim/background/schedule-execution.ts | 26 +- .../components/settings/navigation.test.ts | 3 + apps/sim/components/settings/navigation.ts | 44 + apps/sim/components/ui/select.tsx | 1 + apps/sim/connectors/s3/s3.ts | 17 +- apps/sim/hooks/queries/credentials.ts | 4 + apps/sim/hooks/queries/environment.ts | 7 + apps/sim/hooks/queries/general-settings.ts | 3 + apps/sim/hooks/queries/invitations.ts | 76 + apps/sim/hooks/queries/mcp.ts | 2 +- .../hooks/queries/oauth/oauth-connections.ts | 16 + apps/sim/hooks/queries/schedules.ts | 5 + apps/sim/hooks/queries/session.ts | 25 +- apps/sim/hooks/queries/workflows.ts | 5 + apps/sim/hooks/use-oauth-return.ts | 50 + apps/sim/lib/api/contracts/copilot.ts | 58 +- apps/sim/lib/api/contracts/desktop-auth.ts | 25 + .../contracts/desktop-tool-authorization.ts | 30 + apps/sim/lib/api/contracts/index.ts | 2 + apps/sim/lib/api/contracts/invitations.ts | 11 + .../sim/lib/api/contracts/mothership-chats.ts | 19 + apps/sim/lib/api/contracts/tools/sap.ts | 58 +- apps/sim/lib/api/contracts/user.ts | 3 + apps/sim/lib/auth/auth.ts | 18 +- apps/sim/lib/auth/desktop-handoff.ts | 70 + apps/sim/lib/auth/stale-session-recovery.ts | 2 +- .../sim/lib/browser-agent/attachments.test.ts | 105 + apps/sim/lib/browser-agent/attachments.ts | 73 + apps/sim/lib/browser-agent/open-in-panel.ts | 46 + apps/sim/lib/browser-agent/transport.test.ts | 165 + apps/sim/lib/browser-agent/transport.ts | 319 + .../lib/copilot/async-runs/repository.test.ts | 26 + apps/sim/lib/copilot/async-runs/repository.ts | 97 +- apps/sim/lib/copilot/chat/display-message.ts | 1 + apps/sim/lib/copilot/chat/lifecycle.test.ts | 4 +- apps/sim/lib/copilot/chat/lifecycle.ts | 19 +- apps/sim/lib/copilot/chat/payload.test.ts | 33 + apps/sim/lib/copilot/chat/payload.ts | 30 + .../sim/lib/copilot/chat/persisted-message.ts | 1 + apps/sim/lib/copilot/chat/post.test.ts | 20 + apps/sim/lib/copilot/chat/post.ts | 139 +- apps/sim/lib/copilot/chat/process-contents.ts | 18 + .../copilot/chat/workspace-context.test.ts | 8 +- .../sim/lib/copilot/chat/workspace-context.ts | 10 +- .../generated/mothership-stream-v1-schema.ts | 11 +- .../copilot/generated/mothership-stream-v1.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 859 +- .../lib/copilot/generated/tool-schemas-v1.ts | 605 +- .../copilot/generated/trace-attributes-v1.ts | 2 + .../lib/copilot/generated/trace-spans-v1.ts | 8 +- .../lib/copilot/generated/vfs-snapshot-v1.ts | 10 + .../persistence/tool-permission/auto-allow.ts | 93 + .../persistence/tool-permission/index.ts | 156 + .../request/context/request-context.ts | 1 + .../copilot/request/context/result.test.ts | 1 + .../sim/lib/copilot/request/go/stream.test.ts | 47 +- apps/sim/lib/copilot/request/go/stream.ts | 32 +- .../lib/copilot/request/handlers/complete.ts | 1 + .../copilot/request/handlers/handlers.test.ts | 256 +- apps/sim/lib/copilot/request/handlers/tool.ts | 259 +- .../lifecycle/resume-leg-context.test.ts | 17 + .../lib/copilot/request/lifecycle/run.test.ts | 320 +- apps/sim/lib/copilot/request/lifecycle/run.ts | 97 +- .../copilot/request/tools/executor.test.ts | 21 +- .../sim/lib/copilot/request/tools/executor.ts | 15 + .../lib/copilot/request/tools/files.test.ts | 10 +- .../copilot/request/tools/permission.test.ts | 358 + .../lib/copilot/request/tools/permission.ts | 312 + apps/sim/lib/copilot/request/types.ts | 24 +- .../sim/lib/copilot/resources/availability.ts | 20 + .../lib/copilot/resources/extraction.test.ts | 70 +- apps/sim/lib/copilot/resources/extraction.ts | 92 +- apps/sim/lib/copilot/resources/types.test.ts | 89 + apps/sim/lib/copilot/resources/types.ts | 84 +- apps/sim/lib/copilot/tool-executor/index.ts | 2 +- .../tool-executor/register-handlers.ts | 15 +- apps/sim/lib/copilot/tool-executor/router.ts | 5 + .../client/browser-tool-execution.test.ts | 189 + .../tools/client/browser-tool-execution.ts | 277 + .../lib/copilot/tools/client/completion.ts | 88 + .../lib/copilot/tools/client/hidden-tools.ts | 10 +- .../tools/client/local-filesystem.test.ts | 235 + .../copilot/tools/client/local-filesystem.ts | 409 + .../tools/client/run-tool-execution.ts | 84 +- .../tools/client/terminal-tool-execution.ts | 207 + .../tools/handlers/function-execute.test.ts | 94 +- .../tools/handlers/function-execute.ts | 106 +- .../tools/handlers/materialize-file.test.ts | 26 - .../tools/handlers/materialize-file.ts | 11 +- .../tools/handlers/platform-actions.ts | 18 +- .../copilot/tools/handlers/resources.test.ts | 59 - .../copilot/tools/handlers/vfs-mutate.test.ts | 18 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 374 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 5 +- .../tools/handlers/workflow/mutations.ts | 348 +- .../sim/lib/copilot/tools/local-filesystem.ts | 23 + .../copilot/tools/server/files/create-file.ts | 10 - .../copilot/tools/server/files/delete-file.ts | 112 - .../copilot/tools/server/files/doc-compile.ts | 10 +- .../tools/server/files/doc-servable.test.ts | 12 +- .../tools/server/files/file-folders.ts | 67 - .../tools/server/files/workspace-file.ts | 36 +- apps/sim/lib/copilot/tools/server/router.ts | 8 - .../tools/server/table/user-table.test.ts | 31 + .../copilot/tools/server/table/user-table.ts | 8 + .../lib/copilot/tools/tool-display.test.ts | 171 +- apps/sim/lib/copilot/tools/tool-display.ts | 231 +- .../lib/copilot/vfs/resource-writer.test.ts | 278 +- apps/sim/lib/copilot/vfs/resource-writer.ts | 219 +- .../vfs/workflow-alias-backing.test.ts | 175 - .../lib/copilot/vfs/workflow-alias-backing.ts | 199 - .../copilot/vfs/workflow-alias-resolver.ts | 61 - .../lib/copilot/vfs/workflow-aliases.test.ts | 139 - apps/sim/lib/copilot/vfs/workflow-aliases.ts | 360 - apps/sim/lib/copilot/vfs/workspace-vfs.ts | 238 +- apps/sim/lib/core/config/env-flags.ts | 12 + apps/sim/lib/core/config/env.ts | 3 +- .../sim/lib/core/config/feature-flags.test.ts | 2 - apps/sim/lib/core/config/feature-flags.ts | 8 - .../core/security/input-validation.server.ts | 85 +- .../core/security/input-validation.test.ts | 156 +- .../sim/lib/core/security/input-validation.ts | 43 +- apps/sim/lib/core/utils/urls.ts | 16 +- .../lib/data-drains/sources/copilot-chats.ts | 9 +- apps/sim/lib/desktop/index.ts | 200 + apps/sim/lib/desktop/min-version.test.ts | 51 + apps/sim/lib/desktop/min-version.ts | 96 + apps/sim/lib/desktop/panel-focus.test.ts | 91 + apps/sim/lib/desktop/panel-focus.ts | 50 + apps/sim/lib/desktop/update-feed.test.ts | 137 + apps/sim/lib/desktop/update-feed.ts | 119 + apps/sim/lib/invitations/core.ts | 22 + apps/sim/lib/invitations/error-messages.ts | 32 + apps/sim/lib/mcp/client.ts | 4 +- apps/sim/lib/mcp/domain-check.test.ts | 22 +- apps/sim/lib/mcp/domain-check.ts | 32 +- apps/sim/lib/mcp/oauth/probe.ts | 2 +- apps/sim/lib/mcp/oauth/revoke.test.ts | 10 +- apps/sim/lib/mcp/oauth/url-validation.ts | 2 +- apps/sim/lib/mcp/pinned-fetch.test.ts | 10 +- apps/sim/lib/mcp/pinned-fetch.ts | 4 +- apps/sim/lib/terminal/transport.ts | 161 + .../workspace-file-folder-manager.ts | 26 +- .../workspace/workspace-file-manager.ts | 38 +- apps/sim/lib/users/queries.ts | 13 + apps/sim/lib/workflows/lifecycle.ts | 17 - apps/sim/lib/workflows/utils.ts | 3 - apps/sim/package.json | 8 + apps/sim/stores/browser-session/store.test.ts | 64 + apps/sim/stores/browser-session/store.ts | 151 + apps/sim/stores/constants.ts | 17 + .../sim/stores/copilot-terminal/store.test.ts | 96 + apps/sim/stores/copilot-terminal/store.ts | 75 + apps/sim/stores/panel/types.ts | 8 + apps/sim/stores/sidebar/store.ts | 17 +- apps/sim/stores/tool-permission/store.ts | 60 + apps/sim/types/sim-desktop.d.ts | 7 + biome.json | 5 +- bun.lock | 803 +- package.json | 3 + packages/browser-protocol/package.json | 31 + packages/browser-protocol/src/index.ts | 248 + packages/browser-protocol/tsconfig.json | 5 + .../0273_copilot_tool_permission_decision.sql | 4 + .../db/migrations/meta/0273_snapshot.json | 18236 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 23 + packages/desktop-bridge/contract-snapshot.ts | 1365 ++ packages/desktop-bridge/package.json | 37 + packages/desktop-bridge/src/index.ts | 726 + .../src/local-filesystem-limits.ts | 33 + packages/desktop-bridge/tsconfig.json | 8 + .../chip-date-picker/chip-date-picker.tsx | 1 + .../dropdown-menu/dropdown-menu.tsx | 2 + packages/emcn/src/components/index.ts | 7 + packages/emcn/src/components/modal/modal.tsx | 1 + .../emcn/src/components/popover/popover.tsx | 2 + .../components/tab-strip/tab-strip.test.ts | 80 + .../src/components/tab-strip/tab-strip.tsx | 378 + packages/emcn/src/components/toast/toast.tsx | 1 + .../emcn/src/components/tooltip/tooltip.tsx | 1 + packages/security/package.json | 12 +- packages/security/src/hostnames.test.ts | 29 + packages/security/src/hostnames.ts | 28 + packages/security/src/ssrf.test.ts | 177 + packages/security/src/ssrf.ts | 92 + packages/terminal-protocol/package.json | 31 + packages/terminal-protocol/src/index.ts | 396 + packages/terminal-protocol/tsconfig.json | 5 + packages/testing/src/mocks/env-flags.mock.ts | 2 + .../src/mocks/input-validation.mock.ts | 2 - packages/testing/src/mocks/schema.mock.ts | 1 - packages/utils/src/index.ts | 7 +- packages/utils/src/string.test.ts | 58 +- packages/utils/src/string.ts | 52 + scripts/check-api-validation-contracts.ts | 11 +- scripts/check-desktop-bridge-contract.ts | 258 + scripts/check-desktop-ipc-contract.ts | 293 + 462 files changed, 72326 insertions(+), 4402 deletions(-) create mode 100644 .github/workflows/desktop-e2e.yml create mode 100644 .github/workflows/desktop-release.yml create mode 100644 apps/desktop/.gitignore create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/build/entitlements.mac.plist create mode 100644 apps/desktop/build/icon-dev.icns create mode 100644 apps/desktop/build/icon-local.icns create mode 100644 apps/desktop/build/icon-staging.icns create mode 100644 apps/desktop/build/icon.icns create mode 100644 apps/desktop/docs/electron-upgrade-checklist.md create mode 100644 apps/desktop/e2e/smoke.spec.ts create mode 100644 apps/desktop/electron-builder.yml create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/playwright.config.ts create mode 100644 apps/desktop/scripts/build.ts create mode 100644 apps/desktop/scripts/ensure-pty-prebuilds.ts create mode 100644 apps/desktop/scripts/install-local.ts create mode 100644 apps/desktop/src/main/app-routes.test.ts create mode 100644 apps/desktop/src/main/app-routes.ts create mode 100644 apps/desktop/src/main/atomic-json-file.ts create mode 100644 apps/desktop/src/main/browser-agent/cdp.test.ts create mode 100644 apps/desktop/src/main/browser-agent/cdp.ts create mode 100644 apps/desktop/src/main/browser-agent/context-menu.test.ts create mode 100644 apps/desktop/src/main/browser-agent/context-menu.ts create mode 100644 apps/desktop/src/main/browser-agent/driver.test.ts create mode 100644 apps/desktop/src/main/browser-agent/driver.ts create mode 100644 apps/desktop/src/main/browser-agent/errors.ts create mode 100644 apps/desktop/src/main/browser-agent/keyboard.test.ts create mode 100644 apps/desktop/src/main/browser-agent/keyboard.ts create mode 100644 apps/desktop/src/main/browser-agent/known-sessions.ts create mode 100644 apps/desktop/src/main/browser-agent/page-functions.test.ts create mode 100644 apps/desktop/src/main/browser-agent/page-functions.ts create mode 100644 apps/desktop/src/main/browser-agent/panel.test.ts create mode 100644 apps/desktop/src/main/browser-agent/panel.ts create mode 100644 apps/desktop/src/main/browser-agent/registry.ts create mode 100644 apps/desktop/src/main/browser-agent/session.test.ts create mode 100644 apps/desktop/src/main/browser-agent/session.ts create mode 100644 apps/desktop/src/main/browser-agent/url-guard.test.ts create mode 100644 apps/desktop/src/main/browser-agent/url-guard.ts create mode 100644 apps/desktop/src/main/browser-credentials/fill.test.ts create mode 100644 apps/desktop/src/main/browser-credentials/fill.ts create mode 100644 apps/desktop/src/main/browser-credentials/index.ts create mode 100644 apps/desktop/src/main/browser-credentials/origin.test.ts create mode 100644 apps/desktop/src/main/browser-credentials/origin.ts create mode 100644 apps/desktop/src/main/browser-credentials/os-auth.test.ts create mode 100644 apps/desktop/src/main/browser-credentials/os-auth.ts create mode 100644 apps/desktop/src/main/browser-credentials/vault.test.ts create mode 100644 apps/desktop/src/main/browser-credentials/vault.ts create mode 100644 apps/desktop/src/main/browser-import/browser-sources.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-cookies.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-cookies.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-crypto.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-crypto.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-favicons.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-favicons.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-passwords.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-passwords.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-profiles.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-profiles.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-site-names.test.ts create mode 100644 apps/desktop/src/main/browser-import/chromium-site-names.ts create mode 100644 apps/desktop/src/main/browser-import/cookie-translate.test.ts create mode 100644 apps/desktop/src/main/browser-import/cookie-translate.ts create mode 100644 apps/desktop/src/main/browser-import/import-service.test.ts create mode 100644 apps/desktop/src/main/browser-import/import-service.ts create mode 100644 apps/desktop/src/main/browser-import/index.ts create mode 100644 apps/desktop/src/main/browser-import/sqlite-source.ts create mode 100644 apps/desktop/src/main/browser-import/types.ts create mode 100644 apps/desktop/src/main/browser-sites/directory.test.ts create mode 100644 apps/desktop/src/main/browser-sites/directory.ts create mode 100644 apps/desktop/src/main/browser-sites/index.ts create mode 100644 apps/desktop/src/main/config.test.ts create mode 100644 apps/desktop/src/main/config.ts create mode 100644 apps/desktop/src/main/context-menu.test.ts create mode 100644 apps/desktop/src/main/context-menu.ts create mode 100644 apps/desktop/src/main/csp.test.ts create mode 100644 apps/desktop/src/main/csp.ts create mode 100644 apps/desktop/src/main/desktop-settings.test.ts create mode 100644 apps/desktop/src/main/desktop-settings.ts create mode 100644 apps/desktop/src/main/downloads.test.ts create mode 100644 apps/desktop/src/main/downloads.ts create mode 100644 apps/desktop/src/main/handoff.test.ts create mode 100644 apps/desktop/src/main/handoff.ts create mode 100644 apps/desktop/src/main/index.ts create mode 100644 apps/desktop/src/main/ipc.test.ts create mode 100644 apps/desktop/src/main/ipc.ts create mode 100644 apps/desktop/src/main/load-health.test.ts create mode 100644 apps/desktop/src/main/load-health.ts create mode 100644 apps/desktop/src/main/local-filesystem-grant-store.test.ts create mode 100644 apps/desktop/src/main/local-filesystem-grant-store.ts create mode 100644 apps/desktop/src/main/local-filesystem.test.ts create mode 100644 apps/desktop/src/main/local-filesystem.ts create mode 100644 apps/desktop/src/main/menu.test.ts create mode 100644 apps/desktop/src/main/menu.ts create mode 100644 apps/desktop/src/main/navigation.test.ts create mode 100644 apps/desktop/src/main/navigation.ts create mode 100644 apps/desktop/src/main/observability.test.ts create mode 100644 apps/desktop/src/main/observability.ts create mode 100644 apps/desktop/src/main/security-guards.test.ts create mode 100644 apps/desktop/src/main/security-guards.ts create mode 100644 apps/desktop/src/main/session-lifecycle.test.ts create mode 100644 apps/desktop/src/main/session-lifecycle.ts create mode 100644 apps/desktop/src/main/telemetry-policy.test.ts create mode 100644 apps/desktop/src/main/telemetry-policy.ts create mode 100644 apps/desktop/src/main/terminal/index.ts create mode 100644 apps/desktop/src/main/terminal/process-cwd.test.ts create mode 100644 apps/desktop/src/main/terminal/process-cwd.ts create mode 100644 apps/desktop/src/main/terminal/selection.test.ts create mode 100644 apps/desktop/src/main/terminal/service.test.ts create mode 100644 apps/desktop/src/main/terminal/session.test.ts create mode 100644 apps/desktop/src/main/terminal/session.ts create mode 100644 apps/desktop/src/main/terminal/shell-integration.test.ts create mode 100644 apps/desktop/src/main/terminal/shell-integration.ts create mode 100644 apps/desktop/src/main/terminal/tmux.test.ts create mode 100644 apps/desktop/src/main/terminal/tmux.ts create mode 100644 apps/desktop/src/main/tray.test.ts create mode 100644 apps/desktop/src/main/tray.ts create mode 100644 apps/desktop/src/main/updater.test.ts create mode 100644 apps/desktop/src/main/updater.ts create mode 100644 apps/desktop/src/main/window.test.ts create mode 100644 apps/desktop/src/main/window.ts create mode 100644 apps/desktop/src/main/windows.test.ts create mode 100644 apps/desktop/src/main/windows.ts create mode 100644 apps/desktop/src/preload/browser/index.ts create mode 100644 apps/desktop/src/preload/index.ts create mode 100644 apps/desktop/src/test/electron-mock.ts create mode 100644 apps/desktop/static/dock-icon-dev.png create mode 100644 apps/desktop/static/dock-icon-local.png create mode 100644 apps/desktop/static/dock-icon-staging.png create mode 100644 apps/desktop/static/dock-icon.png create mode 100644 apps/desktop/static/offline.html create mode 100644 apps/desktop/static/tray/simTemplate.png create mode 100644 apps/desktop/static/tray/simTemplate@2x.png create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/vitest.config.ts create mode 100644 apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts create mode 100644 apps/sim/app/_shell/desktop-title-bar.test.ts create mode 100644 apps/sim/app/_shell/desktop-title-bar.tsx create mode 100644 apps/sim/app/_shell/desktop-update-gate.tsx create mode 100644 apps/sim/app/_shell/providers/get-query-client.test.ts create mode 100644 apps/sim/app/api/copilot/tool-permission/route.ts create mode 100644 apps/sim/app/api/desktop/auth/handoff/route.test.ts create mode 100644 apps/sim/app/api/desktop/auth/handoff/route.ts create mode 100644 apps/sim/app/api/desktop/tool/authorize/route.test.ts create mode 100644 apps/sim/app/api/desktop/tool/authorize/route.ts create mode 100644 apps/sim/app/api/desktop/update/latest-mac.yml/route.ts create mode 100644 apps/sim/app/api/invitations/route.ts create mode 100644 apps/sim/app/api/mothership/execute/route.test.ts create mode 100644 apps/sim/app/api/mothership/local-files/stage/route.test.ts create mode 100644 apps/sim/app/api/mothership/local-files/stage/route.ts create mode 100644 apps/sim/app/desktop/auth/authorize-handoff.tsx create mode 100644 apps/sim/app/desktop/auth/page.test.tsx create mode 100644 apps/sim/app/desktop/auth/page.tsx create mode 100644 apps/sim/app/desktop/auth/validation.test.ts create mode 100644 apps/sim/app/desktop/auth/validation.ts create mode 100644 apps/sim/app/desktop/components/desktop-handoff-shell/desktop-handoff-shell.tsx create mode 100644 apps/sim/app/desktop/components/desktop-handoff-shell/index.ts create mode 100644 apps/sim/app/desktop/connect/complete/page.tsx create mode 100644 apps/sim/app/desktop/connect/connect-launcher.tsx create mode 100644 apps/sim/app/desktop/connect/page.tsx create mode 100644 apps/sim/app/desktop/connect/switch-account.tsx create mode 100644 apps/sim/app/desktop/connect/validation.test.ts create mode 100644 apps/sim/app/desktop/connect/validation.ts create mode 100644 apps/sim/app/desktop/done/page.tsx create mode 100644 apps/sim/app/oauth-error/page.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/session-expired/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/session-expired/session-expired.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/session-expired/session-expired.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/browser/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/desktop/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/terminal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/terminal/terminal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx create mode 100644 apps/sim/lib/api/contracts/desktop-auth.ts create mode 100644 apps/sim/lib/api/contracts/desktop-tool-authorization.ts create mode 100644 apps/sim/lib/auth/desktop-handoff.ts create mode 100644 apps/sim/lib/browser-agent/attachments.test.ts create mode 100644 apps/sim/lib/browser-agent/attachments.ts create mode 100644 apps/sim/lib/browser-agent/open-in-panel.ts create mode 100644 apps/sim/lib/browser-agent/transport.test.ts create mode 100644 apps/sim/lib/browser-agent/transport.ts create mode 100644 apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts create mode 100644 apps/sim/lib/copilot/persistence/tool-permission/index.ts create mode 100644 apps/sim/lib/copilot/request/tools/permission.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/permission.ts create mode 100644 apps/sim/lib/copilot/resources/availability.ts create mode 100644 apps/sim/lib/copilot/resources/types.test.ts create mode 100644 apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts create mode 100644 apps/sim/lib/copilot/tools/client/browser-tool-execution.ts create mode 100644 apps/sim/lib/copilot/tools/client/completion.ts create mode 100644 apps/sim/lib/copilot/tools/client/local-filesystem.test.ts create mode 100644 apps/sim/lib/copilot/tools/client/local-filesystem.ts create mode 100644 apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts create mode 100644 apps/sim/lib/copilot/tools/local-filesystem.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/delete-file.ts delete mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-backing.ts delete mode 100644 apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts delete mode 100644 apps/sim/lib/copilot/vfs/workflow-aliases.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/workflow-aliases.ts create mode 100644 apps/sim/lib/desktop/index.ts create mode 100644 apps/sim/lib/desktop/min-version.test.ts create mode 100644 apps/sim/lib/desktop/min-version.ts create mode 100644 apps/sim/lib/desktop/panel-focus.test.ts create mode 100644 apps/sim/lib/desktop/panel-focus.ts create mode 100644 apps/sim/lib/desktop/update-feed.test.ts create mode 100644 apps/sim/lib/desktop/update-feed.ts create mode 100644 apps/sim/lib/invitations/error-messages.ts create mode 100644 apps/sim/lib/terminal/transport.ts create mode 100644 apps/sim/stores/browser-session/store.test.ts create mode 100644 apps/sim/stores/browser-session/store.ts create mode 100644 apps/sim/stores/copilot-terminal/store.test.ts create mode 100644 apps/sim/stores/copilot-terminal/store.ts create mode 100644 apps/sim/stores/tool-permission/store.ts create mode 100644 apps/sim/types/sim-desktop.d.ts create mode 100644 packages/browser-protocol/package.json create mode 100644 packages/browser-protocol/src/index.ts create mode 100644 packages/browser-protocol/tsconfig.json create mode 100644 packages/db/migrations/0273_copilot_tool_permission_decision.sql create mode 100644 packages/db/migrations/meta/0273_snapshot.json create mode 100644 packages/desktop-bridge/contract-snapshot.ts create mode 100644 packages/desktop-bridge/package.json create mode 100644 packages/desktop-bridge/src/index.ts create mode 100644 packages/desktop-bridge/src/local-filesystem-limits.ts create mode 100644 packages/desktop-bridge/tsconfig.json create mode 100644 packages/emcn/src/components/tab-strip/tab-strip.test.ts create mode 100644 packages/emcn/src/components/tab-strip/tab-strip.tsx create mode 100644 packages/security/src/hostnames.test.ts create mode 100644 packages/security/src/hostnames.ts create mode 100644 packages/security/src/ssrf.test.ts create mode 100644 packages/security/src/ssrf.ts create mode 100644 packages/terminal-protocol/package.json create mode 100644 packages/terminal-protocol/src/index.ts create mode 100644 packages/terminal-protocol/tsconfig.json create mode 100644 scripts/check-desktop-bridge-contract.ts create mode 100644 scripts/check-desktop-ipc-contract.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b5c9ee63..887e4ed5ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,48 @@ jobs: echo "ℹ️ Not a release commit" fi + # Detect shell-code changes on dev/staging pushes. Web-only changes never + # need a desktop build (installed shells load the web app live); changes to + # the Electron app or the bridge packages trigger a per-env prerelease build + # (dev → alpha channel, staging → beta) that the env's update feed + # (/api/desktop/update) starts offering automatically. + detect-desktop-changes: + name: Detect Desktop Changes + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging') + outputs: + changed: ${{ steps.diff.outputs.changed }} + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 50 + + - name: Diff desktop paths + id: diff + env: + BEFORE: ${{ github.event.before }} + run: | + # Force pushes (dev resets) can reference a BEFORE we don't have; + # fall back to the previous commit, and to no build when even that + # is unavailable. + if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then + BEFORE="$(git rev-parse HEAD^ 2>/dev/null || echo '')" + fi + if [ -z "$BEFORE" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "ℹ️ No comparable base commit; skipping desktop prerelease" + exit 0 + fi + if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "✅ Desktop shell code changed" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "ℹ️ No desktop shell changes" + fi + # Run database migrations before images are promoted: the ECR latest/staging # tag push triggers CodePipeline, so migrating first guarantees the schema is # in place before the new app version deploys (replaces the removed ECS @@ -590,3 +632,172 @@ jobs: env: GH_PAT: ${{ secrets.GITHUB_TOKEN }} run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }} + + # Desktop release: builds, signs, notarizes, and attaches the macOS app to + # the GitHub release created above. Gated on the Apple signing secrets so a + # release pipeline run skips cleanly (instead of failing) until the Apple + # Developer account is provisioned — the moment the six secrets exist, the + # next vX.Y.Z release ships desktop artifacts with no further changes. + # Job-level `if:` cannot read the secrets context, hence the probe job. + check-desktop-signing: + name: Check Desktop Signing Secrets + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 2 + needs: [detect-version, detect-desktop-changes] + # !cancelled(): detect-desktop-changes is skipped on main (and + # detect-version tags only on main); either path may need the probe. + if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }} + outputs: + configured: ${{ steps.check.outputs.configured }} + steps: + - name: Probe Apple signing secrets + id: check + env: + CONFIGURED: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' && secrets.APPLE_TEAM_ID != '' }} + run: | + echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT" + if [ "$CONFIGURED" != "true" ]; then + echo "::warning::Desktop release skipped: Apple signing secrets are not configured (CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_TEAM_ID)." + fi + + desktop-release: + name: Desktop Release + needs: [create-release, check-desktop-signing, detect-version] + if: needs.check-desktop-signing.outputs.configured == 'true' + permissions: + contents: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.detect-version.outputs.version }} + publish: true + secrets: inherit + + # Per-env desktop prereleases: a dev/staging push that touches shell code + # publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev, + # vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed + # offers only its channel, so dev-pointed shells pick up alpha builds, + # staging-pointed shells beta builds, and prod-pointed shells stable + # releases — independently. Unlike stable releases, prereleases build even + # before the Apple signing secrets exist — unsigned, so the update pipeline + # is testable end to end; installed shells detect the missing Developer ID + # and offer a manual download instead of a Squirrel install. + create-desktop-prerelease: + name: Create Desktop Prerelease + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + needs: [detect-desktop-changes, check-desktop-signing] + # Requires the signing probe to have actually succeeded (not just "not + # cancelled") so a probe failure can't produce a release with no build. + if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }} + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Compute prerelease version and create draft release + id: version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + SIGNED: ${{ needs.check-desktop-signing.outputs.configured }} + run: | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi + # Prerelease core = next patch after the latest stable release, so + # channel builds always outrank the stable they are built on top of + # and are always superseded by the next stable. The run-attempt + # suffix keeps re-runs of the same workflow from colliding on the + # tag while preserving semver ordering. + LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' || true)" + LATEST="${LATEST:-v0.0.0}" + IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" + TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}." + if [ "$SIGNED" != "true" ]; then + NOTES="$NOTES + + ⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine \"/Applications/${APP_NAME}.app\"\`." + fi + # Draft until the build uploads its artifacts: drafts are invisible + # to the update feed, so a failed or in-flight build can never take + # the channel down with an assetless release. Publishing later also + # defers tag creation, so failed builds strand no tags. + gh release create "$TAG" \ + --draft \ + --prerelease \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --notes "$NOTES" + echo "version=$TAG" >> "$GITHUB_OUTPUT" + echo "✅ Created draft prerelease $TAG" + + desktop-prerelease: + name: Desktop Prerelease Build + needs: [create-desktop-prerelease, check-desktop-signing] + permissions: + contents: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.create-desktop-prerelease.outputs.version }} + publish: true + sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }} + secrets: inherit + + # The draft only becomes visible to the update feed once its artifacts are + # attached — this is what makes a dev/staging push atomic from the shell's + # point of view. + publish-desktop-prerelease: + name: Publish Desktop Prerelease + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + needs: [create-desktop-prerelease, desktop-prerelease] + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.create-desktop-prerelease.outputs.version }} + steps: + - name: Publish the draft release + run: gh release edit "$TAG" --draft=false + + # Keep the release list tidy: per channel, retain the newest 5 prereleases + # and delete the rest (with their tags, so dev force-resets don't strand + # commits behind stale tags). Leftover drafts (failed or superseded builds) + # are always garbage by this point — the current run's release is published. + prune-desktop-prereleases: + name: Prune Desktop Prereleases + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + needs: [publish-desktop-prerelease] + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + - name: Delete stale prereleases + run: | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi + gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \ + --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" | + while read -r TAG; do + [ -n "$TAG" ] || continue + echo "Deleting stale prerelease $TAG" + gh release delete "$TAG" --cleanup-tag --yes + done + + - name: Delete leftover draft prereleases + run: | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi + # Drafts have no tag ref, so delete by release id via the API + # (gh release delete resolves by tag, which is ambiguous for drafts). + gh api "repos/${GH_REPO}/releases?per_page=100" \ + --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" | + while read -r ID; do + [ -n "$ID" ] || continue + echo "Deleting leftover draft release $ID" + gh api -X DELETE "repos/${GH_REPO}/releases/${ID}" + done diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml new file mode 100644 index 0000000000..5a305244d1 --- /dev/null +++ b/.github/workflows/desktop-e2e.yml @@ -0,0 +1,85 @@ +name: Desktop E2E + +# Smoke coverage of the real Electron shell, plus an advisory canary leg +# against electron@latest so Chromium-cadence breakage surfaces before an +# upgrade is attempted (U18/U22). +# +# Manual-only for now: the desktop app is tested locally, so the +# pull_request trigger is disabled until desktop CI is turned back on. + +on: + workflow_dispatch: + +concurrency: + group: desktop-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: E2E (${{ matrix.electron }}) + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + electron: [pinned, latest] + continue-on-error: ${{ matrix.electron == 'latest' }} + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Switch to electron@latest (canary) + if: matrix.electron == 'latest' + working-directory: apps/desktop + run: bun add -d electron@latest + + - name: Bundle main and preload + working-directory: apps/desktop + run: bun run build + + - name: Run Playwright _electron smoke suite + working-directory: apps/desktop + run: bunx playwright test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: desktop-e2e-results-${{ matrix.electron }} + path: apps/desktop/test-results + retention-days: 7 + + package-smoke: + name: Unsigned package smoke + runs-on: macos-14 + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Bundle and package unsigned + working-directory: apps/desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: | + bun run build + bunx electron-builder --mac dir --publish never diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000000..f0f512b526 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,212 @@ +name: Desktop Release (macOS) + +# Builds, signs, notarizes, and uploads the desktop app to an existing GitHub +# release. Ordering is load-bearing: scripts/create-single-release.ts skips +# creation when the tag already exists, so this workflow must never create the +# release itself — it only uploads assets after create-release ran (wired via +# workflow_call from ci.yml with needs: [create-release]). + +on: + workflow_call: + inputs: + version: + description: Release tag (vX.Y.Z) to attach desktop artifacts to + required: true + type: string + publish: + description: Upload artifacts to the GitHub release + required: false + type: boolean + default: true + sign: + description: Sign and notarize with the Apple Developer identity. When + false (prerelease testing before the signing secrets exist) the build + is packaged unsigned; installed shells detect this and offer manual + downloads instead of Squirrel installs. + required: false + type: boolean + default: true + workflow_dispatch: + inputs: + version: + description: Release tag (vX.Y.Z) to attach desktop artifacts to + required: true + type: string + publish: + description: Upload artifacts to the GitHub release + required: false + type: boolean + default: false + sign: + description: Sign and notarize with the Apple Developer identity + required: false + type: boolean + default: true + +permissions: + contents: write + +jobs: + build-sign-notarize: + name: Build, Sign, Notarize + runs-on: macos-14 + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Cache Electron binaries + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-cache-${{ runner.os }}-${{ hashFiles('apps/desktop/package.json') }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Inject release version + env: + VERSION: ${{ inputs.version }} + run: | + SEMVER="${VERSION#v}" + if ! [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.].+)?$ ]]; then + echo "Refusing to build: '$VERSION' is not a vX.Y.Z release tag" >&2 + exit 1 + fi + npm pkg set version="$SEMVER" --prefix apps/desktop + INJECTED="$(node -p "require('./apps/desktop/package.json').version")" + if [ "$INJECTED" != "$SEMVER" ]; then + echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2 + exit 1 + fi + + # Prerelease versions carry their environment in the tag: -alpha.N is a + # dev build, -beta.N a staging build. The channel decides the app's + # identity (name/bundle id — a separate app per environment, installable + # side by side) and the default origin baked into the bundle, which in + # turn selects the update feed the installed app polls. + - name: Resolve channel identity + id: channel + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + *-alpha.*) + NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;; + *-beta.*) + NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;; + *) + NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;; + esac + { + echo "name=$NAME" + echo "app_id=$APP_ID" + echo "origin=$ORIGIN" + } >> "$GITHUB_OUTPUT" + echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}" + + - name: Bundle main and preload + working-directory: apps/desktop + env: + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + run: bun run build + + - name: Write App Store Connect API key + if: ${{ inputs.sign }} + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + mkdir -p "$RUNNER_TEMP/appstoreconnect" + printf '%s' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/appstoreconnect/AuthKey.p8" + chmod 600 "$RUNNER_TEMP/appstoreconnect/AuthKey.p8" + + - name: Package, sign, and notarize + if: ${{ inputs.sign }} + working-directory: apps/desktop + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + # Absolute path — @electron/notarize reads this via Node fs, which + # does not expand a leading '~'. + APPLE_API_KEY: ${{ runner.temp }}/appstoreconnect/AuthKey.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + run: > + bunx electron-builder --mac --publish never + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" + + # Unsigned prerelease path: no Developer ID, no notarization. The + # binaries end up ad-hoc/linker-signed, which runs locally but gets + # quarantined when downloaded — fine for testing the update pipeline. + - name: Package unsigned + if: ${{ !inputs.sign }} + working-directory: apps/desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + run: > + bunx electron-builder --mac --publish never -c.mac.notarize=false + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" + + - name: Validate signature and notarization + if: ${{ inputs.sign }} + run: | + DMG="$(ls apps/desktop/release/*.dmg | head -1)" + xcrun stapler validate "$DMG" + hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet + spctl --assess --type execute --verbose /tmp/sim-dmg/*.app + codesign --verify --deep --strict /tmp/sim-dmg/*.app + hdiutil detach /tmp/sim-dmg -quiet + + - name: Upload artifacts to the release + if: ${{ inputs.publish }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + # electron-builder's GitHub provider always names the manifest + # latest-mac.yml (channels are a generic-provider concept), and the + # update feed expects exactly that asset name on every release — + # normalize defensively in case a config change ever produces a + # channel-named manifest. + YML="$(find apps/desktop/release -maxdepth 1 -name '*-mac.yml' | head -1)" + if [ -z "$YML" ]; then + echo "::error::No *-mac.yml updater manifest found in apps/desktop/release" + exit 1 + fi + if [ "$(basename "$YML")" != "latest-mac.yml" ]; then + mv "$YML" apps/desktop/release/latest-mac.yml + fi + gh release upload "$VERSION" \ + apps/desktop/release/*.dmg \ + apps/desktop/release/*.zip \ + apps/desktop/release/*.blockmap \ + apps/desktop/release/latest-mac.yml \ + --clobber + + - name: Upload artifacts to the workflow run + if: ${{ !inputs.publish }} + uses: actions/upload-artifact@v4 + with: + name: sim-desktop-${{ inputs.version }} + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + apps/desktop/release/*-mac.yml + retention-days: 7 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 3a934e51ed..195d19dcd3 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,15 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: Desktop bridge contract audit + run: bun run check:desktop-bridge + + # Complements the bridge audit above, which compares against a snapshot + # this same PR is allowed to regenerate. This one derives every fact from + # the source both sides execute, so it has no such blind spot. + - name: Desktop IPC contract audit + run: bun run check:desktop-ipc + - name: Shared utils enforcement audit run: bun run check:utils diff --git a/.gitignore b/.gitignore index a8a0d8e2fd..68ef944d64 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ package-lock.json /apps/**/out/ /apps/**/.next/ /apps/**/build +# apps/desktop/build holds electron-builder INPUTS (icon, entitlements), not outputs +!/apps/desktop/build # production /build diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000000..22cddc211b --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,5 @@ +dist/ +release/ +build/generated-icon.icns +playwright-report/ +test-results/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000000..8a52ef91c0 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,194 @@ +# Sim Desktop (macOS) + +A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://sim.ai`) 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) +static/ # bundled local pages (offline.html) +e2e/ # Playwright _electron smoke suite +``` + +## Local development + +```bash +bun install # workspace root +cd apps/desktop +bun run dev # bundle + launch against https://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=` 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 `/desktop/auth?state=&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:/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) + +Host lists live in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`, `IN_WINDOW_IDP_HOSTS`). Verified so far: Google + Microsoft blocked (by policy, not spike); GitHub assumed lenient. **Before GA, run the spike**: GitHub sign-in, consumer-Microsoft, a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox), SSO, and Turnstile-on-signup in a packaged build, then update the lists 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:`, 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. Packaged `.icns` files live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icns` path consumed by electron-builder. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs. + +CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`): +- Runs 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. 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-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: + +```ts +// 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 && } +``` + +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--/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 GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session. +- Channels: stable builds (`X.Y.Z`) follow `latest`; `-beta.N` builds follow `beta` (never attach a beta `latest-mac.yml` to a stable tag). +- 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. diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist new file mode 100644 index 0000000000..446fe171da --- /dev/null +++ b/apps/desktop/build/entitlements.mac.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/apps/desktop/build/icon-dev.icns b/apps/desktop/build/icon-dev.icns new file mode 100644 index 0000000000000000000000000000000000000000..8a602e51b277b16efd44e8efa7237313772aebb3 GIT binary patch literal 124879 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 ?4ZrM-Rr9%h^5)j8SK9azNU^dgm4{cq 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

JrOF{Z;QvpIp8`41Kvn3ZI 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 z%0A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�n(sIh+8xD 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/build/icon-local.icns b/apps/desktop/build/icon-local.icns new file mode 100644 index 0000000000000000000000000000000000000000..83593f66398b003c8467a1b0ab383c52e41c4ff6 GIT binary patch literal 124854 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≪BAZXIeY!8MGG3 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 z%0D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

{8;T&uzqE( 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 = { + '/workspace': `Sim Fixture +

fixture-app

+ + + `, + '/workspace/two': '

second-route

', + '/login': '

fixture-login

', +} + +function startFixtureServer(): Promise<{ server: Server; origin: string }> { + return new Promise((resolvePromise) => { + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + const body = PAGES[path] + if (!body) { + response.writeHead(404, { 'Content-Type': 'text/html' }).end('

not found

') + return + } + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(body) + }) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + resolvePromise({ server, origin: `http://127.0.0.1:${port}` }) + }) + }) +} + +async function launchApp(origin: string): Promise { + return electron.launch({ + args: ['.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: origin, + SIM_DESKTOP_USER_DATA: mkdtempSync(join(tmpdir(), 'sim-desktop-e2e-')), + }, + }) +} + +test.describe('desktop shell smoke', () => { + let server: Server + let origin: string + let app: ElectronApplication + + test.beforeAll(async () => { + ;({ server, origin } = await startFixtureServer()) + }) + + test.afterAll(async () => { + server.close() + }) + + test.afterEach(async () => { + await app?.close().catch(() => {}) + }) + + test('loads the configured origin top-level', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await expect(window.locator('#app')).toHaveText('fixture-app') + expect(window.url()).toBe(`${origin}/workspace`) + }) + + test('internal window.open creates an independent full Sim window', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + const newWindowPromise = app.waitForEvent('window') + await window.locator('#internal-blank').click() + const secondWindow = await newWindowPromise + await expect(secondWindow.locator('#two')).toHaveText('second-route') + await expect(window.locator('#app')).toHaveText('fixture-app') + expect(app.windows()).toHaveLength(2) + }) + + test('external window.open goes to the system browser, never a new app window', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await app.evaluate(({ shell }) => { + const opened: string[] = [] + ;(globalThis as { __openedExternal?: string[] }).__openedExternal = opened + shell.openExternal = async (url: string) => { + opened.push(url) + } + }) + await window.locator('#external-blank').click() + await expect + .poll(() => + app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal) + ) + .toEqual(['https://docs.sim.ai/x']) + expect(app.windows()).toHaveLength(1) + await expect(window.locator('#app')).toHaveText('fixture-app') + }) + + test('unreachable origin shows the bundled offline page', async () => { + app = await launchApp('http://127.0.0.1:1') + const window = await app.firstWindow() + await window.waitForSelector('#retry', { timeout: 30_000 }) + expect(window.url().startsWith('file:')).toBe(true) + }) +}) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000000..595ce865a0 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,69 @@ +appId: ai.sim.desktop +productName: Sim +copyright: Copyright © 2026 Sim + +directories: + output: release + buildResources: build + +files: + - dist/** + - static/** + - package.json + +asar: true + +# Native modules cannot be dlopen'd from inside an asar. +# `scripts/ensure-pty-prebuilds.ts` guarantees both arch prebuilds are present +# before packaging; see mac.x64ArchFiles for how the universal merge treats them. +asarUnpack: + - "**/node_modules/@lydell/node-pty-*/prebuilds/**" + +# Space-free regardless of productName ("Sim Dev" etc.): GitHub rewrites +# asset names containing spaces, which would desync the electron-updater +# manifest from the uploaded files. +artifactName: "Sim-${version}-${arch}.${ext}" + +electronFuses: + runAsNode: false + enableCookieEncryption: true + enableNodeOptionsEnvironmentVariable: false + enableNodeCliInspectArguments: false + enableEmbeddedAsarIntegrityValidation: true + onlyLoadAppFromAsar: true + +mac: + category: public.app-category.developer-tools + target: + - target: dmg + arch: [universal] + - target: zip + arch: [universal] + icon: build/generated-icon.icns + # node-pty keeps each architecture's binary at its own path, so both halves of + # the universal build carry an identical copy of both. @electron/universal + # refuses single-arch Mach-O files it wasn't told about, so name them here — + # it then takes one copy instead of trying to lipo two same-arch binaries. + x64ArchFiles: "**/node-pty-darwin-*/prebuilds/**" + # Both halves bundle byte-identical JS, so the merge is pure overhead and + # @electron/universal reuses a single app.asar anyway. Skipping it also avoids + # @electron/asar 3.x, which calls minimatch through a default export the + # workspace-wide minimatch@10 pin no longer provides. + mergeASARs: false + hardenedRuntime: true + gatekeeperAssess: false + entitlements: build/entitlements.mac.plist + entitlementsInherit: build/entitlements.mac.plist + notarize: true + +dmg: + sign: false + +# node-pty ships pure N-API prebuilds, which are ABI-stable across Node and +# Electron versions, so there is nothing to rebuild against Electron's ABI. +npmRebuild: false + +publish: + provider: github + owner: simstudioai + repo: sim diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000000..0ac8f76658 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,58 @@ +{ + "name": "@sim/desktop", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "description": "Sim desktop app for macOS — Electron shell around the hosted web app", + "author": "Sim ", + "homepage": "https://sim.ai", + "type": "module", + "main": "dist/main.cjs", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "scripts": { + "build": "bun run scripts/ensure-pty-prebuilds.ts && bun run scripts/build.ts", + "dev": "bun run scripts/build.ts && electron .", + "start": "electron .", + "package:dir": "bun run build && electron-builder --mac dir --publish never", + "package:mac": "bun run build && electron-builder --mac --publish never", + "package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none", + "install:local": "bun run scripts/install-local.ts", + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "@lydell/node-pty": "1.2.0-beta.12", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", + "@sim/utils": "workspace:*", + "@xterm/headless": "6.0.0", + "electron-updater": "6.8.9", + "micromatch": "4.0.8", + "safe-regex2": "5.1.0" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@sim/tsconfig": "workspace:*", + "@types/micromatch": "4.0.10", + "@types/node": "24.2.1", + "electron": "43.1.1", + "electron-builder": "26.15.3", + "esbuild": "0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.0" + } +} diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000000..142b184e05 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 90_000, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], +}) diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts new file mode 100644 index 0000000000..18880124fa --- /dev/null +++ b/apps/desktop/scripts/build.ts @@ -0,0 +1,95 @@ +import { copyFileSync } from 'node:fs' +import { build } from 'esbuild' + +const watch = process.argv.includes('--watch') + +// Optional build-time default server origin (pre-release shares pointed at a +// non-prod environment): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai. +// Baked into the bundle so it applies to fresh installs with no settings — +// unlike the SIM_DESKTOP_ORIGIN env var, which only affects terminal-launched +// processes. Official builds leave it unset (default https://sim.ai). +const bakedDefaultOrigin = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? '' +if ( + bakedDefaultOrigin && + !/^https:\/\/[^\s/]+$/.test(bakedDefaultOrigin) && + !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(bakedDefaultOrigin) +) { + console.error( + `SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin or http://localhost (got "${bakedDefaultOrigin}")` + ) + process.exit(1) +} +if (bakedDefaultOrigin) { + console.log(`• Baking default server origin: ${bakedDefaultOrigin}`) +} + +/** Selects the branded app icon that matches the build's baked environment. */ +function iconForOrigin(origin: string): string { + if (!origin) return 'build/icon.icns' + const host = new URL(origin).hostname.toLowerCase() + if (host === 'localhost' || host === '127.0.0.1') return 'build/icon-local.icns' + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return 'build/icon-dev.icns' + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) { + return 'build/icon-staging.icns' + } + return 'build/icon.icns' +} + +const appIcon = iconForOrigin(bakedDefaultOrigin) +copyFileSync(appIcon, 'build/generated-icon.icns') +console.log(`• Selecting desktop icon: ${appIcon}`) + +const common = { + bundle: true, + platform: 'node' as const, + format: 'cjs' as const, + target: 'node22', + sourcemap: true, + // node-pty resolves a prebuilt .node binary at runtime, so it must stay + // external and be loaded from node_modules rather than inlined here. + external: ['electron', '@lydell/node-pty'], + tsconfig: 'tsconfig.json', + logLevel: 'info' as const, + define: { + 'process.env.SIM_DESKTOP_DEFAULT_ORIGIN': JSON.stringify(bakedDefaultOrigin), + }, +} + +async function run(): Promise { + if (watch) { + const { context } = await import('esbuild') + const mainCtx = await context({ + ...common, + entryPoints: ['src/main/index.ts'], + outfile: 'dist/main.cjs', + }) + const preloadCtx = await context({ + ...common, + entryPoints: ['src/preload/index.ts'], + outfile: 'dist/preload.cjs', + }) + // Separate from the main-window preload: this one is injected into + // untrusted pages in the built-in browser and must stay minimal. + const browserPreloadCtx = await context({ + ...common, + entryPoints: ['src/preload/browser/index.ts'], + outfile: 'dist/browser-preload.cjs', + }) + await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()]) + return + } + await Promise.all([ + build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }), + build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }), + build({ + ...common, + entryPoints: ['src/preload/browser/index.ts'], + outfile: 'dist/browser-preload.cjs', + }), + ]) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/desktop/scripts/ensure-pty-prebuilds.ts b/apps/desktop/scripts/ensure-pty-prebuilds.ts new file mode 100644 index 0000000000..a41f94cfae --- /dev/null +++ b/apps/desktop/scripts/ensure-pty-prebuilds.ts @@ -0,0 +1,94 @@ +/** + * Fetches the node-pty prebuilt binaries for every architecture the macOS + * universal build ships. + * + * `@lydell/node-pty` selects its native binary at runtime from a per-arch + * package (`@lydell/node-pty-darwin-arm64`, `-darwin-x64`), each declaring a + * matching `cpu` field. Package managers honour that field, so installing on + * an arm64 Mac leaves the x64 binary absent and the x64 half of the universal + * app ships without a working PTY. Fetching the tarball directly is the only + * way to get both without lying about the host architecture. + * + * Both binaries live at distinct paths, so `@electron/universal` never has to + * lipo them together — it sees byte-identical trees in both halves and keeps + * one. That is why this build needs no `x64ArchFiles` rule. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))) +const workspaceRoot = dirname(dirname(desktopDir)) + +interface DesktopPackageJson { + dependencies?: Record +} + +async function pinnedVersion(): Promise { + const pkg = (await import(join(desktopDir, 'package.json'), { + with: { type: 'json' }, + })) as { default: DesktopPackageJson } + const version = pkg.default.dependencies?.['@lydell/node-pty'] + if (!version) { + throw new Error('@lydell/node-pty is not a dependency of @sim/desktop') + } + // Exact pin only: a range would let the two halves of a universal build + // resolve different binaries. + if (!/^\d+\.\d+\.\d+/.test(version)) { + throw new Error(`@lydell/node-pty must be pinned to an exact version (got "${version}")`) + } + return version +} + +/** Where the workspace hoists installed packages. */ +function packageDir(arch: string): string { + return join(workspaceRoot, 'node_modules', '@lydell', `node-pty-${arch}`) +} + +async function fetchPrebuild(arch: string, version: string): Promise { + const name = `node-pty-${arch}` + const url = `https://registry.npmjs.org/@lydell/${name}/-/${name}-${version}.tgz` + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + const staging = mkdtempSync(join(tmpdir(), 'sim-pty-prebuild-')) + try { + const tarball = join(staging, 'package.tgz') + writeFileSync(tarball, Buffer.from(await response.arrayBuffer())) + execFileSync('tar', ['-xzf', tarball, '-C', staging], { stdio: 'pipe' }) + + const target = packageDir(arch) + mkdirSync(dirname(target), { recursive: true }) + rmSync(target, { recursive: true, force: true }) + renameSync(join(staging, 'package'), target) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +async function run(): Promise { + const version = await pinnedVersion() + for (const arch of REQUIRED_ARCHES) { + const dir = packageDir(arch) + if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { + console.log(`• node-pty prebuild present: ${arch}`) + continue + } + console.log(`• Fetching node-pty prebuild: ${arch}@${version}`) + await fetchPrebuild(arch, version) + if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { + throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`) + } + } +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts new file mode 100644 index 0000000000..efd27652a4 --- /dev/null +++ b/apps/desktop/scripts/install-local.ts @@ -0,0 +1,181 @@ +/** + * Local dev-install: packages the app from the current checkout and installs + * it into /Applications — the "run it like a real Mac app" loop before + * official distribution. Signing/notarization are not involved; the locally + * built app never carries a quarantine flag, so Gatekeeper doesn't mind. + * + * bun run install:local # plain Sim.app (origin unchanged) + * bun run install:local --local # Sim Local.app → http://localhost:3000 + * bun run install:local --dev # Sim Dev.app → https://www.dev.sim.ai + * bun run install:local --staging # Sim Staging.app → https://www.staging.sim.ai + * bun run install:local --prod # Sim.app → https://www.sim.ai + * bun run install:local --no-open # build → install only + * + * Each environment is a separate app (name, bundle id, install path, userData, + * single-instance lock, update feed), so all four can be installed and run + * side by side. The flag both bakes the default server origin into the build + * and writes the app's persisted settings (same as changing the server URL in + * Settings). A running installed copy of the SAME channel is quit before + * replacing; other channels keep running. + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +interface ChannelIdentity { + /** Display + bundle name; also the userData directory name. */ + name: string + appId: string + /** Baked default origin + persisted settings origin. Unset = leave as-is. */ + origin?: string +} + +/** Must stay in sync with APP_NAME_FOR_CHANNEL in src/main/config.ts. */ +const CHANNEL_FLAGS: Record = { + '--local': { name: 'Sim Local', appId: 'ai.sim.desktop.local', origin: 'http://localhost:3000' }, + '--dev': { name: 'Sim Dev', appId: 'ai.sim.desktop.dev', origin: 'https://www.dev.sim.ai' }, + '--staging': { + name: 'Sim Staging', + appId: 'ai.sim.desktop.staging', + origin: 'https://www.staging.sim.ai', + }, + // Bare sim.ai, matching official prod builds (config.ts) — www.sim.ai + // would land in a different cookie partition than a real install. + '--prod': { name: 'Sim', appId: 'ai.sim.desktop', origin: 'https://sim.ai' }, +} + +const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' } + +const channelFlags = process.argv.filter((arg) => arg in CHANNEL_FLAGS) +if (channelFlags.length > 1) { + console.error(`✖ Pass at most one of ${Object.keys(CHANNEL_FLAGS).join(', ')}`) + process.exit(1) +} +const identity = channelFlags.length === 1 ? CHANNEL_FLAGS[channelFlags[0]] : DEFAULT_IDENTITY + +const APP_NAME = `${identity.name}.app` +const INSTALL_PATH = `/Applications/${APP_NAME}` +const RELEASE_DIRS = ['release/mac-universal', 'release/mac-arm64', 'release/mac'] +/** Matches the app's userData path (app.setName(...) in src/main/index.ts). */ +const SETTINGS_PATH = join(homedir(), `Library/Application Support/${identity.name}/settings.json`) + +function run(command: string, args: string[], env?: Record): void { + const result = spawnSync(command, args, { + stdio: 'inherit', + env: env ? { ...process.env, ...env } : process.env, + }) + if (result.status !== 0) { + console.error(`\n✖ ${command} ${args.join(' ')} failed`) + process.exit(result.status ?? 1) + } +} + +function localBuildStamp(): string { + try { + const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD']).toString().trim() + const dirty = execFileSync('git', ['status', '--porcelain']).toString().trim() ? '+dirty' : '' + return `${sha}${dirty}` + } catch { + return 'unknown' + } +} + +function quitInstalledApp(): void { + // Match only processes launched from this channel's installed bundle — + // never the dev instance running out of node_modules/electron, and never + // another channel's install. + const running = spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status === 0 + if (!running) return + console.log('• Quitting the running installed app…') + spawnSync('osascript', ['-e', `tell application "${identity.name}" to quit`]) + // Poll briefly; fall back to a hard kill so the install never half-replaces + // a live bundle. + for (let i = 0; i < 20; i++) { + if (spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status !== 0) return + execFileSync('sleep', ['0.25']) + } + spawnSync('pkill', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]) +} + +/** + * Points the installed app at an environment by writing its persisted + * settings — the same field the in-app Settings window edits. Merges into the + * existing file so window bounds and other settings survive. + */ +function applyOrigin(origin: string): void { + let settings: Record = {} + try { + settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')) as Record + } catch { + // Missing or corrupt settings file — start fresh; the app validates on load. + } + settings.origin = origin + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }) + writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`) + console.log(`• Server origin set to ${origin}`) + if (origin.startsWith('http://localhost')) { + console.log(' (make sure the sim dev server is running on :3000)') + } +} + +console.log(`• Packaging ${identity.name} from the current checkout…`) +run( + 'bun', + ['run', 'build'], + identity.origin ? { SIM_DESKTOP_DEFAULT_ORIGIN: identity.origin } : undefined +) +// electron-builder only writes the output dir for the CURRENT target/arch +// (e.g. mac-arm64); other release dirs from older runs (a universal dmg +// build, an Intel machine) would survive and win the pick below. Remove all +// candidates first so the only app found is the one just built. +for (const dir of RELEASE_DIRS) { + rmSync(dir, { recursive: true, force: true }) +} +// Same as package:dir, minus trusted timestamps: codesign's --timestamp does +// a network round trip to Apple PER FILE (hundreds inside the Electron +// framework), which turns local signing into a multi-minute stall. Local +// installs don't need timestamped signatures — only notarized distribution +// builds do. +run('bunx', [ + 'electron-builder', + '--mac', + 'dir', + '--publish', + 'never', + '-c.mac.timestamp=none', + `-c.productName=${identity.name}`, + `-c.appId=${identity.appId}`, +]) + +const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync) +if (!builtApp) { + console.error(`✖ No built app found under ${RELEASE_DIRS.join(', ')}`) + process.exit(1) +} + +// Without a Developer ID identity electron-builder skips signing entirely, +// and its fuse-flip step invalidates Electron's shipped ad-hoc seal — Apple +// silicon then SIGKILLs the binary at launch (Code Signature Invalid). +// Re-seal the whole bundle ad-hoc; ditto below preserves the signature. +console.log('• Ad-hoc signing the bundle…') +run('codesign', ['--force', '--deep', '--sign', '-', builtApp]) + +quitInstalledApp() + +console.log(`• Installing ${builtApp} → ${INSTALL_PATH}`) +rmSync(INSTALL_PATH, { recursive: true, force: true }) +// ditto preserves the code signature and extended attributes, unlike cp. +run('ditto', [builtApp, INSTALL_PATH]) + +if (identity.origin) { + applyOrigin(identity.origin) +} + +console.log(`✔ Installed ${identity.name} (${localBuildStamp()}) to ${INSTALL_PATH}`) + +if (!process.argv.includes('--no-open')) { + run('open', [INSTALL_PATH]) +} else { + console.log(` Launch it with: open ${INSTALL_PATH}`) +} diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts new file mode 100644 index 0000000000..245019bdc5 --- /dev/null +++ b/apps/desktop/src/main/app-routes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { newChatRoute, settingsRoute } from '@/main/app-routes' + +describe('app routes', () => { + it('derives the new-chat route from the last workspace route', () => { + expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home') + expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home') + expect(newChatRoute('/account')).toBe('/workspace') + expect(newChatRoute(undefined)).toBe('/workspace') + expect(newChatRoute('//evil.example')).toBe('/workspace') + }) + + it('derives the settings route from the last workspace route', () => { + expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop') + expect(settingsRoute('/account')).toBe('/workspace') + expect(settingsRoute(undefined)).toBe('/workspace') + expect(settingsRoute('//evil.example')).toBe('/workspace') + }) +}) diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts new file mode 100644 index 0000000000..6e877edd01 --- /dev/null +++ b/apps/desktop/src/main/app-routes.ts @@ -0,0 +1,41 @@ +import { isSafeInternalPath } from '@/main/config' + +/** + * Routes into the Sim web app that the shell navigates to on the user's + * behalf, from a menu item or a tray item. + * + * They live here rather than in either caller because both the tray and the + * application menu offer the same destinations. Keeping them in `tray.ts` made + * `index.ts` import tray internals to wire up menu items that have nothing to + * do with the tray, and the tray can be absent entirely. + */ + +/** Workspace id from the last visited route, or null when it carries none. */ +function workspaceIdFromRoute(lastRoute: string | undefined): string | null { + if (isSafeInternalPath(lastRoute)) { + const match = /^\/workspace\/([^/?#]+)/.exec(lastRoute) + if (match) { + return match[1] + } + } + return null +} + +/** + * Route for "New Chat": the home (chat) surface of the workspace the user was + * last in, falling back to the workspace picker redirect when the last route + * carries no workspace. + */ +export function newChatRoute(lastRoute: string | undefined): string { + const workspaceId = workspaceIdFromRoute(lastRoute) + return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace' +} + +/** + * Route for "Settings…": the Sim app's settings surface for the workspace the + * user was last in, falling back to the workspace picker redirect. + */ +export function settingsRoute(lastRoute: string | undefined): string { + const workspaceId = workspaceIdFromRoute(lastRoute) + return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace' +} diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts new file mode 100644 index 0000000000..4561b4c044 --- /dev/null +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -0,0 +1,72 @@ +import { mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { mkdir, rename, unlink, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** Owner-only, matching every store that keeps user data in userData. */ +const FILE_MODE = 0o600 + +/** + * Distinct per call, not just per process. + * + * The pid keeps a second Sim process from sharing the path — the site + * directory used a bare `.tmp` and could be clobbered by exactly that. The + * counter covers the other half: these stores are read-modify-write with no + * lock, so two overlapping writes to the SAME store in one process (a password + * import racing a forget) would otherwise both truncate and write the one + * temp file, and the first rename would publish a spliced blob. The vault + * treats an unparseable file as empty, so that surfaces as every saved + * password silently vanishing. + */ +let temporaryFileCounter = 0 +function temporaryPathFor(filePath: string): string { + temporaryFileCounter += 1 + return `${filePath}.${process.pid}.${temporaryFileCounter}.tmp` +} + +/** + * Crash-safe JSON writes for the small encrypted stores in userData. + * + * Every one of them (local-filesystem grants, the credential vault, the site + * directory) had written this same temp-file-then-rename sequence by hand, and + * they had already drifted: two scoped the temporary file by pid and the third + * did not, so two Sim processes writing that store could clobber each other + * through a shared `.tmp` path. Owning the sequence once removes the class. + */ +export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise { + await mkdir(dirname(filePath), { recursive: true }) + const temporaryPath = temporaryPathFor(filePath) + await writeFile(temporaryPath, JSON.stringify(value), { mode: FILE_MODE }) + await rename(temporaryPath, filePath) +} + +/** + * The same sequence for a caller that cannot await. + * + * Only the settings store needs this: it flushes on `before-quit`, where the + * event loop stops before a promise would settle. `indent` because that file + * is one users open and edit by hand. + */ +export function writeJsonFileAtomicallySync( + filePath: string, + value: unknown, + indent?: number +): void { + mkdirSync(dirname(filePath), { recursive: true }) + const temporaryPath = temporaryPathFor(filePath) + writeFileSync(temporaryPath, JSON.stringify(value, null, indent), { mode: FILE_MODE }) + renameSync(temporaryPath, filePath) +} + +/** + * Deletes a store file, treating "already gone" as success. + * + * Anything else rethrows: a store that reports a successful `clear()` after an + * EACCES tells sign-out teardown the data is gone when it is still on disk. + */ +export async function removeFileIfPresent(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} diff --git a/apps/desktop/src/main/browser-agent/cdp.test.ts b/apps/desktop/src/main/browser-agent/cdp.test.ts new file mode 100644 index 0000000000..95173dd37c --- /dev/null +++ b/apps/desktop/src/main/browser-agent/cdp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { WebContentsView } from 'electron' +import { setColorScheme } from '@/main/browser-agent/cdp' + +describe('browser-agent CDP theme', () => { + it('emulates explicit light and dark preferences', async () => { + const contents = new WebContentsView().webContents + + await setColorScheme(contents, 'dark') + await setColorScheme(contents, 'light') + + expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([ + [ + 'Emulation.setEmulatedMedia', + { features: [{ name: 'prefers-color-scheme', value: 'dark' }] }, + ], + [ + 'Emulation.setEmulatedMedia', + { features: [{ name: 'prefers-color-scheme', value: 'light' }] }, + ], + ]) + }) + + it('clears the override for the system preference', async () => { + const contents = new WebContentsView().webContents + + await setColorScheme(contents, 'system') + + expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setEmulatedMedia', { + features: [], + }) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts new file mode 100644 index 0000000000..b19496ea74 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -0,0 +1,176 @@ +/** + * CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles + * the page states that would otherwise wedge automation (JS dialogs, file + * choosers), captures screenshots that work even while the view is hidden, + * and dispatches TRUSTED input (key events, text insertion). Trusted input + * goes through Blink's real input pipeline — unlike synthetic DOM + * `KeyboardEvent`s, it triggers default actions (select-all, deletion, caret + * movement, character insertion) and is honored by code editors. The user + * sees and drives the real embedded page, so there is no screencast. + */ +import type { BrowserTheme } from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import type { WebContents } from 'electron' + +const logger = createLogger('BrowserAgentCdp') + +const PROTOCOL_VERSION = '1.3' + +export interface PageDialog { + type: string + message: string +} + +export interface CdpCallbacks { + /** A JS dialog was auto-handled; the driver surfaces it to the model. */ + onDialog: (dialog: PageDialog) => void + /** A file chooser was suppressed; the driver surfaces it to the model. */ + onFileChooser: () => void +} + +/** Per-tab callbacks, so a background tab's events reach ITS driver, not the + * most-recently-instrumented tab's. */ +const callbacksByContents = new WeakMap() +/** Contents already instrumented (attach survives for the tab's lifetime). */ +const instrumented = new WeakSet() + +async function send( + contents: WebContents, + method: string, + params?: Record +): Promise { + return (await contents.debugger.sendCommand(method, params)) as T +} + +/** Idempotently instruments a tab's WebContents. */ +export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks): Promise { + callbacksByContents.set(contents, cb) + if (instrumented.has(contents) && contents.debugger.isAttached()) return + + if (!contents.debugger.isAttached()) { + contents.debugger.attach(PROTOCOL_VERSION) + } + if (!instrumented.has(contents)) { + instrumented.add(contents) + contents.debugger.on('message', (_event, method, params) => { + handleDebuggerEvent(contents, method, params as Record) + }) + } + + await send(contents, 'Page.enable') + // Suppress native file choosers: nothing can drive them from the panel, + // and an open chooser blocks the page. Recorded and surfaced instead. + await send(contents, 'Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => {}) +} + +/** + * Mirrors Sim's theme into the page's `prefers-color-scheme` media query. + * `system` removes the per-tab override so Chromium continues following the OS. + */ +export async function setColorScheme(contents: WebContents, theme: BrowserTheme): Promise { + await send(contents, 'Emulation.setEmulatedMedia', { + features: theme === 'system' ? [] : [{ name: 'prefers-color-scheme', value: theme }], + }) +} + +function handleDebuggerEvent( + contents: WebContents, + method: string, + params: Record +): void { + const callbacks = callbacksByContents.get(contents) + if (method === 'Page.javascriptDialogOpening') { + const type = String(params.type ?? 'dialog') + const message = String(params.message ?? '').slice(0, 500) + // beforeunload is accepted (navigation proceeds); everything else is + // dismissed — the model reacts to the recorded message instead of a + // dialog that would block the page. + void send(contents, 'Page.handleJavaScriptDialog', { + accept: type === 'beforeunload', + }).catch(() => {}) + logger.info('Auto-handled page dialog', { type }) + callbacks?.onDialog({ type, message }) + return + } + if (method === 'Page.fileChooserOpened') { + logger.info('Suppressed file chooser in agent browser') + callbacks?.onFileChooser() + } +} + +/** + * Longest edge of a captured frame, in pixels. The image is sent to the model + * as a base64 image block, so its encoded size is charged against the tool + * result budget — an unbounded capture on a retina display is several hundred + * kilobytes and buys no legibility the model can use. + */ +const MAX_SCREENSHOT_EDGE = 1024 +const SCREENSHOT_QUALITY = 70 + +interface CdpViewport { + clientWidth: number + clientHeight: number +} + +/** + * Screenshot via CDP (works while the view is hidden), bounded in resolution. + * + * `clip.scale` is relative to CSS pixels, so passing the CSS viewport with a + * scale of 1 already sidesteps the device pixel ratio — an unclipped capture + * on a 2x display returns a 2x image. Scaling further down keeps the longest + * edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture + * when layout metrics are unavailable. + */ +export async function captureScreenshot(contents: WebContents): Promise { + const metrics = await send<{ + cssLayoutViewport?: CdpViewport + layoutViewport?: CdpViewport + }>(contents, 'Page.getLayoutMetrics').catch(() => null) + + const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport + const width = viewport?.clientWidth ?? 0 + const height = viewport?.clientHeight ?? 0 + const clip = + width > 0 && height > 0 + ? { + x: 0, + y: 0, + width, + height, + scale: Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)), + } + : undefined + + const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', { + format: 'jpeg', + quality: SCREENSHOT_QUALITY, + ...(clip ? { clip } : {}), + }) + return `data:image/jpeg;base64,${result.data}` +} + +/** One half of a trusted key press (`Input.dispatchKeyEvent` params). */ +export interface CdpKeyEvent { + type: 'keyDown' | 'rawKeyDown' | 'keyUp' + modifiers: number + key: string + code: string + windowsVirtualKeyCode: number + nativeVirtualKeyCode: number + text?: string + /** Blink editing commands to run with the event (macOS shortcut parity). */ + commands?: string[] +} + +/** Dispatches one trusted key event through Blink's input pipeline. */ +export async function dispatchKeyEvent(contents: WebContents, event: CdpKeyEvent): Promise { + await send(contents, 'Input.dispatchKeyEvent', event as unknown as Record) +} + +/** + * Inserts text at the focused element's selection (replacing it) through the + * native IME path — works in plain fields and code editors alike. + */ +export async function insertText(contents: WebContents, text: string): Promise { + await send(contents, 'Input.insertText', { text }) +} diff --git a/apps/desktop/src/main/browser-agent/context-menu.test.ts b/apps/desktop/src/main/browser-agent/context-menu.test.ts new file mode 100644 index 0000000000..a190d1eced --- /dev/null +++ b/apps/desktop/src/main/browser-agent/context-menu.test.ts @@ -0,0 +1,260 @@ +import type { ContextMenuParams, MenuItemConstructorOptions } from 'electron' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { Menu, WebContentsView } from 'electron' +import { + attachAgentContextMenu, + BASE_ZOOM_FACTOR, + buildAgentContextMenuTemplate, + steppedZoomFactor, + zoomPercentOf, +} from '@/main/browser-agent/context-menu' + +const EDIT_FLAGS: ContextMenuParams['editFlags'] = { + canUndo: false, + canRedo: false, + canCut: false, + canCopy: false, + canPaste: false, + canDelete: false, + canSelectAll: false, + canEditRichly: false, +} + +type Params = Parameters[0] +type Page = Parameters[1] +type Handlers = Parameters[2] + +function params(overrides: Partial = {}): Params { + return { selectionText: '', linkURL: '', isEditable: false, editFlags: EDIT_FLAGS, ...overrides } +} + +function page(overrides: Partial = {}): Page { + // A fresh tab sits at the panel's baseline, which the menu reports as 100%. + return { canGoBack: true, canGoForward: true, zoomFactor: BASE_ZOOM_FACTOR, ...overrides } +} + +function handlers(): Handlers { + return { + copy: vi.fn(), + paste: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + reload: vi.fn(), + openTab: vi.fn(), + copyLink: vi.fn(), + setZoomFactor: vi.fn(), + } +} + +const labels = (template: MenuItemConstructorOptions[]) => + template.filter((item) => item.type !== 'separator').map((item) => item.label) + +const item = (template: MenuItemConstructorOptions[], label: string) => + template.find((entry) => entry.label === label) + +describe('buildAgentContextMenuTemplate', () => { + it('always offers navigation and zoom, whatever was clicked', () => { + const template = buildAgentContextMenuTemplate(params(), page(), handlers()) + + // Unlike the main window's menu, an empty template is not an option here: + // the page has no menu of its own to fall back to. + expect(labels(template)).toEqual([ + 'Back', + 'Forward', + 'Reload', + 'Zoom In', + 'Zoom Out', + 'Actual Size (100%)', + ]) + }) + + it('offers clipboard items only where the click can use them', () => { + expect(labels(buildAgentContextMenuTemplate(params(), page(), handlers()))).not.toContain( + 'Copy' + ) + + const withSelection = buildAgentContextMenuTemplate( + params({ selectionText: ' hello ' }), + page(), + handlers() + ) + expect(labels(withSelection)).toContain('Copy') + + const inField = buildAgentContextMenuTemplate( + params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }), + page(), + handlers() + ) + expect(labels(inField)).toContain('Paste') + + // A read-only field can report canPaste; both signals have to agree. + const readOnly = buildAgentContextMenuTemplate( + params({ isEditable: false, editFlags: { ...EDIT_FLAGS, canPaste: true } }), + page(), + handlers() + ) + expect(labels(readOnly)).not.toContain('Paste') + }) + + it('offers link items for http(s) targets only', () => { + const handled = handlers() + const template = buildAgentContextMenuTemplate( + params({ linkURL: 'https://example.com/docs' }), + page(), + handled + ) + expect(labels(template)).toContain('Open Link in New Tab') + + item(template, 'Open Link in New Tab')?.click?.({} as never, undefined as never, {} as never) + expect(handled.openTab).toHaveBeenCalledWith('https://example.com/docs') + + // The actions open a tab or copy an address; neither means anything for a + // script or mail target, so the menu must not offer them. + for (const linkURL of ['javascript:alert(1)', 'mailto:a@b.com', 'file:///etc/passwd']) { + const other = buildAgentContextMenuTemplate(params({ linkURL }), page(), handlers()) + expect(labels(other)).not.toContain('Open Link in New Tab') + expect(labels(other)).not.toContain('Copy Link Address') + } + }) + + it('disables navigation the page cannot do', () => { + const template = buildAgentContextMenuTemplate( + params(), + page({ canGoBack: false, canGoForward: false }), + handlers() + ) + + expect(item(template, 'Back')?.enabled).toBe(false) + expect(item(template, 'Forward')?.enabled).toBe(false) + expect(item(template, 'Reload')?.enabled).toBeUndefined() + }) + + it('reports the current zoom and disables the ends of the ladder', () => { + // Two rungs up from the baseline, reported against the baseline rather than + // against Chromium's native scale (where this factor would read 110%). + const twoUp = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), 1) + const stepped = buildAgentContextMenuTemplate(params(), page({ zoomFactor: twoUp }), handlers()) + expect(item(stepped, 'Actual Size (121%)')?.enabled).toBe(true) + + const atMax = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 3 }), handlers()) + expect(item(atMax, 'Zoom In')?.enabled).toBe(false) + expect(item(atMax, 'Zoom Out')?.enabled).toBe(true) + + const atMin = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 0.5 }), handlers()) + expect(item(atMin, 'Zoom Out')?.enabled).toBe(false) + + // Nothing to reset to at 100%. + expect( + item(buildAgentContextMenuTemplate(params(), page(), handlers()), 'Actual Size (100%)') + ?.enabled + ).toBe(false) + }) + + it('resets to exactly the baseline, undoing accumulated drift', () => { + const handled = handlers() + // Three rungs of float multiplication up, so the factor no longer sits on a + // clean value — reset has to restore the baseline exactly, not step back. + const drifted = [1, 1, 1].reduce((factor) => steppedZoomFactor(factor, 1), BASE_ZOOM_FACTOR) + const template = buildAgentContextMenuTemplate(params(), page({ zoomFactor: drifted }), handled) + + item(template, 'Actual Size (133%)')?.click?.({} as never, undefined as never, {} as never) + + expect(handled.setZoomFactor).toHaveBeenCalledWith(BASE_ZOOM_FACTOR) + }) + + it('never leaves a separator with nothing above it', () => { + for (const p of [ + params(), + params({ selectionText: 'hi' }), + params({ linkURL: 'https://example.com' }), + params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }), + ]) { + const template = buildAgentContextMenuTemplate(p, page(), handlers()) + expect(template[0].type).not.toBe('separator') + expect(template[template.length - 1].type).not.toBe('separator') + expect( + template.some( + (entry, index) => entry.type === 'separator' && template[index - 1]?.type === 'separator' + ) + ).toBe(false) + } + }) +}) + +describe('attachAgentContextMenu', () => { + type ContextMenuListener = (event: unknown, params: Params) => void + + it('pops a menu built from the page that was right-clicked', () => { + const contents = new WebContentsView().webContents + vi.mocked(contents.navigationHistory.canGoBack).mockReturnValue(true) + attachAgentContextMenu(contents, { openTab: vi.fn() }) + + const listeners = vi.mocked(contents.on).mock.calls as unknown as [ + string, + ContextMenuListener, + ][] + const onContextMenu = listeners.find(([event]) => event === 'context-menu')?.[1] + expect(onContextMenu).toBeDefined() + onContextMenu?.({}, params()) + + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as + | MenuItemConstructorOptions[] + | undefined + // The template is read off the live page, not a snapshot of it. + expect(item(template ?? [], 'Back')?.enabled).toBe(true) + expect(item(template ?? [], 'Forward')?.enabled).toBe(false) + }) +}) + +describe('steppedZoomFactor', () => { + it('steps up and down from the current factor', () => { + expect(steppedZoomFactor(1, 1)).toBe(1.1) + expect(steppedZoomFactor(1, -1)).toBe(1 / 1.1) + }) + + it('clamps at both ends so a step never runs away', () => { + expect(steppedZoomFactor(3, 1)).toBe(3) + expect(steppedZoomFactor(0.5, -1)).toBe(0.5) + }) + + it('treats a nonsense factor as the baseline', () => { + // One rung up from the baseline is Chromium's native 1.0. + expect(steppedZoomFactor(Number.NaN, 1)).toBe(1) + expect(steppedZoomFactor(0, 1)).toBe(1) + }) +}) + +describe('BASE_ZOOM_FACTOR', () => { + it('renders a rung below native but reads as 100%', () => { + expect(BASE_ZOOM_FACTOR).toBeCloseTo(0.909, 3) + expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100) + }) + + it('keeps the ladder landing exactly on Chromium native one step up', () => { + expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).toBe(1) + expect(zoomPercentOf(1)).toBe(110) + }) + + it('stays inside the ladder, so the page menu can still step both ways', () => { + expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).not.toBe(BASE_ZOOM_FACTOR) + expect(steppedZoomFactor(BASE_ZOOM_FACTOR, -1)).not.toBe(BASE_ZOOM_FACTOR) + }) +}) + +describe('zoomPercentOf', () => { + it('reports every rung relative to the panel baseline, not to native', () => { + expect(zoomPercentOf(steppedZoomFactor(BASE_ZOOM_FACTOR, -1))).toBe(91) + expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100) + expect(zoomPercentOf(steppedZoomFactor(BASE_ZOOM_FACTOR, 1))).toBe(110) + }) + + it('still reads 100% after a round trip up and back down', () => { + // The ladder is float arithmetic, so the reset item's `!== 100` guard has to + // survive a step that does not return bit-identically to the baseline. + const roundTripped = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), -1) + expect(zoomPercentOf(roundTripped)).toBe(100) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/context-menu.ts b/apps/desktop/src/main/browser-agent/context-menu.ts new file mode 100644 index 0000000000..52710c1e9a --- /dev/null +++ b/apps/desktop/src/main/browser-agent/context-menu.ts @@ -0,0 +1,185 @@ +/** + * Right-click menu for the embedded browser page. + * + * Native, deliberately. The page is a native view composited OVER the renderer, + * so a menu drawn from the app's own dropdown can only appear by freezing the + * page to a captured frame and hiding the view beneath it. That frame has to + * pixel-match a live compositor surface, and scrollbars, device scale, and + * resize timing all conspire against it — a mismatch shows up as the page + * flickering or shifting under the menu. A native menu draws above the view + * while the page keeps rendering, so none of that applies. + * + * It is also what a browser's page menu is everywhere else, and unlike the + * terminal's hidden textarea, the roles here act on a real page: `copy` and + * `paste` go to the frame that was clicked. + */ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron' +import { clipboard, Menu } from 'electron' + +/** + * Page-zoom ladder for the embedded browser, in Chromium's absolute zoom + * factors. The ends are the platform's own limits, not a product choice — + * Chromium refuses to scale past them, and a rung outside the range would come + * back clamped and leave the menu offering a step that never lands. + */ +const ZOOM_STEP_RATIO = 1.1 +const MIN_ZOOM_FACTOR = 0.5 +const MAX_ZOOM_FACTOR = 3 + +/** + * What the panel calls 100%. + * + * The browser lives in a panel that is only ever a fraction of the window, so + * it renders a rung below Chromium's native scale and treats THAT as its + * baseline: the menu reads 100% there, and every other rung is reported + * relative to it. Users get a zoom control that behaves the way one should — + * starts at 100%, resets to 100% — over a page that is genuinely rendering at + * ~91% of native. + * + * Defined as one rung below native rather than as a round number so the ladder + * still lands exactly on Chromium's 1.0 (the crispest rasterization, one step + * up from the baseline) instead of straddling it. + */ +export const BASE_ZOOM_FACTOR = 1 / ZOOM_STEP_RATIO + +/** + * A Chromium zoom factor as a percentage of {@link BASE_ZOOM_FACTOR} — what the + * menu shows and what "100%" means everywhere in the browser panel's UI. + * + * Only display and the reset target convert; the ladder itself stays in + * absolute factors, so stepping never round-trips through this and cannot + * accumulate float drift away from the rungs. + */ +export function zoomPercentOf(factor: number): number { + return Math.round((factor / BASE_ZOOM_FACTOR) * 100) +} + +/** + * One step along the zoom ladder, clamped to its ends. Returning the current + * factor unchanged is how the menu knows an end is reached, so it can disable + * the item rather than offer a step that does nothing. + */ +export function steppedZoomFactor(current: number, direction: 1 | -1): number { + const base = Number.isFinite(current) && current > 0 ? current : BASE_ZOOM_FACTOR + const next = direction === 1 ? base * ZOOM_STEP_RATIO : base / ZOOM_STEP_RATIO + return Math.min(MAX_ZOOM_FACTOR, Math.max(MIN_ZOOM_FACTOR, next)) +} + +/** The parts of a right-click the menu acts on. */ +type AgentContextMenuParams = Pick< + ContextMenuParams, + 'selectionText' | 'linkURL' | 'isEditable' | 'editFlags' +> + +/** What the page can currently do, read at the moment of the click. */ +interface AgentPageContext { + canGoBack: boolean + canGoForward: boolean + zoomFactor: number +} + +interface AgentContextMenuHandlers { + copy(): void + paste(): void + back(): void + forward(): void + reload(): void + openTab(url: string): void + copyLink(url: string): void + setZoomFactor(factor: number): void +} + +export interface AgentContextMenuHost { + /** Opens a link from the page in another tab of the same browser. */ + openTab(url: string): void +} + +/** + * Builds the page menu. Unlike the main window's menu this is never empty — + * navigation and zoom always apply, and only the clipboard and link items + * depend on what was clicked. + * + * Only http(s) links get link items: the actions open a browser tab or copy an + * address, and neither means anything for a `javascript:` or `mailto:` target. + */ +export function buildAgentContextMenuTemplate( + params: AgentContextMenuParams, + page: AgentPageContext, + handlers: AgentContextMenuHandlers +): MenuItemConstructorOptions[] { + const template: MenuItemConstructorOptions[] = [] + const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : '' + + if (linkUrl) { + template.push( + { label: 'Open Link in New Tab', click: () => handlers.openTab(linkUrl) }, + { label: 'Copy Link Address', click: () => handlers.copyLink(linkUrl) }, + { type: 'separator' } + ) + } + + if (params.selectionText.trim()) { + template.push({ label: 'Copy', click: () => handlers.copy() }) + } + if (params.isEditable && params.editFlags.canPaste) { + template.push({ label: 'Paste', click: () => handlers.paste() }) + } + if (template.length > 0 && template[template.length - 1].type !== 'separator') { + template.push({ type: 'separator' }) + } + + template.push( + { label: 'Back', enabled: page.canGoBack, click: () => handlers.back() }, + { label: 'Forward', enabled: page.canGoForward, click: () => handlers.forward() }, + { label: 'Reload', click: () => handlers.reload() }, + { type: 'separator' } + ) + + const zoomIn = steppedZoomFactor(page.zoomFactor, 1) + const zoomOut = steppedZoomFactor(page.zoomFactor, -1) + const zoomPercent = zoomPercentOf(page.zoomFactor) + template.push( + { + label: 'Zoom In', + enabled: zoomIn !== page.zoomFactor, + click: () => handlers.setZoomFactor(zoomIn), + }, + { + label: 'Zoom Out', + enabled: zoomOut !== page.zoomFactor, + click: () => handlers.setZoomFactor(zoomOut), + }, + { + label: `Actual Size (${zoomPercent}%)`, + enabled: zoomPercent !== 100, + click: () => handlers.setZoomFactor(BASE_ZOOM_FACTOR), + } + ) + + return template +} + +/** Gives one agent tab its page menu. */ +export function attachAgentContextMenu(contents: WebContents, host: AgentContextMenuHost): void { + contents.on('context-menu', (_event, params) => { + const template = buildAgentContextMenuTemplate( + params, + { + canGoBack: contents.navigationHistory.canGoBack(), + canGoForward: contents.navigationHistory.canGoForward(), + zoomFactor: contents.getZoomFactor(), + }, + { + copy: () => contents.copy(), + paste: () => contents.paste(), + back: () => contents.navigationHistory.goBack(), + forward: () => contents.navigationHistory.goForward(), + reload: () => contents.reload(), + openTab: (url) => host.openTab(url), + copyLink: (url) => clipboard.writeText(url), + setZoomFactor: (factor) => contents.setZoomFactor(factor), + } + ) + Menu.buildFromTemplate(template).popup() + }) +} diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts new file mode 100644 index 0000000000..cc1059baec --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow } from 'electron' +import * as driverModule from '@/main/browser-agent/driver' +import * as session from '@/main/browser-agent/session' + +type DriverModule = typeof import('@/main/browser-agent/driver') + +/** + * `initDriver` is a full reset of the driver's and the session's per-session + * state, so a clean driver needs no module reload — which is what lets this + * file use static imports instead of the `vi.resetModules()` the root + * CLAUDE.md forbids. Tests needing real callbacks call `initDriver` again; + * calling it twice is exactly the re-init case the reset exists for. + */ +function freshDriver(): DriverModule { + driverModule.initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => null + ) + return driverModule +} + +describe('executeTool', () => { + let driver: DriverModule + + beforeEach(async () => { + driver = freshDriver() + }) + + it('returns ok:false instead of throwing for tool-level failures', async () => { + // No session exists, so any page-dependent tool fails with guidance. + const result = await driver.executeTool('browser_click', { elementId: 1 }) + expect(result.ok).toBe(false) + expect(result.error).toMatch(/No page is open yet/) + }) + + it('validates navigation URLs before touching the session', async () => { + const result = await driver.executeTool('browser_navigate', { url: 'file:///etc/passwd' }) + expect(result).toEqual({ + ok: false, + error: 'URL must be absolute and start with http:// or https://', + }) + }) + + it('reports missing required parameters by name', async () => { + const result = await driver.executeTool('browser_navigate', {}) + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Missing required parameter "url"/) + }) + + it('serializes tool calls: a queued failure never rejects the next call', async () => { + const first = await driver.executeTool('browser_snapshot', {}) + expect(first.ok).toBe(false) + const second = await driver.executeTool('browser_list_tabs', {}) + // list_tabs works without a session (empty list). + expect(second.ok).toBe(true) + expect(second.result).toMatchObject({ tabs: [] }) + }) + + it.each(['', 'about:blank'])( + 'fails page tools immediately and releases queued tab listing when the URL is %j', + async (url) => { + const win = new BrowserWindow() + driver.initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + await driver.executeTool('browser_open_tab', {}) + + const contents = session.requireTab().view.webContents + vi.mocked(contents.getURL).mockReturnValue(url) + vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {})) + + const snapshot = driver.executeTool('browser_snapshot', {}) + const listTabs = driver.executeTool('browser_list_tabs', {}) + + await expect(snapshot).resolves.toEqual({ + ok: false, + error: + 'The active tab is blank. Call browser_navigate before using page inspection or interaction tools.', + }) + await expect(listTabs).resolves.toMatchObject({ + ok: true, + result: { + tabs: [{ url }], + }, + }) + expect(contents.executeJavaScript).not.toHaveBeenCalled() + } + ) + + it('leaves no watchdog timer pending once a tool finishes', async () => { + vi.useFakeTimers() + try { + // Racing against an uncancellable sleep left one timer alive per call for + // the full watchdog window — up to two minutes, dozens deep in a run. + const before = vi.getTimerCount() + await driver.executeTool('browser_list_tabs', {}) + + expect(vi.getTimerCount()).toBe(before) + } finally { + vi.useRealTimers() + } + }) + + it('releases the serialized queue before the renderer timeout when a page call hangs', async () => { + vi.useFakeTimers() + try { + const win = new BrowserWindow() + driver.initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + await driver.executeTool('browser_open_tab', {}) + + const contents = session.requireTab().view.webContents + vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {})) + + const hung = driver.executeTool('browser_snapshot', {}) + const queued = driver.executeTool('browser_list_tabs', {}) + await vi.advanceTimersByTimeAsync(20_000) + + await expect(hung).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('did not finish this action in time'), + }) + await expect(queued).resolves.toMatchObject({ + ok: true, + result: { tabs: expect.any(Array) }, + }) + } finally { + vi.useRealTimers() + } + }) +}) + +/** + * Trusted CDP input never enters the page, so a focused credential field can + * only be ruled out in the driver. These cover that seam; the page-side + * detection itself is covered in page-functions.test.ts. + */ +describe('credential protection', () => { + let driver: DriverModule + + beforeEach(async () => { + driver = freshDriver() + }) + + /** Opens a tab on a real URL so injected page calls are not short-circuited. */ + async function openPage() { + const win = new BrowserWindow() + driver.initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + await driver.executeTool('browser_open_tab', {}) + const contents = session.requireTab().view.webContents + vi.mocked(contents.getURL).mockReturnValue('https://example.com/login') + return contents + } + + /** + * Routes injected calls by the function name in the serialized source, so a + * test can say what each page probe reports. + */ + function respondWith( + contents: Awaited>, + replies: Record + ): void { + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + for (const [fnName, value] of Object.entries(replies)) { + if (expression.includes(fnName)) return Promise.resolve(value) + } + return Promise.resolve(undefined) + }) + } + + function cdpCalls(contents: Awaited>, method: string): unknown[][] { + return vi + .mocked(contents.debugger.sendCommand) + .mock.calls.filter(([called]) => called === method) + } + + it('refuses a keystroke while a password field holds focus', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'secret' }) + + const result = await driver.executeTool('browser_press_key', { key: 'a' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) + }) + + it('refuses character insertion into a frame it cannot inspect', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'opaque' }) + + const result = await driver.executeTool('browser_press_key', { key: 'a' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/cross-origin frame/) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) + }) + + it('still allows caret and dismissal keys in a frame it cannot inspect', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'opaque', readActiveElementState: {} }) + + const result = await driver.executeTool('browser_press_key', { key: 'Escape' }) + + expect(result.ok).toBe(true) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0) + }) + + it('sends the keystroke when nothing sensitive is focused', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) + + const result = await driver.executeTool('browser_press_key', { key: 'a' }) + + expect(result.ok).toBe(true) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0) + }) + + it('aborts a type when focus moves to a password field before the insert', async () => { + const contents = await openPage() + // The element passed the guard, then the page advanced focus — what a + // login form does between the username and password steps. + respondWith(contents, { + focusElementForTyping: { focused: true, kind: 'input' }, + activeElementSecrecy: 'secret', + }) + + const result = await driver.executeTool('browser_type', { elementId: 0, text: 'hunter2' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('types normally when focus stays on the vetted element', async () => { + const contents = await openPage() + respondWith(contents, { + focusElementForTyping: { focused: true, kind: 'input' }, + activeElementSecrecy: 'safe', + readActiveElementState: { activeElement: 'input', valueLength: 7 }, + }) + + const result = await driver.executeTool('browser_type', { elementId: 0, text: 'hunter2' }) + + expect(result.ok).toBe(true) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) + }) + + it.each(['Cmd+V', 'Control+V', 'Cmd+C', 'Cmd+X'])( + 'refuses the clipboard shortcut %s', + async (key) => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) + + const result = await driver.executeTool('browser_press_key', { key }) + + // Paste would move a password copied out of a manager into the page, + // where the next snapshot reports it as an ordinary field value. + expect(result.ok).toBe(false) + expect(result.error).toMatch(/clipboard/i) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) + } + ) + + it('still allows select-all, which carries no clipboard content', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) + + const result = await driver.executeTool('browser_press_key', { key: 'Cmd+A' }) + + expect(result.ok).toBe(true) + }) + + it('surfaces the page-side refusal for element-targeted actions', async () => { + const contents = await openPage() + respondWith(contents, { clickElement: { error: 'password' } }) + + const result = await driver.executeTool('browser_click', { elementId: 0 }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts new file mode 100644 index 0000000000..01b1ce501f --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -0,0 +1,913 @@ +/** + * Browser-agent driver: executes the copilot's `browser_*` tools against the + * agent browser (session.ts) and keeps the renderer's panel header fed with + * live page state. + * + * Perception drives through injected page functions (element registry with a + * structural outline). Keyboard actuation (press_key, type) goes through + * TRUSTED CDP input events — synthetic DOM KeyboardEvents never trigger + * default editing actions (select-all, deletion, character insertion) and are + * ignored by code editors, so they exist only as a fallback. Clicks still use + * injected functions (element-targeted, no coordinate math). The user needs + * no input translation at all — the real page is embedded in the Sim window, + * so their clicks and typing are native. Tool calls serialize through a + * queue — one real browser can only do one thing at a time — and every call + * is bounded by a watchdog so the Sim side always gets a response instead of + * waiting out its own timeout against silence. + */ +import { + BROWSER_DATA_KINDS, + type BrowserDataKind, + type BrowserKnownSessionsState, + type BrowserPageState, + type BrowserPanelAction, + type BrowserTabsState, + type BrowserToolName, +} from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' +import type { BrowserWindow, WebContents } from 'electron' +import * as cdp from '@/main/browser-agent/cdp' +import { ToolError } from '@/main/browser-agent/errors' +import { + comboInsertsText, + comboTouchesClipboard, + dispatchKeyCombo, + parseKeyCombo, +} from '@/main/browser-agent/keyboard' +import { BrowserKnownSessionRegistry } from '@/main/browser-agent/known-sessions' +import { + activeElementSecrecy, + clickElement, + collectSnapshot, + focusElementForTyping, + getViewportInfo, + hoverElement, + pageContainsText, + pressKeyOnPage, + readActiveElementState, + readPageText, + scrollPage, + selectOptionInElement, + typeIntoElement, +} from '@/main/browser-agent/page-functions' +import * as session from '@/main/browser-agent/session' +import { checkAgentUrl } from '@/main/browser-agent/url-guard' +import { clearCredentials, fillCoordinator, initFillCoordinator } from '@/main/browser-credentials' +import type { ConfigStore } from '@/main/config' + +const logger = createLogger('BrowserAgentDriver') + +const NAVIGATION_TIMEOUT_MS = 25_000 +const NAVIGATION_SETTLE_MS = 400 +const DEFAULT_WAIT_FOR_TIMEOUT_MS = 10_000 +const MAX_WAIT_FOR_TIMEOUT_MS = 120_000 +const TAKEOVER_POLL_MS = 1_500 +const TAKEOVER_MAX_MS = 12 * 60 * 60 * 1000 +/** + * Hard ceiling on any single tool execution (takeover excepted): whatever + * goes wrong, the Sim side always gets a response. Sits above the longest + * legitimate tool (browser_wait_for caps at 120s). + */ +const DEFAULT_TOOL_WATCHDOG_MS = 20_000 +const NAVIGATION_TOOL_WATCHDOG_MS = 30_000 +const WAIT_FOR_TOOL_WATCHDOG_GRACE_MS = 5_000 + +export interface DriverCallbacks { + onPageState: (state: BrowserPageState) => void + onTabsState: (state: BrowserTabsState) => void + onSessionStatus: (alive: boolean) => void + /** Whether the active tab shows a login form Sim holds a credential for. */ + onFillAvailability: (available: boolean) => void +} + +let driverCallbacks: DriverCallbacks | null = null +let knownSessions: BrowserKnownSessionRegistry | null = null +/** Kept so teardown can force its erasures past the settings write debounce. */ +let configStore: ConfigStore | null = null + +/** + * Page states auto-handled since the last tool result (dismissed dialogs, + * suppressed file choosers, blocked downloads). Attached to the next tool + * result so the model reacts to what actually happened on the page. + */ +let pendingNotices: string[] = [] + +function recordNotice(notice: string): void { + if (pendingNotices.length < 10) pendingNotices.push(notice) +} + +/** + * True while browser_request_takeover waits on the user. The Done chip on the + * chat's takeover tool row completes it via the `takeover-done` panel action; + * the state lives here (session-level, not in the page) so it survives + * navigations and tab switches. + */ +let takeoverActive = false +let takeoverDone = false + +function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { + return { + tabId, + url: contents.getURL(), + title: contents.getTitle(), + loading: contents.isLoading(), + canGoBack: contents.navigationHistory.canGoBack(), + canGoForward: contents.navigationHistory.canGoForward(), + } +} + +function pushPageState(contents: WebContents): void { + if (contents.isDestroyed()) return + const active = session.activeTab() + if (active?.view.webContents !== contents) return + driverCallbacks?.onPageState(pageStateFor(contents, active.id)) +} + +let lastTabsStateFingerprint: string | null = null + +/** + * Pushes the tab list to the renderer, skipping a push identical to the last. + * + * Every tab's load and title events call this, background tabs included, and a + * site that rewrites its title on a timer fires them continuously — each an + * unconditional broadcast that rebuilt the whole strip. The fingerprint drops + * the repeats, matching what the terminal side already does with emitTabs. + */ +function pushTabsState(): void { + const state = session.getTabsState() + const fingerprint = JSON.stringify(state) + if (fingerprint === lastTabsStateFingerprint) return + lastTabsStateFingerprint = fingerprint + driverCallbacks?.onTabsState(state) +} + +/** Instruments a fresh tab: CDP dialog/chooser handling + page-state pushes. */ +function instrumentTab(contents: WebContents): void { + void cdp + .ensureInstrumented(contents, { + onDialog: (dialog) => { + recordNotice( + `The page showed a ${dialog.type} dialog ("${dialog.message}") which was auto-dismissed.` + ) + }, + onFileChooser: () => { + recordNotice( + 'The page opened a file picker; native file uploads are not driven by the agent — ' + + 'the user can complete the upload directly in the browser panel if needed.' + ) + }, + }) + .then(() => cdp.setColorScheme(contents, session.getBrowserTheme())) + .catch((error) => { + logger.warn('CDP instrumentation failed', { + error: getErrorMessage(error), + }) + }) + contents.on('did-navigate', () => { + knownSessions?.noteTopLevelNavigation(contents.getURL()) + pushPageState(contents) + pushTabsState() + }) + for (const event of [ + 'did-navigate-in-page', + 'page-title-updated', + 'did-start-loading', + 'did-stop-loading', + ] as const) { + contents.on(event as 'did-navigate', () => { + pushPageState(contents) + pushTabsState() + }) + } + driverCallbacks?.onSessionStatus(true) +} + +export function initDriver( + callbacks: DriverCallbacks, + getMainWindow: () => BrowserWindow | null, + config?: ConfigStore +): void { + driverCallbacks = callbacks + knownSessions = config ? new BrowserKnownSessionRegistry(config) : null + configStore = config ?? null + // The rest of this module's state is per-session too. Left behind, a new + // session inherits the previous one's pending notices, a takeover still + // waiting on a user who is gone, and a fingerprint that suppresses its very + // first tab push as a duplicate. + pendingNotices = [] + takeoverActive = false + takeoverDone = false + lastTabsStateFingerprint = null + // The serialization chain, too. A takeover from the previous session can sit + // unresolved indefinitely, and its `takeoverDone` flag is reset above — so + // leaving the old chain head in place would queue the new session's first + // tool call behind a promise nothing can ever settle. + toolQueue = Promise.resolve() + initFillCoordinator({ + getActiveContents: () => session.activeTab()?.view.webContents ?? null, + onAvailabilityChanged: (available) => callbacks.onFillAvailability(available), + }) + session.initSession( + { + onSessionClosed: () => { + driverCallbacks?.onSessionStatus(false) + }, + onTabCreated: instrumentTab, + onTabNavigated: (contents) => fillCoordinator()?.noteNavigation(contents), + onTabClosed: (contents) => fillCoordinator()?.forget(contents), + onActiveTabChanged: (contents) => { + pushPageState(contents) + // The fill affordance belongs to whichever page is in front. + void fillCoordinator()?.refreshAvailability() + }, + onTabsChanged: pushTabsState, + onTabThemeChanged: (contents, theme) => { + void cdp.setColorScheme(contents, theme).catch((error) => { + logger.warn('Could not update browser tab theme', { + error: getErrorMessage(error), + }) + }) + }, + onDownloadBlocked: (filename) => { + recordNotice( + `The page tried to download "${filename}"; downloads are not supported in the agent browser, so it was blocked.` + ) + }, + }, + getMainWindow, + config + ? { + load: () => config.get('browserPinnedTabUrls'), + save: (urls) => config.set('browserPinnedTabUrls', urls), + } + : undefined + ) +} + +export async function getKnownSessions(): Promise { + if (!knownSessions) return { sessions: [] } + const cookieSignals = await session.listAgentCookieSignals() + return knownSessions.list(cookieSignals) +} + +/** + * Cookies, site storage, cache, and the remembered browsing trail. + * + * Saved passwords are deliberately NOT touched. "Clear browsing data" is about + * signing out of websites, and a user who wanted to erase their password vault + * would have to say so separately — silently taking their credentials with it + * would be a destructive surprise. + */ +export async function clearBrowsingData( + kinds: readonly BrowserDataKind[] = BROWSER_DATA_KINDS +): Promise { + // The remembered browsing trail is the local mirror of the cookie jar, so it + // goes when cookies do and stays when they do not. + if (kinds.includes('cookies')) knownSessions?.clear() + await session.clearAgentData(kinds) +} + +/** + * Everything the embedded browser holds for the signed-in user, including the + * credential vault. This is the Sim sign-out path: a different account signing + * in on the same machine must not inherit the previous user's sessions or + * passwords. + */ +export async function clearBrowserProfile(): Promise { + knownSessions?.clear() + await session.clearProfileStorage() + await clearCredentials() + // Last, covering the pinned-tab list `clearProfileStorage` just emptied. + // Settings writes coalesce, and an erasure that is still sitting in that + // window when the process dies leaves the previous account's data on disk + // after sign-out already told the user it was gone. + configStore?.flush() +} + +function str(params: Record, key: string): string | undefined { + const value = params[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function num(params: Record, key: string): number | undefined { + const value = params[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +/** + * Native execution must time out before the renderer gives up (30s default, + * 45s navigation, requested wait + 15s). Otherwise the abandoned native + * promise keeps owning the serialized queue and every later browser action + * times out behind it. + */ +export function browserToolWatchdogMs( + tool: BrowserToolName, + params: Record +): number | null { + if (tool === 'browser_request_takeover') return null + if ( + tool === 'browser_navigate' || + tool === 'browser_open_url' || + tool === 'browser_go_back' || + tool === 'browser_go_forward' || + tool === 'browser_open_tab' + ) { + return NAVIGATION_TOOL_WATCHDOG_MS + } + if (tool === 'browser_wait_for') { + const requested = Math.min( + num(params, 'timeoutMs') ?? DEFAULT_WAIT_FOR_TIMEOUT_MS, + MAX_WAIT_FOR_TIMEOUT_MS + ) + return requested + WAIT_FOR_TOOL_WATCHDOG_GRACE_MS + } + return DEFAULT_TOOL_WATCHDOG_MS +} + +function requireStr(params: Record, key: string): string { + const value = str(params, key) + if (value === undefined) throw new ToolError(`Missing required parameter "${key}"`) + return value +} + +function requireNum(params: Record, key: string): number { + const value = num(params, key) + if (value === undefined) throw new ToolError(`Missing required numeric parameter "${key}"`) + return value +} + +/** + * Serializes a self-contained page function and executes it in the page's + * main world with JSON-encoded arguments (Electron's executeJavaScript has no + * function+args transport like chrome.scripting). + */ +async function execInPage( + contents: WebContents, + fn: (...args: Args) => Result, + args: Args +): Promise { + const url = contents.getURL() + if (url === '' || url === 'about:blank') { + throw new ToolError( + 'The active tab is blank. Call browser_navigate before using page inspection or interaction tools.' + ) + } + const expression = `(${String(fn)}).apply(null, ${JSON.stringify(args)})` + try { + return (await contents.executeJavaScript(expression, true)) as Result + } catch (error) { + const message = getErrorMessage(error) + throw new ToolError( + `Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` + + 'navigate to a regular website first.' + ) + } +} + +/** + * Covers focusing, clicking, and typing: the agent has no legitimate reason to + * reach a credential field, and takeover is the sanctioned path when a task + * needs one. + */ +const PASSWORD_REFUSAL = + 'Refusing to act on a password field. Call browser_request_takeover so the user ' + + 'can enter their credentials themselves.' + +/** Maps sentinel `{ error: ... }` results from injected functions to ToolErrors. */ +function unwrapPageResult(result: unknown): unknown { + if (isRecordLike(result) && 'error' in result) { + const code = (result as { error: string }).error + if (code === 'stale') { + throw new ToolError( + 'That element id is stale (the page changed since the last snapshot). ' + + 'Call browser_snapshot again and use a fresh id.' + ) + } + if (code === 'password') { + throw new ToolError(PASSWORD_REFUSAL) + } + if (code === 'not-editable') { + throw new ToolError('That element is not a text input — pick an editable element.') + } + if (code === 'not-select') { + throw new ToolError('That element is not a ' + register(...Array.from(document.body.children).map(visible)) + + expect(() => runSerialized(fn, args)).not.toThrow() + }) + + it('still refuses a password field when run as serialized source', () => { + // The guards must survive the trip through String(fn), not just direct + // invocation from this module. + document.body.innerHTML = '' + register(visible(document.querySelector('input') as HTMLInputElement)) + setActiveElement(document, document.querySelector('input')) + + expect(runSerialized(clickElement, [0])).toEqual({ error: 'password' }) + expect(runSerialized(activeElementSecrecy, [])).toBe('secret') + expect(runSerialized(readActiveElementState, [])).toMatchObject({ redacted: true }) + }) +}) + +describe('secret-field detection', () => { + const secretCases: Array<[string, string]> = [ + ['type=password', ''], + [ + 'revealed password (type flipped to text)', + '', + ], + ['new-password field', ''], + ['uppercase autocomplete token', ''], + ] + + it.each(secretCases)('clickElement refuses a %s', (_label, html) => { + document.body.innerHTML = html + const input = visible(document.querySelector('input') as HTMLInputElement) + register(input) + + expect(clickElement(0)).toEqual({ error: 'password' }) + }) + + it.each(secretCases)('typeIntoElement refuses a %s', (_label, html) => { + document.body.innerHTML = html + const input = document.querySelector('input') as HTMLInputElement + register(input) + + expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' }) + expect(input.value).toBe('') + }) + + it.each(secretCases)('focusElementForTyping refuses a %s', (_label, html) => { + document.body.innerHTML = html + register(document.querySelector('input') as HTMLInputElement) + + expect(focusElementForTyping(0)).toEqual({ error: 'password' }) + }) + + it('still allows ordinary fields and controls', () => { + document.body.innerHTML = + '' + const [text, , email] = Array.from(document.body.querySelectorAll('input, button')).map(visible) + const button = visible(document.querySelector('button') as HTMLButtonElement) + register(text, button, email) + + expect(typeIntoElement(0, 'search terms', false)).toMatchObject({ typed: true }) + expect(clickElement(1)).toMatchObject({ clicked: true }) + expect(focusElementForTyping(2)).toMatchObject({ focused: true }) + }) + + it('detects a password field reached through a same-origin iframe', () => { + // `instanceof HTMLInputElement` is realm-bound and returns false for nodes + // owned by a frame, which is why detection matches on tagName instead. + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '' + const nested = inner.querySelector('input') as HTMLInputElement + + expect(nested instanceof HTMLInputElement).toBe(false) + register(nested) + expect(clickElement(0)).toEqual({ error: 'password' }) + }) +}) + +describe('elements inside a same-origin iframe', () => { + /** + * The snapshot walks into same-origin frames and hands the model ids for + * what it finds, so every interaction has to work on them. `instanceof` + * against the top frame's constructors is false for those nodes, which used + * to make the driver report a real `` as "not a text input" — + * breaking framed login forms and editors like TinyMCE. + */ + function framedBody(html: string): Document { + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + // The frame is its own realm, so the shims installed on the top document's + // prototypes do not apply here — the same property that makes `instanceof` + // fail across frames. + const innerWindow = inner.defaultView as Window & typeof globalThis + innerWindow.Element.prototype.scrollIntoView = () => {} + inner.body.innerHTML = html + return inner + } + + it('types into a framed input', () => { + const inner = framedBody('') + const field = inner.querySelector('input') as HTMLInputElement + register(field) + + expect(field instanceof HTMLInputElement).toBe(false) + expect(typeIntoElement(0, 'hello', false)).toMatchObject({ typed: true }) + expect(field.value).toBe('hello') + }) + + it('focuses a framed input for native typing', () => { + const inner = framedBody('') + register(inner.querySelector('input') as HTMLInputElement) + + expect(focusElementForTyping(0)).toMatchObject({ focused: true, kind: 'input' }) + }) + + it('selects an option in a framed select', () => { + const inner = framedBody( + '' + ) + const select = inner.querySelector('select') as HTMLSelectElement + register(select) + + expect(selectOptionInElement(0, 'B')).toMatchObject({ selected: 'B' }) + expect(select.value).toBe('b') + }) + + it('focuses a framed element when clicking it', () => { + const inner = framedBody('') + const button = visible(inner.querySelector('button') as HTMLButtonElement) + register(button) + let focused = false + button.addEventListener('focus', () => { + focused = true + }) + + expect(clickElement(0)).toMatchObject({ clicked: true }) + expect(focused).toBe(true) + }) + + it('still refuses a framed password field', () => { + const inner = framedBody('') + register(inner.querySelector('input') as HTMLInputElement) + + expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' }) + expect(focusElementForTyping(0)).toEqual({ error: 'password' }) + }) +}) + +describe('collectSnapshot', () => { + it('labels a password field and never emits its value', () => { + document.body.innerHTML = '' + visible(document.querySelector('input') as HTMLInputElement) + + const outline = outlineOf(collectSnapshot()) + + expect(outline).toContain('password-field') + expect(outline).not.toContain('hunter2') + expect(outline).not.toContain('value=') + }) + + it('withholds the value of a revealed password field', () => { + document.body.innerHTML = + '' + visible(document.querySelector('input') as HTMLInputElement) + + const outline = outlineOf(collectSnapshot()) + + expect(outline).toContain('password-field') + expect(outline).not.toContain('hunter2') + }) + + it('still reports ordinary input values', () => { + document.body.innerHTML = '' + visible(document.querySelector('input') as HTMLInputElement) + + expect(outlineOf(collectSnapshot())).toContain('value="tokyo"') + }) +}) + +describe('readActiveElementState', () => { + it('withholds value, length, and selection size for a password field', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + + expect(readActiveElementState()).toEqual({ + activeElement: 'password-field', + selectedChars: 0, + valueLength: 0, + valuePreview: '', + redacted: true, + }) + }) + + it('withholds the value of a revealed password field', () => { + document.body.innerHTML = + '' + setActiveElement(document, document.querySelector('input')) + + expect(readActiveElementState()).toMatchObject({ redacted: true, valuePreview: '' }) + }) + + it('reports ordinary fields in full', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + + expect(readActiveElementState()).toMatchObject({ + activeElement: 'input', + valueLength: 5, + valuePreview: 'tokyo', + }) + }) + + it('descends into a same-origin frame rather than reporting the frame', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '' + setActiveElement(inner, inner.querySelector('input')) + setActiveElement(document, frame) + + expect(readActiveElementState()).toMatchObject({ + activeElement: 'password-field', + redacted: true, + }) + }) +}) + +describe('activeElementSecrecy', () => { + it('reports safe for an ordinary field', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + + expect(activeElementSecrecy()).toBe('safe') + }) + + it('reports safe when nothing is focused', () => { + setActiveElement(document, document.body) + + expect(activeElementSecrecy()).toBe('safe') + }) + + it('reports secret for a focused password field', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + + expect(activeElementSecrecy()).toBe('secret') + }) + + it('reports secret for a password field inside an open shadow root', () => { + const host = document.createElement('div') + document.body.append(host) + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = '' + setActiveElement(shadow as unknown as Document, shadow.querySelector('input')) + setActiveElement(document, host) + + expect(activeElementSecrecy()).toBe('secret') + }) + + it('reports opaque for a cross-origin frame it cannot inspect', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + // A cross-origin frame yields null here; jsdom cannot host one, so the + // boundary is reproduced directly. + Object.defineProperty(frame, 'contentDocument', { configurable: true, get: () => null }) + setActiveElement(document, frame) + + expect(activeElementSecrecy()).toBe('opaque') + }) + + it('descends into a same-origin frame instead of calling it opaque', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '' + setActiveElement(inner, inner.querySelector('input')) + setActiveElement(document, frame) + + expect(activeElementSecrecy()).toBe('safe') + }) +}) + +describe('pressKeyOnPage', () => { + it('refuses to deliver a keystroke to a focused password field', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + + expect(pressKeyOnPage('a', 'KeyA', 65, false, false, false, false)).toEqual({ + error: 'password', + }) + }) + + it('delivers keystrokes to ordinary fields', () => { + document.body.innerHTML = '' + const input = document.querySelector('input') as HTMLInputElement + setActiveElement(document, input) + const seen: string[] = [] + input.addEventListener('keydown', (event) => seen.push(event.key)) + + expect(pressKeyOnPage('a', 'KeyA', 65, false, false, false, false)).toMatchObject({ + pressed: 'a', + }) + expect(seen).toEqual(['a']) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts new file mode 100644 index 0000000000..9b8868b3e8 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -0,0 +1,672 @@ +/** + * Functions injected into automated pages via `webContents.executeJavaScript`. + * The driver serializes each function's source (`String(fn)`) and calls it + * with JSON-encoded arguments, so every function here MUST be fully + * self-contained: no imports, no closed-over variables, only its own + * arguments and page globals. Helpers live INSIDE the function that uses them. + * + * The element registry (`window.__simAgentElements`) is rebuilt by every + * snapshot and naturally cleared by navigation; interaction functions treat a + * missing or disconnected entry as a stale id. + * + * Several functions repeat an identical `isSecretField` helper. That + * duplication is required, not accidental: self-containment means a shared + * module-level helper would not survive serialization. It matches on + * `tagName`/`type`/`autocomplete` rather than `instanceof HTMLInputElement` + * because element wrappers are realm-bound — an input reached through a + * same-origin iframe belongs to that frame's realm, so `instanceof` against + * the top frame's constructor returns false and would skip the check on + * exactly the nested login forms that need it most. + */ + +declare global { + interface Window { + __simAgentElements?: Element[] + } +} + +/** + * Builds the page snapshot: a structural outline (headings, landmarks) with + * interactive elements carrying numeric ids, walking open shadow roots and + * same-origin iframes. Rebuilds the element registry as a side effect. + */ +export function collectSnapshot(): unknown { + const refCap = 300 + const lineCap = 600 + // Surrogate-safe truncation: plain slice() cuts by UTF-16 code units and + // can split an astral character (emoji, 𝐛𝐨𝐥𝐝 text), leaving a lone high + // surrogate that is invalid JSON downstream (Postgres jsonb rejects it). + const cut = (s: string, n: number): string => { + const out = s.slice(0, n) + const last = out.charCodeAt(out.length - 1) + return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out + } + const interactiveSelector = [ + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="textbox"]', + '[role="searchbox"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="combobox"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="tab"]', + '[role="switch"]', + '[role="option"]', + '[role="slider"]', + '[onclick]', + '[contenteditable="true"]', + '[contenteditable=""]', + ].join(', ') + const landmarkSelector = [ + 'nav', + 'main', + 'header', + 'footer', + 'aside', + 'dialog', + '[role="navigation"]', + '[role="main"]', + '[role="banner"]', + '[role="contentinfo"]', + '[role="complementary"]', + '[role="search"]', + '[role="dialog"]', + '[role="form"]', + ].join(', ') + + const registry: Element[] = [] + window.__simAgentElements = registry + const lines: string[] = [] + let truncated = false + + const isVisible = (el: Element): boolean => { + const rect = el.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return false + const doc = el.ownerDocument + const win = doc.defaultView + if (!win) return false + const style = win.getComputedStyle(el) + return style.visibility !== 'hidden' && style.display !== 'none' + } + + const isSecretField = (el: Element | null): boolean => { + if (!el || el.tagName !== 'INPUT') return false + if (String((el as HTMLInputElement).type || '').toLowerCase() === 'password') return true + // A reveal toggle flips the field to type="text" without making its + // contents any less secret, and some forms never use type="password" at + // all. The autocomplete token is the page's own declaration either way. + const hint = String(el.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + const roleFor = (el: Element): string => { + const explicit = el.getAttribute('role') + if (explicit) return explicit + const tag = el.tagName + if (tag === 'A') return 'link' + if (tag === 'BUTTON' || tag === 'SUMMARY') return 'button' + if (tag === 'SELECT') return 'combobox' + if (tag === 'TEXTAREA') return 'textbox' + if (tag === 'INPUT') { + const type = (el as HTMLInputElement).type + if (type === 'checkbox') return 'checkbox' + if (type === 'radio') return 'radio' + if (type === 'submit' || type === 'button' || type === 'reset') return 'button' + return 'textbox' + } + if ((el as HTMLElement).isContentEditable) return 'textbox' + return 'clickable' + } + + const nameFor = (el: Element): string => { + let name = el.getAttribute('aria-label') || '' + if (!name) { + const labels = (el as HTMLInputElement).labels + if (labels && labels.length > 0) name = labels[0].innerText || '' + } + if (!name) name = (el as HTMLElement).innerText || '' + if (!name) { + name = + el.getAttribute('placeholder') || + el.getAttribute('title') || + el.getAttribute('alt') || + el.getAttribute('name') || + '' + } + return cut(name.replace(/\s+/g, ' ').trim(), 120) + } + + const push = (line: string): boolean => { + if (lines.length >= lineCap) { + truncated = true + return false + } + lines.push(line) + return true + } + + const emitInteractive = (el: Element, indent: string): void => { + if (registry.length >= refCap) { + truncated = true + return + } + const id = registry.length + registry.push(el) + let role = roleFor(el) + const parts: string[] = [] + if (isSecretField(el)) { + role = 'password-field' + } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') { + // Tag comparison so fields inside same-origin iframes report their value + // like any other. Redaction above is realm-safe and runs first, so + // widening this cannot expose a credential field. + const value = (el as HTMLInputElement).value + if (value) parts.push(`value="${cut(String(value), 120)}"`) + } + if (el.tagName === 'A') { + const href = el.getAttribute('href') + if (href) parts.push(`href="${cut(href, 200)}"`) + } + if ((el as HTMLInputElement).disabled === true) parts.push('disabled') + if ((el as HTMLInputElement).checked === true) parts.push('checked') + const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : '' + push(`${indent}- ${role} "${nameFor(el)}" [ref=${id}]${suffix}`) + } + + const headingLevel = (el: Element): number | null => { + const match = /^H([1-6])$/.exec(el.tagName) + if (match) return Number(match[1]) + if (el.getAttribute('role') === 'heading') { + const level = Number(el.getAttribute('aria-level') || '2') + return Number.isFinite(level) ? level : 2 + } + return null + } + + const landmarkLabel = (el: Element): string => { + const role = el.getAttribute('role') + const tag = el.tagName.toLowerCase() + const kind = + role || + (tag === 'nav' + ? 'navigation' + : tag === 'header' + ? 'banner' + : tag === 'footer' + ? 'contentinfo' + : tag === 'aside' + ? 'complementary' + : tag) + const label = cut((el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(), 80) + return label ? `${kind} "${label}"` : kind + } + + const walk = (root: ParentNode, depth: number): void => { + if (truncated && registry.length >= refCap) return + for (const el of Array.from(root.children)) { + if (registry.length >= refCap && lines.length >= lineCap) return + const tag = el.tagName + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue + + const indent = ' '.repeat(depth) + let childDepth = depth + + if (el.matches(landmarkSelector) && isVisible(el)) { + if (!push(`${indent}- ${landmarkLabel(el)}:`)) return + childDepth = depth + 1 + } else { + const level = headingLevel(el) + if (level !== null && isVisible(el)) { + const text = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160) + if (text) push(`${indent}- heading "${text}" (h${level})`) + } else if (el.matches(interactiveSelector) && isVisible(el)) { + emitInteractive(el, indent) + // Interactive containers rarely nest other interactives; still + // recurse so e.g. a clickable card exposes its inner links. + } + } + + if (tag === 'IFRAME' || tag === 'FRAME') { + try { + const innerDoc = (el as HTMLIFrameElement).contentDocument + if (innerDoc?.body && isVisible(el)) { + if (!push(`${indent}- iframe:`)) return + walk(innerDoc.body, childDepth + 1) + } + } catch { + // Cross-origin iframe — not readable. + } + continue + } + + const shadow = (el as HTMLElement).shadowRoot + if (shadow) walk(shadow, childDepth) + walk(el, childDepth) + } + } + + if (document.body) walk(document.body, 0) + + return { + url: window.location.href, + title: document.title, + outline: lines.join('\n'), + truncated, + scrollY: Math.round(window.scrollY), + pageHeight: Math.round(document.documentElement.scrollHeight), + viewportHeight: window.innerHeight, + } +} + +export function clickElement(id: number): unknown { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + // Clicking focuses, and a focused credential field is the one state in + // which subsequent keystrokes would land in a password. Refusing the click + // keeps that state unreachable rather than relying on every later keyboard + // path to re-check. + if (isSecretField(el)) return { error: 'password' } + el.scrollIntoView({ block: 'center', inline: 'center' }) + const rect = el.getBoundingClientRect() + const opts = { + bubbles: true, + cancelable: true, + composed: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + button: 0, + } + // Duck-typed rather than `instanceof HTMLElement`: an element reached + // through a same-origin iframe belongs to that frame's realm, so the check + // is false there and the click would skip focus entirely. + const html = el as HTMLElement + el.dispatchEvent(new PointerEvent('pointerdown', opts)) + el.dispatchEvent(new MouseEvent('mousedown', opts)) + if (typeof html.focus === 'function') html.focus() + el.dispatchEvent(new PointerEvent('pointerup', opts)) + el.dispatchEvent(new MouseEvent('mouseup', opts)) + if (typeof html.click === 'function') html.click() + else el.dispatchEvent(new MouseEvent('click', opts)) + const label = (el.getAttribute('aria-label') || (el as HTMLElement).innerText || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80) + // Drop a trailing lone high surrogate the slice may have created. + return { clicked: true, element: label.replace(/[\uD800-\uDBFF]$/, '') } +} + +/** + * Prepares an element for NATIVE typing (driver-side CDP `Input.insertText`): + * focuses it and selects its current content so the inserted text REPLACES + * what's there — including inside code editors (CodeMirror/Monaco), whose + * models sync from the DOM selection / native input pipeline. + */ +export function focusElementForTyping(id: number): unknown { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + el.scrollIntoView({ block: 'center' }) + + if (isSecretField(el)) { + return { error: 'password' } + } + + // Tag comparisons, not `instanceof`: element wrappers are realm-bound, so an + // input inside a same-origin iframe — a framed login form, a TinyMCE body — + // fails every `instanceof` against the top frame's constructors and would be + // reported back as "not a text input". + const tag = el.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + const field = el as HTMLInputElement | HTMLTextAreaElement + field.focus() + field.select() + return { focused: true, kind: tag === 'INPUT' ? 'input' : 'textarea' } + } + + // Editors often register a wrapper as the interactive element while the + // actual editable surface is a descendant. + const editable = (el as HTMLElement).isContentEditable + ? (el as HTMLElement) + : el.querySelector('[contenteditable="true"], [contenteditable=""]') + if (editable) { + editable.focus() + const selection = editable.ownerDocument.defaultView?.getSelection() + if (selection) { + const range = editable.ownerDocument.createRange() + range.selectNodeContents(editable) + selection.removeAllRanges() + selection.addRange(range) + } + return { focused: true, kind: 'contenteditable' } + } + return { error: 'not-editable' } +} + +/** + * Reads back the focused element's state after a native key/type action so + * the driver can report what actually happened instead of assuming success. + */ +export function readActiveElementState(): unknown { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + // Focus inside a frame or an open shadow root surfaces on the outer document + // as the host element, so descend to find what is really focused. + let active = document.activeElement as HTMLElement | null + for (let depth = 0; active && depth < 10; depth++) { + const shadow = active.shadowRoot + if (shadow?.activeElement) { + active = shadow.activeElement as HTMLElement + continue + } + if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') { + try { + const inner = (active as HTMLIFrameElement).contentDocument + if (inner?.activeElement && inner.activeElement !== inner.body) { + active = inner.activeElement as HTMLElement + continue + } + } catch { + // Cross-origin frame — not inspectable, report the frame itself. + } + } + break + } + + if (!active || active === active.ownerDocument.body) { + return { activeElement: 'body', selectedChars: 0, valueLength: 0, valuePreview: '' } + } + // Length and selection size are withheld along with the value: both are + // observations of a secret the agent is never allowed to learn. + if (isSecretField(active)) { + return { + activeElement: 'password-field', + selectedChars: 0, + valueLength: 0, + valuePreview: '', + redacted: true, + } + } + let value = '' + let selectedChars = 0 + if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') { + const field = active as HTMLInputElement | HTMLTextAreaElement + value = field.value + selectedChars = Math.abs((field.selectionEnd ?? 0) - (field.selectionStart ?? 0)) + } else { + value = active.innerText || active.textContent || '' + selectedChars = window.getSelection()?.toString().length ?? 0 + } + return { + activeElement: active.tagName.toLowerCase(), + selectedChars, + valueLength: value.length, + // Trailing regex drops a lone high surrogate the slice may have created. + valuePreview: value + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .replace(/[\uD800-\uDBFF]$/, ''), + } +} + +/** + * Classifies what currently holds focus, for the driver's pre-dispatch check + * on trusted CDP key events (which bypass the page entirely and land on + * whatever is focused, so this cannot be enforced from inside the page alone): + * + * - `secret` — a credential field. No keystroke belongs here. + * - `opaque` — a cross-origin frame we cannot inspect, so a credential field + * cannot be ruled out. Character insertion is refused; caret movement and + * Escape are not. + * - `safe` — anything we can see and that is not a credential field. + */ +export function activeElementSecrecy(): string { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + let active = document.activeElement as HTMLElement | null + for (let depth = 0; active && depth < 10; depth++) { + if (isSecretField(active)) return 'secret' + const shadow = active.shadowRoot + if (shadow?.activeElement) { + active = shadow.activeElement as HTMLElement + continue + } + if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') { + let inner: Document | null = null + try { + inner = (active as HTMLIFrameElement).contentDocument + } catch { + return 'opaque' + } + // A same-origin frame yields a document; a cross-origin one yields null. + if (!inner) return 'opaque' + if (inner.activeElement && inner.activeElement !== inner.body) { + active = inner.activeElement as HTMLElement + continue + } + } + break + } + return isSecretField(active) ? 'secret' : 'safe' +} + +export function typeIntoElement(id: number, text: string, submit: boolean): unknown { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + el.scrollIntoView({ block: 'center' }) + + if (isSecretField(el)) { + return { error: 'password' } + } + + const tag = el.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + const field = el as HTMLInputElement | HTMLTextAreaElement + field.focus() + // The native setter must come from the element's OWN realm. A same-origin + // iframe has its own constructors, and calling the top frame's setter on + // one of its nodes throws "Illegal invocation". + const view = el.ownerDocument.defaultView ?? window + const proto = + tag === 'INPUT' ? view.HTMLInputElement.prototype : view.HTMLTextAreaElement.prototype + const descriptor = Object.getOwnPropertyDescriptor(proto, 'value') + if (descriptor?.set) descriptor.set.call(field, text) + else field.value = text + field.dispatchEvent(new Event('input', { bubbles: true })) + field.dispatchEvent(new Event('change', { bubbles: true })) + } else if ((el as HTMLElement).isContentEditable) { + const editable = el as HTMLElement + editable.focus() + editable.textContent = text + editable.dispatchEvent( + new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' }) + ) + } else { + return { error: 'not-editable' } + } + + if (submit) { + const key = { + bubbles: true, + cancelable: true, + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + } + const notCancelled = el.dispatchEvent(new KeyboardEvent('keydown', key)) + el.dispatchEvent(new KeyboardEvent('keyup', key)) + const form = (el as HTMLInputElement).form ?? (el as HTMLElement).closest?.('form') ?? null + if (notCancelled && form) { + if (typeof form.requestSubmit === 'function') form.requestSubmit() + else form.submit() + } + } + return { typed: true, submitted: submit === true } +} + +export function pressKeyOnPage( + key: string, + code: string, + keyCode: number, + ctrl: boolean, + meta: boolean, + shift: boolean, + alt: boolean +): unknown { + const isSecretField = (node: Element | null): boolean => { + if (!node || node.tagName !== 'INPUT') return false + if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true + const hint = String(node.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' + } + + const target = (document.activeElement as HTMLElement | null) ?? document.body + // The driver checks focus before taking the trusted CDP path; this covers + // the synthetic fallback, which is reached independently when CDP is down. + if (isSecretField(target)) return { error: 'password' } + const opts = { + bubbles: true, + cancelable: true, + key, + code, + keyCode, + which: keyCode, + ctrlKey: ctrl, + metaKey: meta, + shiftKey: shift, + altKey: alt, + } + target.dispatchEvent(new KeyboardEvent('keydown', opts)) + target.dispatchEvent(new KeyboardEvent('keyup', opts)) + return { pressed: key, target: target.tagName.toLowerCase() } +} + +export function scrollPage(direction: string, amount?: number): unknown { + const distance = typeof amount === 'number' && amount > 0 ? amount : window.innerHeight * 0.85 + window.scrollBy({ top: direction === 'up' ? -distance : distance, behavior: 'instant' }) + const scrollY = Math.round(window.scrollY) + const pageHeight = Math.round(document.documentElement.scrollHeight) + return { + scrollY, + pageHeight, + atTop: scrollY <= 0, + atBottom: scrollY + window.innerHeight >= pageHeight - 2, + } +} + +export function selectOptionInElement(id: number, value: string): unknown { + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + if (el.tagName !== 'SELECT') return { error: 'not-select' } + const select = el as HTMLSelectElement + const wanted = value.trim().toLowerCase() + const option = Array.from(select.options).find( + (o) => o.value.trim().toLowerCase() === wanted || o.label.trim().toLowerCase() === wanted + ) + if (!option) { + return { + error: 'no-option', + options: Array.from(select.options) + .slice(0, 50) + .map((o) => o.label.trim()), + } + } + select.value = option.value + select.dispatchEvent(new Event('input', { bubbles: true })) + select.dispatchEvent(new Event('change', { bubbles: true })) + return { selected: option.label.trim() } +} + +export function hoverElement(id: number): unknown { + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + el.scrollIntoView({ block: 'center' }) + const rect = el.getBoundingClientRect() + const opts = { + bubbles: true, + cancelable: true, + composed: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + } + el.dispatchEvent(new PointerEvent('pointerover', opts)) + el.dispatchEvent(new PointerEvent('pointerenter', opts)) + el.dispatchEvent(new MouseEvent('mouseover', opts)) + el.dispatchEvent(new MouseEvent('mouseenter', opts)) + el.dispatchEvent(new PointerEvent('pointermove', opts)) + el.dispatchEvent(new MouseEvent('mousemove', opts)) + return { hovered: true } +} + +export function readPageText(id?: number): unknown { + const maxChars = 30000 + let text: string + if (typeof id === 'number') { + const el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + text = (el as HTMLElement).innerText ?? el.textContent ?? '' + } else { + text = document.body?.innerText ?? '' + } + const trimmed = text.replace(/\n{3,}/g, '\n\n').trim() + return { + url: window.location.href, + title: document.title, + // Trailing regex drops a lone high surrogate the slice may have created. + text: trimmed.slice(0, maxChars).replace(/[\uD800-\uDBFF]$/, ''), + truncated: trimmed.length > maxChars, + } +} + +export function pageContainsText(text: string): boolean { + return Boolean(document.body?.innerText.includes(text)) +} + +export function getViewportInfo(): unknown { + return { + url: window.location.href, + title: document.title, + width: window.innerWidth, + height: window.innerHeight, + } +} diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts new file mode 100644 index 0000000000..e6f154ce07 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow, WebContentsView } from 'electron' +import * as panelModule from '@/main/browser-agent/panel' + +type PanelModule = typeof import('@/main/browser-agent/panel') + +/** + * `initPanel` is a full reset of the module's session state, so a clean panel + * needs no module reload — which is what lets this file use a static import + * instead of the `vi.resetModules()` the root CLAUDE.md forbids. + * + * The reset happens here rather than being left to `showPanel`, so a test that + * never shows a panel still starts from a clean one. + */ +function freshPanel(): PanelModule { + panelModule.initPanel({ + getMainWindow: () => null, + activeTab: () => null, + ensureInitialTab: () => {}, + onViewDetached: () => {}, + }) + return panelModule +} + +const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 } + +/** A panel showing one tab, which is the state occlusion applies to. */ +function showPanel(panel: PanelModule) { + const win = new BrowserWindow() + const view = new WebContentsView() + let active = { id: 'tab-1', view, pinned: false } + panel.initPanel({ + getMainWindow: () => win, + activeTab: () => active, + ensureInitialTab: () => {}, + onViewDetached: () => {}, + }) + panel.setPanelBounds(PANEL_RECT, win) + /** Swaps in another tab's view, as switching tabs does. */ + const switchTab = (next: WebContentsView) => { + active = { id: 'tab-2', view: next, pinned: false } + panel.layout() + } + return { win, view, switchTab } +} + +/** When the view was hidden, in the global mock invocation order. */ +function hiddenAt(view: WebContentsView): number | undefined { + const call = vi.mocked(view.setVisible).mock.calls.findIndex(([visible]) => visible === false) + return call === -1 ? undefined : vi.mocked(view.setVisible).mock.invocationCallOrder[call] +} + +/** When the replacement frame was pushed to the renderer. */ +function snapshotSentAt(win: BrowserWindow): number | undefined { + const call = vi + .mocked(win.webContents.send) + .mock.calls.findIndex(([channel]) => channel === 'browser-agent:panel-snapshot') + return call === -1 ? undefined : vi.mocked(win.webContents.send).mock.invocationCallOrder[call] +} + +describe('panel occlusion', () => { + let panel: PanelModule + + beforeEach(() => { + panel = freshPanel() + }) + + it('keeps the page up until its replacement frame exists', async () => { + const { win, view } = showPanel(panel) + + panel.setPanelOccluded(true, win) + + // Hiding here is what produced the flash: the renderer paints its snapshot + // as soon as it reports occlusion, and the only frame it holds until the + // new one lands is the previous overlay's — a different scroll position, or + // nothing at all. + expect(hiddenAt(view)).toBeUndefined() + }) + + it('sends the frame before hiding, so the swap shows no seam', async () => { + const { win, view } = showPanel(panel) + + panel.setPanelOccluded(true, win) + await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined()) + + const sent = snapshotSentAt(win) + expect(sent).toBeDefined() + expect(sent).toBeLessThan(hiddenAt(view) as number) + }) + + it('stays visible when the overlay closes while the frame is being taken', async () => { + const { win, view } = showPanel(panel) + + panel.setPanelOccluded(true, win) + panel.setPanelOccluded(false, win) + await vi.waitFor(() => expect(snapshotSentAt(win)).toBeDefined()) + + // Hiding after the overlay is gone would blank the page with nothing above it. + expect(hiddenAt(view)).toBeUndefined() + }) + + it('photographs a visible page as visible, so no scrollbar flashes into the frame', async () => { + const { win, view } = showPanel(panel) + + panel.setPanelOccluded(true, win) + + // Asking to capture a visible page as hidden moves its visibility state, + // and Chromium flashes overlay scrollbars across that transition — which + // the frame then freezes, so the swap shows a scrollbar the page lacked. + expect(view.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: false }) + }) + + it('keeps an already-hidden page hidden when a tab switch needs a frame', async () => { + const { win, view, switchTab } = showPanel(panel) + panel.setPanelOccluded(true, win) + await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined()) + + // Switching tabs under an open overlay re-captures, and that panel really + // is hidden — waking it for the shot is what the flag exists to prevent. + const next = new WebContentsView() + switchTab(next) + + expect(next.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }) + }) + + it('hides anyway when the frame cannot be taken', async () => { + const { win, view } = showPanel(panel) + vi.mocked(view.webContents.capturePage).mockRejectedValue(new Error('capture failed')) + + panel.setPanelOccluded(true, win) + + // Waiting forever on a failed capture would leave the page over the overlay. + await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined()) + expect(snapshotSentAt(win)).toBeUndefined() + }) + + it('accepts occlusion again after the panel is hidden and shown', async () => { + const { win, view } = showPanel(panel) + panel.setPanelOccluded(true, win) + await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined()) + + // Hiding the panel forgets both halves of the occlusion state; a stale + // "already requested" would dedupe the next overlay's report away and leave + // the page painting over it. + panel.setPanelBounds(null, win) + panel.setPanelBounds(PANEL_RECT, win) + vi.mocked(view.setVisible).mockClear() + panel.setPanelOccluded(true, win) + + await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined()) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts new file mode 100644 index 0000000000..44b84dee14 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -0,0 +1,518 @@ +/** + * Compositing for the embedded browser: where the native view sits inside a + * Sim window, when it is visible, and which window owns it. + * + * The browser is ONE native surface shared by every app window, so exactly one + * window may drive it at a time. That, the renderer bounds lease, and the + * occlusion snapshot are the intricate parts of the browser and are kept here, + * apart from tab bookkeeping. + * + * Depends on the session only through {@link PanelHost}, injected once at + * startup. Tab state changes are pushed in by the session calling {@link layout}; + * this module never reaches back into it, so the import graph stays one-way. + */ +import type { + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserPanelSnapshot, +} from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { BrowserWindow, WebContentsView } from 'electron' +import type { AgentTab } from '@/main/browser-agent/session' + +const logger = createLogger('BrowserAgentPanel') + +/** + * The renderer renews the panel rect on a heartbeat. If it goes quiet — the + * page crashed, unmounted, or wedged — the lease expires and the native view + * is hidden rather than left floating over whatever replaced the panel. + */ +const PANEL_LEASE_TTL_MS = 2_500 +const PANEL_LEASE_CHECK_MS = 1_000 + +/** What the panel needs from the session, supplied once by {@link initPanel}. */ +export interface PanelHost { + getMainWindow: () => BrowserWindow | null + /** The tab whose view should be composited, or null when there is none. */ + activeTab: () => AgentTab | null + /** + * Materializes the initial tab when the panel first becomes visible: a + * visible browser resource always represents one open browser window, and + * the tab strip, omnibox, and native session must not disagree about that. + */ + ensureInitialTab: () => void + /** Lets the session drop focus tracking for a view that is no longer attached. */ + onViewDetached: (view: WebContentsView | null) => void +} + +let host: PanelHost = { + getMainWindow: () => null, + activeTab: () => null, + ensureInitialTab: () => {}, + onViewDetached: () => {}, +} + +/** Where the panel sits in the window (CSS px); null = panel hidden. */ +let panelBounds: BrowserPanelBounds | null = null +/** How {@link panelBounds} derives from the viewport, when the renderer said. */ +let panelAnchor: BrowserPanelAnchor | null = null +/** True while the view is actually hidden for renderer-owned UI above it. */ +let panelOccluded = false +/** + * What the renderer last reported, which leads {@link panelOccluded} while a + * frame is being captured. Hiding is what has to wait: the renderer paints its + * snapshot the moment it believes the panel is occluded, and the only frame it + * has until the new one lands is the one from the previous overlay — a picture + * of a different scroll position, or nothing at all. Staying visible until the + * replacement is sent means the swap is invisible instead of a flash. + */ +let panelOcclusionRequested = false +let panelLeaseAt = 0 +let leaseTimer: ReturnType | null = null +let panelSnapshotGeneration = 0 +/** The window currently hosting the active view, for re-parenting checks. */ +let hostedWindow: BrowserWindow | null = null +/** The app window whose renderer most recently leased the visible panel. */ +let panelOwnerWindow: BrowserWindow | null = null +/** The view attached to the host window (attach only on change — re-adding an + * attached view re-stacks it and can flicker the composite). */ +let attachedView: WebContentsView | null = null +let lastAppliedBounds = '' +let lastAppliedVisibility: boolean | null = null +/** The host window whose `resize` currently drives {@link layout}, if any. */ +let resizeBoundWindow: BrowserWindow | null = null +/** Captures nothing, so one instance serves every window it is bound to. */ +const onHostResize = () => layout() + +export function initPanel(panelHost: PanelHost): void { + // A real reset, not a partial setter. Everything below is per-session state, + // and this call IS the session boundary — anything left behind is inherited + // by the next session: a stale owner window that rejects legitimate panel + // updates, a lease timer polling for a panel that no longer exists, a + // `lastApplied*` value that dedupes away the first layout of the new one. + detachAttachedView() + resetOcclusion() + if (leaseTimer !== null) { + clearInterval(leaseTimer) + leaseTimer = null + } + host = panelHost + panelBounds = null + panelAnchor = null + panelLeaseAt = 0 + panelOwnerWindow = null +} + +/** + * The window owning the visible panel, or null. Self-healing: a destroyed + * owner is forgotten here rather than left to reject panel updates from a + * window that is legitimately showing the browser. + */ +function panelOwner(): BrowserWindow | null { + if (panelOwnerWindow?.isDestroyed()) { + panelOwnerWindow = null + } + return panelOwnerWindow +} + +/** The window the panel's native view and pushes belong to. */ +export function panelWindow(): BrowserWindow | null { + return panelOwner() ?? host.getMainWindow() +} + +/** + * Whether a window may act on the panel. An unowned panel accepts anyone; once + * owned, only the owner — so a stale report from a second window cannot hide + * or steal the singleton browser surface. + */ +export function panelUpdateAllowed(ownerWindow?: BrowserWindow): boolean { + if (!ownerWindow) return true + const owner = panelOwner() + return owner === null || owner === ownerWindow +} + +/** + * Whether a window may report panel bounds, given which Sim window has OS + * focus. Ownership transfers only to the focused window: when Sim is in the + * background nothing is focused, and without this rule every window with the + * panel mounted would reclaim it on its next heartbeat, re-parenting the + * native view back and forth about once a second. + */ +export function canReportPanelBounds( + win: BrowserWindow, + focusedWindow: BrowserWindow | null +): boolean { + const owner = panelOwner() + return owner === null || owner === win || focusedWindow === win +} + +/** True while the renderer is reporting a panel rect. */ +export function isPanelVisible(): boolean { + return panelBounds !== null +} + +/** + * Re-lays-out on the host window's own `resize` (~68/sec during a live drag, one + * per resize step) so the view tracks the frame it is actually in. + * + * @see layout — the resize is a trigger, never a source of bounds. + */ +function bindHostResize(win: BrowserWindow): void { + if (resizeBoundWindow === win) return + unbindHostResize() + win.on('resize', onHostResize) + resizeBoundWindow = win +} + +function unbindHostResize(): void { + if (resizeBoundWindow && !resizeBoundWindow.isDestroyed()) { + resizeBoundWindow.removeListener('resize', onHostResize) + } + resizeBoundWindow = null +} + +/** + * Re-derives the rect for a viewport the renderer has not measured yet, from the + * rule it declared plus the rect it measured at `anchor`'s own viewport. + * Everything but the width ratio falls out of that pair: the insets are + * size-invariant, and the width residual is whatever the ratio leaves over. + * + * Null when the viewport still matches the measured one, so the measurement + * wins wherever it is exact and a wrong ratio can only reach live-resize frames. + */ +function evaluateAnchor( + anchor: BrowserPanelAnchor | null, + measured: BrowserPanelBounds, + viewportWidth: number, + viewportHeight: number +): BrowserPanelBounds | null { + if ( + anchor === null || + (viewportWidth === anchor.viewportWidth && viewportHeight === anchor.viewportHeight) + ) { + return null + } + const widthOffset = measured.width - anchor.viewportWidth * anchor.widthRatio + const rightInset = anchor.viewportWidth - (measured.x + measured.width) + const bottom = anchor.viewportHeight - (measured.y + measured.height) + const width = viewportWidth * anchor.widthRatio + widthOffset + return { + x: viewportWidth - rightInset - width, + y: measured.y, + width, + height: viewportHeight - measured.y - bottom, + } +} + +/** + * Confines a rect to the window's content box, and owns the 1px floor for the + * whole path. Pure constraint — it needs no model of where the panel sits. + */ +function clampToContent( + rect: BrowserPanelBounds, + contentWidth: number, + contentHeight: number +): BrowserPanelBounds { + const x = Math.min(Math.max(0, rect.x), Math.max(0, contentWidth - 1)) + const y = Math.min(Math.max(0, rect.y), Math.max(0, contentHeight - 1)) + return { + x, + y, + width: Math.max(1, Math.min(rect.width, contentWidth - x)), + height: Math.max(1, Math.min(rect.height, contentHeight - y)), + } +} + +/** + * Clears the tracked attachment before touching Electron objects so a stale + * host or child view cannot leave layout permanently wedged after teardown. + */ +export function detachAttachedView(): void { + const view = attachedView + const win = hostedWindow + attachedView = null + hostedWindow = null + lastAppliedBounds = '' + lastAppliedVisibility = null + unbindHostResize() + host.onViewDetached(view) + + if (!view || !win) return + try { + if (win.isDestroyed() || view.webContents.isDestroyed()) return + win.contentView.removeChildView(view) + } catch (error) { + logger.warn('Could not detach embedded browser view', { + error: getErrorMessage(error, 'unknown'), + }) + } +} + +/** + * Stops the attached view painting without giving up its compositor surface, + * so showing it again is immediate. A hidden view takes no input either, which + * is what lets renderer UI sit where it used to be. + */ +function hideAttachedView(): void { + const view = attachedView + if (!view || lastAppliedVisibility === false) return + lastAppliedVisibility = false + // Nothing to re-lay-out while hidden; the showing path rebinds. + unbindHostResize() + try { + if (!view.webContents.isDestroyed()) view.setVisible(false) + } catch (error) { + logger.warn('Could not hide embedded browser view', { + error: getErrorMessage(error, 'unknown'), + }) + } +} + +/** + * Detaches only when this exact view is the attached one. Closing a background + * tab must not pull the visible tab out of the window. + */ +export function detachIfAttached(view: WebContentsView): void { + if (attachedView === view) { + detachAttachedView() + } +} + +/** Forgets both halves of the occlusion state, so a later report is not deduped away. */ +function resetOcclusion(): void { + panelOccluded = false + panelOcclusionRequested = false + panelSnapshotGeneration++ +} + +/** + * Captures the current browser frame for the renderer to display while the + * native view is hidden beneath an overlay. + * + * The capture is a copy of the compositor surface, so it can never relayout the + * page — but asking to capture a VISIBLE page as hidden perturbs its visibility + * bookkeeping, and Chromium flashes overlay scrollbars across that transition. + * The frame then freezes the flash, and the swap shows a scrollbar the live page + * did not have. So the flag tracks what the view actually is: hidden only for + * the one caller that captures an already-hidden page (a tab switched while the + * panel is occluded), where it stops Chromium promoting it back for the shot. + */ +/** Widest the placeholder snapshot needs to be; it sits behind a transient overlay. */ +const SNAPSHOT_MAX_WIDTH = 1024 +const SNAPSHOT_JPEG_QUALITY = 70 + +/** + * Turns a captured frame into a compact JPEG data URL. Resize is a native + * operation and JPEG encode runs in native code too, so both are far cheaper + * than a full-resolution PNG `toDataURL`, which encodes synchronously on the + * event loop. + */ +function encodeSnapshot(image: Electron.NativeImage): string { + const { width } = image.getSize() + const scaled = width > SNAPSHOT_MAX_WIDTH ? image.resize({ width: SNAPSHOT_MAX_WIDTH }) : image + const jpeg = scaled.toJPEG(SNAPSHOT_JPEG_QUALITY) + return `data:image/jpeg;base64,${jpeg.toString('base64')}` +} + +function capturePanelSnapshot(onSettled?: () => void): void { + const active = host.activeTab() + const win = panelWindow() + if (!active || !win || active.view.webContents.isDestroyed()) { + onSettled?.() + return + } + + const generation = ++panelSnapshotGeneration + const tabId = active.id + void active.view.webContents + .capturePage(undefined, { stayHidden: panelOccluded }) + .then((image) => { + if (generation !== panelSnapshotGeneration || image.isEmpty()) return + // Ownership can move while the capture is in flight. This frame is a + // picture of the page, so it goes to the window still showing the + // browser or nowhere at all. + if (panelWindow() !== win || win.isDestroyed()) return + // Downscale and JPEG-encode before crossing IPC. capturePage returns a + // device-pixel PNG — on a retina half-window that is millions of pixels, + // and toDataURL's PNG encode is synchronous on the main process, so a + // full-size encode stalls every window's input for the frame. This is a + // placeholder shown under a transient overlay, so a downscaled JPEG is + // indistinguishable and an order of magnitude cheaper to encode and send. + const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId } + win.webContents.send('browser-agent:panel-snapshot', snapshot) + }) + .catch((error) => { + logger.warn('Could not capture browser panel snapshot', { + error: getErrorMessage(error), + }) + }) + .finally(() => onSettled?.()) +} + +/** + * Repositions the active view over the panel rect inside its window + * (re-parenting if that window was recreated), and detaches it when the panel + * is hidden. CSS pixels scale to DIP by the page's zoom factor. Idempotent: + * repeated calls with unchanged inputs perform no view mutations. + * + * The renderer's measured report is the ONLY writer of bounds. This module + * used to also predict a rect on the window's own `resize` event, on the + * premise that the panel was right-anchored at a constant width — true only + * after a divider drag pins an inline pixel width. The panel's default is + * `w-1/2`, so the prediction was wrong by half the frame's window travel and, + * because it shared this dedup key, the two writers each invalidated the + * other's key and applied a different rect twice per frame. That double + * compositor resize was the "swimming" the prediction was meant to prevent. + * A divider drag still gets a predicted rect, from the renderer, where the + * arithmetic is exact because only the panel's left edge moves. + * + * The window's `resize` does drive this function (see {@link bindHostResize}), + * but only as a trigger — it supplies no rect. The report the renderer already + * sent is re-clamped against the new content box, so a shrink can never leave + * the view overhanging the frame while that report is one frame stale. The + * clamp needs no model of where the panel sits, which is exactly what the + * reverted prediction did need. Bounds keep one writer and one dedup key, so + * the contention above cannot recur: on a grow the clamp is inert and the key + * is unchanged, costing no view mutation at all. + */ +export function layout(): void { + const win = panelWindow() + const active = host.activeTab() + const showing = active !== null && panelBounds !== null && win !== null + const activeViewChanged = showing && attachedView !== active?.view + + // Detach only when the attached view cannot stay where it is: no tab is + // active, a different tab took over, or the hosting window changed. + // + // A panel that is merely hidden keeps its view attached and invisible, for + // the same reason occlusion does (see setPanelOccluded): removing the view + // gives up its compositor surface, and rebuilding that on the way back is a + // blank repaint that reads as the page having reloaded. Every switch to + // another resource and back hides the panel, so that was every switch. + if ( + attachedView !== null && + (active === null || win === null || hostedWindow !== win || attachedView !== active.view) + ) { + detachAttachedView() + } + if (!showing || !active || !win || panelBounds === null) { + hideAttachedView() + return + } + + if (attachedView !== active.view) { + win.contentView.addChildView(active.view) + hostedWindow = win + attachedView = active.view + if (panelOccluded && activeViewChanged) { + capturePanelSnapshot() + } + } + bindHostResize(win) + const zoom = win.webContents.getZoomFactor() + const [contentWidth, contentHeight] = win.getContentSize() + // The anchor is declared in the renderer's CSS pixels, so compare and + // evaluate there, then scale the result the same way a measured rect is. + const rect = + evaluateAnchor(panelAnchor, panelBounds, contentWidth / zoom, contentHeight / zoom) ?? + panelBounds + const bounds = clampToContent( + { + x: Math.round(rect.x * zoom), + y: Math.round(rect.y * zoom), + width: Math.round(rect.width * zoom), + height: Math.round(rect.height * zoom), + }, + contentWidth, + contentHeight + ) + const boundsKey = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}` + if (boundsKey !== lastAppliedBounds) { + lastAppliedBounds = boundsKey + active.view.setBounds(bounds) + } + const visible = !panelOccluded + if (visible !== lastAppliedVisibility) { + lastAppliedVisibility = visible + active.view.setVisible(visible) + } +} + +/** + * Renderer-reported panel rect (null = panel hidden/unmounted). When an owner + * is supplied, stale reports from another app window cannot steal or hide the + * singleton browser surface. + */ +export function setPanelBounds( + bounds: BrowserPanelBounds | null, + ownerWindow?: BrowserWindow, + anchor?: BrowserPanelAnchor +): void { + // A closing window releases the panel from its `closed` handler, by which + // point Electron has already destroyed it. That release has to be honoured + // or the panel stays "visible" with a dead owner, and the next layout + // re-parents the native view onto whatever window is active — over a UI + // that never asked for it — until the bounds lease expires. + if (bounds !== null && ownerWindow?.isDestroyed()) return + // Only the owner may hide the panel; a stale report from another window + // must not pull the browser out from under the window displaying it. + if (bounds === null && !panelUpdateAllowed(ownerWindow)) return + if (bounds !== null) { + panelOwnerWindow = ownerWindow ?? host.getMainWindow() + } else { + panelOwnerWindow = null + } + panelBounds = bounds + panelAnchor = bounds === null ? null : (anchor ?? null) + if (bounds !== null) { + host.ensureInitialTab() + } + if (bounds === null) { + resetOcclusion() + } + panelLeaseAt = Date.now() + if (bounds !== null && leaseTimer === null) { + leaseTimer = setInterval(() => { + if (panelBounds !== null && Date.now() - panelLeaseAt > PANEL_LEASE_TTL_MS) { + logger.info('Panel bounds lease expired; hiding embedded browser view') + panelBounds = null + panelOwnerWindow = null + resetOcclusion() + layout() + } + if (panelBounds === null && leaseTimer !== null) { + clearInterval(leaseTimer) + leaseTimer = null + } + }, PANEL_LEASE_CHECK_MS) + } + layout() +} + +/** + * Renderer-reported native-surface occlusion. The view stays attached and + * keeps its bounds while hidden, avoiding the flicker and restacking caused by + * removing and re-adding it for every tooltip or menu. + * + * Revealing is immediate; hiding waits for the frame that replaces it (see + * {@link panelOcclusionRequested}). A capture that fails or finds nothing to + * photograph still hides, so an overlay is never left with the page on top. + */ +export function setPanelOccluded(occluded: boolean, ownerWindow?: BrowserWindow): void { + if (!panelUpdateAllowed(ownerWindow)) return + if (panelOcclusionRequested === occluded) return + panelOcclusionRequested = occluded + if (!occluded) { + panelOccluded = false + layout() + return + } + capturePanelSnapshot(() => { + // The overlay can close while its frame is being taken; hiding then would + // blank the page with nothing above it. + if (!panelOcclusionRequested) return + panelOccluded = true + layout() + }) +} diff --git a/apps/desktop/src/main/browser-agent/registry.ts b/apps/desktop/src/main/browser-agent/registry.ts new file mode 100644 index 0000000000..eeabc9de40 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/registry.ts @@ -0,0 +1,20 @@ +import type { WebContents } from 'electron' + +/** + * Registry of WebContents that belong to the agent browser (the browser-agent + * session's tabs). The global security guards consult this to swap the + * app-origin navigation policy for the agent policy (free http/https + * browsing) on exactly these contents and nothing else. + * + * Registration happens right after a view is constructed; the guards check at + * navigation time, so the post-construction registration races nothing. + */ +const agentContents = new WeakSet() + +export function registerAgentWebContents(contents: WebContents): void { + agentContents.add(contents) +} + +export function isAgentWebContents(contents: WebContents): boolean { + return agentContents.has(contents) +} diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts new file mode 100644 index 0000000000..8ba73fcfc9 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -0,0 +1,1435 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { MAX_BROWSER_TABS } from '@sim/browser-protocol' +import { sleep } from '@sim/utils/helpers' +import { BrowserWindow, session as electronSession } from 'electron' +import * as panel from '@/main/browser-agent/panel' +import * as sessionModule from '@/main/browser-agent/session' + +type SessionModule = typeof import('@/main/browser-agent/session') + +interface MockView { + webContents: { + session: { + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + } + on: ReturnType + setWindowOpenHandler: ReturnType + loadURL: ReturnType + getURL: ReturnType + close: ReturnType + focus: ReturnType + isFocused: ReturnType + isDestroyed: ReturnType + setBackgroundThrottling: ReturnType + capturePage: ReturnType + findInPage: ReturnType + stopFindInPage: ReturnType + } + setBackgroundColor: ReturnType + setBounds: ReturnType + setVisible: ReturnType +} + +function mainWindowMock() { + const win = new BrowserWindow() as unknown as { + contentView: { + addChildView: ReturnType + removeChildView: ReturnType + } + webContents: { getZoomFactor?: ReturnType } + } + win.webContents.getZoomFactor = vi.fn(() => 1) + return win as unknown as BrowserWindow +} + +/** + * `initSession` is a full reset of both this module's and the panel's + * per-session state, so a clean session needs no module reload — which is what + * lets this file use static imports instead of the `vi.resetModules()` the + * root CLAUDE.md forbids. + */ +function freshSession( + win: BrowserWindow | null | (() => BrowserWindow | null), + eventOverrides: Partial = {}, + persistence?: sessionModule.PinnedTabPersistence +): SessionModule { + const mainWindowProvider = typeof win === 'function' ? win : () => win + const session = sessionModule + session.initSession( + { + onSessionClosed: vi.fn(), + onTabCreated: vi.fn(), + onActiveTabChanged: vi.fn(), + onTabsChanged: vi.fn(), + onTabThemeChanged: vi.fn(), + onDownloadBlocked: vi.fn(), + onTabNavigated: vi.fn(), + onTabClosed: vi.fn(), + ...eventOverrides, + }, + mainWindowProvider, + persistence + ) + return session +} + +/** The host `resize` listener panel.ts binds while a view is attached. */ +function hostResizeHandler(win: BrowserWindow): () => void { + const calls = (win as unknown as { on: ReturnType }).on.mock.calls + const handler = calls.find(([event]) => event === 'resize')?.[1] + if (typeof handler !== 'function') throw new Error('no host resize listener bound') + return handler as () => void +} + +describe('browser-agent session', () => { + let win: BrowserWindow + let session: SessionModule + + beforeEach(async () => { + win = mainWindowMock() + session = freshSession(win) + }) + + it('creates the first tab lazily, then reuses it', () => { + expect(session.hasSession()).toBe(false) + const first = session.ensureTab() + expect(session.hasSession()).toBe(true) + expect(session.ensureTab()).toBe(first) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0]).toMatchObject({ tabId: first.id, active: true }) + }) + + it('starts a second session clean instead of inheriting the first', () => { + // `initSession` names itself as the session boundary but used to set three + // of its thirteen fields, so everything else leaked into the next session: + // its tabs, its theme, its find, its tab-id counter. Nothing re-inits in + // production today, which is exactly why the gap stayed invisible — and + // why these tests had to reset the whole MODULE to get a clean one. + const firstTab = session.ensureTab() + session.setBrowserTheme('dark') + expect(session.listTabs()).toHaveLength(1) + + const second = freshSession(win) + + expect(second.listTabs()).toHaveLength(0) + expect(second.getBrowserTheme()).toBe('system') + // Same id as the first session's first tab: the counter restarted, so a + // stale id cannot address a tab that outlived the session it came from. + expect(second.ensureTab().id).toBe(firstTab.id) + }) + + it('normalizes browser shortcuts to Command on macOS and Control elsewhere', () => { + const input = { + type: 'keyDown', + key: 'l', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: false, + alt: false, + meta: true, + } + + expect(session.browserShortcutForInput(input, 'darwin')).toBe('focus-omnibox') + expect(session.browserShortcutForInput(input, 'win32')).toBeNull() + expect(session.browserShortcutForInput({ ...input, meta: false, control: true }, 'win32')).toBe( + 'focus-omnibox' + ) + expect(session.browserShortcutForInput({ ...input, key: 't' }, 'darwin')).toBe('new-tab') + expect(session.browserShortcutForInput({ ...input, key: 'w' }, 'darwin')).toBe('close-tab') + expect(session.browserShortcutForInput({ ...input, key: 'f' }, 'darwin')).toBe('find') + expect( + session.browserShortcutForInput({ ...input, key: 't', shift: true }, 'darwin') + ).toBeNull() + }) + + it('handles browser shortcuts from a focused native tab', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + const firstContents = (first.view as unknown as MockView).webContents + const beforeInput = firstContents.on.mock.calls.find( + ([eventName]) => eventName === 'before-input-event' + )?.[1] as + | ((event: { preventDefault: () => void }, input: Record) => void) + | undefined + const event = { preventDefault: vi.fn() } + const input = { + type: 'keyDown', + key: 'l', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: process.platform !== 'darwin', + alt: false, + meta: process.platform === 'darwin', + } + + beforeInput?.(event, input) + expect(event.preventDefault).toHaveBeenCalled() + expect(win.webContents.focus).toHaveBeenCalled() + expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'select') + + beforeInput?.(event, { ...input, key: 't' }) + expect(session.listTabs()).toHaveLength(2) + expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'clear') + + const second = session.activeTab() + expect(second).not.toBeNull() + const secondContents = (second?.view as unknown as MockView).webContents + const secondBeforeInput = secondContents.on.mock.calls.find( + ([eventName]) => eventName === 'before-input-event' + )?.[1] as + | ((event: { preventDefault: () => void }, input: Record) => void) + | undefined + secondBeforeInput?.(event, { ...input, key: 'w' }) + expect(session.listTabs()).toHaveLength(1) + expect(firstContents.focus).toHaveBeenCalled() + + beforeInput?.(event, { ...input, key: 'w' }) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).not.toBe(first.id) + expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'clear') + }) + + it('opens the renderer find bar when the page takes Mod+F', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.requireTab() + const contents = (tab.view as unknown as MockView).webContents + const beforeInput = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-input-event' + )?.[1] as + | ((event: { preventDefault: () => void }, input: Record) => void) + | undefined + const event = { preventDefault: vi.fn() } + + beforeInput?.(event, { + type: 'keyDown', + key: 'f', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: process.platform !== 'darwin', + alt: false, + meta: process.platform === 'darwin', + }) + + // The page never sees it — otherwise a site's own Mod+F wins over find. + expect(event.preventDefault).toHaveBeenCalled() + expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:open-find') + }) + + it('restarts the search while typing and steps without restarting on next/previous', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.requireTab() + const contents = (tab.view as unknown as MockView).webContents + + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + expect(contents.findInPage).toHaveBeenLastCalledWith('needle', { + forward: true, + findNext: false, + }) + + session.findInActiveTab({ query: 'needle', findNext: true, forward: false }) + expect(contents.findInPage).toHaveBeenLastCalledWith('needle', { + forward: false, + findNext: true, + }) + + // Clearing the box is a stop, not a search for the empty string — and the + // bar has to survive it, or deleting the last character closes the bar the + // user is still typing in. + vi.mocked(win.webContents.send).mockClear() + session.findInActiveTab({ query: '', findNext: false, forward: true }) + expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection') + expect(contents.findInPage).toHaveBeenCalledTimes(2) + expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find') + }) + + it('forwards match counts only for the tab the find is running on', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents + const secondContents = (second.view as unknown as MockView).webContents + const foundOn = (contents: MockView['webContents']) => + contents.on.mock.calls.find(([eventName]) => eventName === 'found-in-page')?.[1] as + | ((event: unknown, result: Record) => void) + | undefined + + session.switchTab(first.id) + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + foundOn(firstContents)?.({}, { activeMatchOrdinal: 2, matches: 7, finalUpdate: true }) + expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:find-result', { + activeMatchOrdinal: 2, + matches: 7, + final: true, + }) + + // A late result from a tab that is not being searched would relabel the bar + // with counts for a page the user is not looking at. + vi.mocked(win.webContents.send).mockClear() + foundOn(secondContents)?.({}, { activeMatchOrdinal: 1, matches: 3, finalUpdate: true }) + expect(win.webContents.send).not.toHaveBeenCalledWith( + 'browser-agent:find-result', + expect.anything() + ) + }) + + it('drops the find when its page navigates away, but not on a same-document change', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.requireTab() + const contents = (tab.view as unknown as MockView).webContents + const navigate = contents.on.mock.calls.find( + ([eventName]) => eventName === 'did-start-navigation' + )?.[1] as ((details: Record) => void) | undefined + + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + vi.mocked(win.webContents.send).mockClear() + + // A pushState route change keeps the document the matches live in. + navigate?.({ isMainFrame: true, isSameDocument: true }) + expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find') + // A subframe load likewise leaves the main document alone. + navigate?.({ isMainFrame: false, isSameDocument: false }) + expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find') + + navigate?.({ isMainFrame: true, isSameDocument: false }) + expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection') + expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find') + }) + + it('drops the find when the tab it is running on is closed', () => { + // Otherwise the searched tab id outlives the tab: the bar stays open + // counting matches on a page that no longer exists, and nothing clears it + // until the user happens to type a new query. + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + session.requireTab() + const second = session.addTab() + session.switchTab(second.id) + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + vi.mocked(win.webContents.send).mockClear() + + session.closeTab(second.id) + + expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find') + }) + + it('drops the find when the tab it is running on crashes', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + session.addTab() + session.switchTab(first.id) + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + vi.mocked(win.webContents.send).mockClear() + + const contents = (first.view as unknown as MockView).webContents + const gone = contents.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined + gone?.({}, { reason: 'crashed' }) + + expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find') + }) + + it('drops the find when the user switches to another tab', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents + + session.switchTab(first.id) + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + vi.mocked(win.webContents.send).mockClear() + + session.switchTab(second.id) + expect(firstContents.stopFindInPage).toHaveBeenCalledWith('clearSelection') + expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find') + }) + + it('returns focus to the page only when the user dismissed the bar', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.requireTab() + const contents = (tab.view as unknown as MockView).webContents + + // Panel teardown: the bar unmounts under a user who has already moved on, + // so pulling focus back into the browser would drag them back to it. + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + contents.focus.mockClear() + session.stopFindInActiveTab(false) + expect(contents.focus).not.toHaveBeenCalled() + + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + contents.focus.mockClear() + session.stopFindInActiveTab(true) + expect(contents.focus).toHaveBeenCalled() + }) + + it('returns focus to the page even when no search was running', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.requireTab() + const contents = (tab.view as unknown as MockView).webContents + + // Opened and closed without typing. Focus still has to leave the bar: it is + // unmounting, and cannot receive the Mod+F that reopens it. + contents.focus.mockClear() + session.stopFindInActiveTab(true) + expect(contents.focus).toHaveBeenCalled() + + // Same once the box is emptied — clearing the query ends the search, so + // dismissing afterwards has no searched tab to key focus off either. + session.findInActiveTab({ query: 'needle', findNext: false, forward: true }) + session.findInActiveTab({ query: '', findNext: false, forward: true }) + contents.focus.mockClear() + session.stopFindInActiveTab(true) + expect(contents.focus).toHaveBeenCalled() + }) + + it('closes only the native browser tab targeted by the application menu accelerator', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents + const secondContents = (second.view as unknown as MockView).webContents + const focusListener = secondContents.on.mock.calls.find( + ([eventName]) => eventName === 'focus' + )?.[1] as (() => void) | undefined + const blurListener = secondContents.on.mock.calls.find( + ([eventName]) => eventName === 'blur' + )?.[1] as (() => void) | undefined + + // Menu accelerators can shift Electron's live focus flag before their + // click callback runs. The captured owner must survive that synchronous + // blur and remain routable for the current event-loop turn. + focusListener?.() + blurListener?.() + + expect(session.closeFocusedTab()).toBe(true) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).toBe(first.id) + expect(firstContents.focus).toHaveBeenCalledOnce() + + // Focus ownership transfers with the close, so a repeated Mod+W closes + // the newly active tab even if Electron has not emitted its focus event. + expect(session.closeFocusedTab()).toBe(true) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).not.toBe(first.id) + + // The replacement is an untouched about:blank tab. It still owns the + // browser context, so it must not require a page load or another click. + const blankTabId = session.listTabs()[0].tabId + expect(session.closeFocusedTab()).toBe(true) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).not.toBe(blankTabId) + + session.setPanelFocused(false) + expect(session.closeFocusedTab()).toBe(false) + expect(session.listTabs()).toHaveLength(1) + }) + + it('treats renderer browser chrome as browser focus', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const first = session.requireTab() + const second = session.addTab() + + session.setPanelFocused(true) + expect(session.closeFocusedTab()).toBe(true) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).toBe(first.id) + expect(session.listTabs()[0].tabId).not.toBe(second.id) + + session.setPanelFocused(false) + expect(session.closeFocusedTab()).toBe(false) + }) + + it('retains browser focus while a renderer overlay temporarily occludes the page', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + session.requireTab() + session.setPanelFocused(true) + + // Tooltips and browser chrome overlays hide the native surface briefly; + // visual occlusion is not a focus change. + panel.setPanelOccluded(true) + expect(session.closeFocusedTab()).toBe(true) + }) + + it('unthrottles only the active tab while automation is active', () => { + const active = session.ensureTab() + const activeContents = (active.view as unknown as MockView).webContents + const background = session.addTab() + const backgroundContents = (background.view as unknown as MockView).webContents + // addTab activated the second tab; put focus back on the first. + session.switchTab(active.id) + activeContents.setBackgroundThrottling.mockClear() + backgroundContents.setBackgroundThrottling.mockClear() + + session.setAutomationActive(true) + // The waking is scoped to the active tab; the background tab stays throttled. + expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false) + expect(backgroundContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true) + + session.setAutomationActive(false) + expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true) + }) + + it('moves the automation exemption to whichever tab becomes active', () => { + const first = session.ensureTab() + const second = session.addTab() + session.switchTab(first.id) + const firstContents = (first.view as unknown as MockView).webContents + const secondContents = (second.view as unknown as MockView).webContents + + session.setAutomationActive(true) + firstContents.setBackgroundThrottling.mockClear() + secondContents.setBackgroundThrottling.mockClear() + + session.switchTab(second.id) + + // The old active tab is re-throttled, the new one exempted — otherwise a + // mid-tool switch would strand the wake on a tab the agent left behind. + expect(firstContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true) + expect(secondContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false) + }) + + it('updates the native backdrop when Sim changes browser theme', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + + session.setBrowserTheme('dark') + expect(session.getBrowserTheme()).toBe('dark') + expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#0c0c0c') + + session.setBrowserTheme('light') + expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#ffffff') + }) + + it('propagates theme changes to every existing tab', async () => { + const onTabThemeChanged = vi.fn() + const themedSession = freshSession(win, { onTabThemeChanged }) + const first = themedSession.ensureTab() + const second = themedSession.addTab() + + themedSession.setBrowserTheme('dark') + + expect(onTabThemeChanged.mock.calls).toEqual([ + [first.view.webContents, 'dark'], + [second.view.webContents, 'dark'], + ]) + }) + + it('requireTab refuses when no page is open yet', () => { + expect(() => session.requireTab()).toThrow(/No page is open yet/) + }) + + it('opens, switches, and closes tabs with stable ids', () => { + const first = session.ensureTab() + const second = session.addTab() + expect(second.id).not.toBe(first.id) + expect(session.activeTab()?.id).toBe(second.id) + + const switched = session.switchTab(first.id) + expect(switched.id).toBe(first.id) + expect(session.activeTab()?.id).toBe(first.id) + + session.closeTab(first.id) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([second.id]) + expect(session.activeTab()?.id).toBe(second.id) + + expect(() => session.switchTab('999')).toThrow(/No tab with id 999/) + expect(() => session.closeTab('999')).toThrow(/No tab with id 999/) + }) + + it('selects the neighboring tab when the active tab closes', () => { + const first = session.ensureTab() + const second = session.addTab() + const third = session.addTab() + + session.switchTab(second.id) + session.closeTab(second.id) + expect(session.activeTab()?.id).toBe(third.id) + + session.closeTab(third.id) + expect(session.activeTab()?.id).toBe(first.id) + }) + + it('reopens the latest closed tab while the browser owns focus', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + session.ensureTab() + const closed = session.addTab() + session.setPanelFocused(true) + session.closeTab(closed.id) + + expect(session.reopenFocusedTab()).toBe(true) + const reopened = session.activeTab() + expect(reopened?.id).not.toBe(closed.id) + const contents = (reopened?.view as unknown as MockView | undefined)?.webContents + expect(contents?.loadURL).toHaveBeenCalledWith('https://example.com/') + expect(contents?.focus).toHaveBeenCalled() + + session.setPanelFocused(false) + expect(session.reopenFocusedTab()).toBe(false) + }) + + it('keeps stale reports from another app window from hiding or controlling the browser panel', () => { + const otherWindow = mainWindowMock() + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }, win) + session.ensureTab() + vi.mocked(win.contentView.removeChildView).mockClear() + + panel.setPanelBounds(null, otherWindow) + expect(win.contentView.removeChildView).not.toHaveBeenCalled() + + session.setPanelFocused(true, win) + expect(session.closeFocusedTab(otherWindow)).toBe(false) + expect(session.closeFocusedTab(win)).toBe(true) + }) + + it('reorders tabs while preserving the pinned-tab boundary', () => { + const first = session.ensureTab() + const second = session.addTab() + const third = session.addTab() + + session.reorderTab(third.id, 0) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([third.id, first.id, second.id]) + + session.setTabPinned(first.id, true) + session.reorderTab(second.id, 0) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id]) + + session.reorderTab(first.id, 2) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id]) + expect(() => session.reorderTab('999', 0)).toThrow(/No tab with id 999/) + }) + + it('moves pinned tabs left and requires unpinning before any close path', async () => { + const save = vi.fn() + const pinnedSession = freshSession( + win, + {}, + { + load: () => [], + save, + } + ) + const first = pinnedSession.ensureTab() + const second = pinnedSession.addTab() + + pinnedSession.setTabPinned(second.id, true) + + expect(pinnedSession.listTabs()).toEqual([ + expect.objectContaining({ tabId: second.id, pinned: true }), + expect.objectContaining({ tabId: first.id, pinned: false }), + ]) + expect(save).toHaveBeenLastCalledWith(['https://example.com/']) + expect(() => pinnedSession.closeTab(second.id)).toThrow(/Pinned tabs cannot be closed/) + + pinnedSession.setTabPinned(second.id, false) + pinnedSession.closeTab(second.id) + expect(pinnedSession.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) + expect(save).toHaveBeenLastCalledWith([]) + }) + + it('restores pinned tabs when the browser resource opens again', async () => { + const restoredSession = freshSession( + win, + {}, + { + load: () => ['https://docs.sim.ai/guide'], + save: vi.fn(), + } + ) + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + + const [restored] = restoredSession.listTabs() + expect(restored).toMatchObject({ pinned: true, active: true }) + const contents = (restoredSession.requireTab().view as unknown as MockView).webContents + expect(contents.loadURL).toHaveBeenCalledWith('https://docs.sim.ai/guide') + expect(() => restoredSession.closeTab(restored.tabId)).toThrow(/Pinned tabs cannot be closed/) + + const regular = restoredSession.addTab() + expect(restoredSession.listTabs()).toEqual([ + expect.objectContaining({ tabId: restored.tabId, pinned: true }), + expect.objectContaining({ tabId: regular.id, pinned: false, active: true }), + ]) + }) + + it('limits the browser session to the shared tab cap', () => { + session.ensureTab() + for (let index = 1; index < MAX_BROWSER_TABS; index++) { + session.addTab() + } + + expect(session.listTabs()).toHaveLength(MAX_BROWSER_TABS) + expect(() => session.addTab()).toThrow( + `The browser supports up to ${MAX_BROWSER_TABS} open tabs.` + ) + }) + + it('embeds the active view in the MAIN window only while panel bounds are reported', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const content = (win as unknown as { contentView: { addChildView: ReturnType } }) + .contentView + + // No bounds yet: the view is not attached to the window. + expect(content.addChildView).not.toHaveBeenCalledWith(tab.view) + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect(content.addChildView).toHaveBeenCalledWith(tab.view) + expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 800, height: 600 }) + + // Panel hidden: the view stops painting but stays attached. Detaching + // would give up its compositor surface, and rebuilding that on the way + // back is the blank repaint that reads as the page having reloaded — + // which is every switch to another resource and back. + const removeChildView = ( + win as unknown as { contentView: { removeChildView: ReturnType } } + ).contentView.removeChildView + view.setVisible.mockClear() + panel.setPanelBounds(null) + expect(view.setVisible).toHaveBeenCalledWith(false) + expect(removeChildView).not.toHaveBeenCalled() + + // Showing it again reuses the attached view rather than re-adding it. + content.addChildView.mockClear() + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect(view.setVisible).toHaveBeenLastCalledWith(true) + expect(content.addChildView).not.toHaveBeenCalled() + }) + + it('detaches the previous view when another tab becomes active', () => { + const first = session.ensureTab() + panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 }) + const content = ( + win as unknown as { + contentView: { + addChildView: ReturnType + removeChildView: ReturnType + } + } + ).contentView + content.addChildView.mockClear() + content.removeChildView.mockClear() + + const second = session.addTab() + + // Hiding keeps a view attached, but a tab switch still has to detach: + // two native views stacked in the window would composite over each other. + expect(content.removeChildView).toHaveBeenCalledWith(first.view) + expect(content.addChildView).toHaveBeenCalledWith(second.view) + }) + + // The measured report is the sole writer of bounds. A main-process + // prediction on the window's own `resize` used to race it: it assumed a + // constant panel width, which only holds after a divider drag pins one, so + // with the default half-width panel it applied a rect that disagreed with + // the measurement by half the window's travel — twice per frame, because + // the two writers shared a dedup key and kept invalidating each other. + it('applies renderer-measured bounds once and invents no rect when the window grows', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const mock = win as unknown as { + on: ReturnType + getContentSize: ReturnType + } + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 800, height: 600 }) + + // The window's resize is a layout trigger, never a source of bounds. On a + // grow the clamp is inert, so the rect is unchanged and nothing is applied + // until the renderer measures — this is what keeps the reverted prediction + // from creeping back in. + const onResize = hostResizeHandler(win) + + view.setBounds.mockClear() + mock.getContentSize.mockReturnValue([1380, 950]) + onResize() + expect(view.setBounds).not.toHaveBeenCalled() + + // A repeated identical report stays idempotent. + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect(view.setBounds).not.toHaveBeenCalled() + + // The next measured rect is applied exactly once. + panel.setPanelBounds({ x: 300, y: 50, width: 900, height: 700 }) + expect(view.setBounds).toHaveBeenCalledTimes(1) + expect(view.setBounds).toHaveBeenCalledWith({ x: 300, y: 50, width: 900, height: 700 }) + }) + + // A shrink outruns the renderer's measurement by a frame; without the clamp + // the stale rect is applied verbatim and the view overhangs the new frame. + it('confines the view to the content box when the window shrinks', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const mock = win as unknown as { + on: ReturnType + getContentSize: ReturnType + } + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const onResize = hostResizeHandler(win) + + view.setBounds.mockClear() + mock.getContentSize.mockReturnValue([600, 400]) + onResize() + + expect(view.setBounds).toHaveBeenCalledTimes(1) + expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 500, height: 350 }) + + // Re-clamping the same stale rect is idempotent. + onResize() + expect(view.setBounds).toHaveBeenCalledTimes(1) + }) + + // The measured rect is a frame stale mid-drag, and for a half-width panel a + // window change of D moves the panel's left edge by D/2 — which the clamp + // cannot correct because it only truncates. The declared anchor is what moves + // x, closing the gap between the divider and the view's left edge. + it('re-derives the rect from the declared anchor while the window resizes', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const mock = win as unknown as { + on: ReturnType + getContentSize: ReturnType + } + + // Half-width panel, right-flush, measured at a 1000x800 viewport. + mock.getContentSize.mockReturnValue([1000, 800]) + panel.setPanelBounds({ x: 500, y: 40, width: 500, height: 760 }, undefined, { + viewportWidth: 1000, + viewportHeight: 800, + widthRatio: 0.5, + }) + expect(view.setBounds).toHaveBeenLastCalledWith({ x: 500, y: 40, width: 500, height: 760 }) + + const onResize = hostResizeHandler(win) + + // Window grows to 1200 wide: half-width means x moves to 600, not 500. + view.setBounds.mockClear() + mock.getContentSize.mockReturnValue([1200, 800]) + onResize() + expect(view.setBounds).toHaveBeenCalledWith({ x: 600, y: 40, width: 600, height: 760 }) + + // Shrinking below the measured size derives it just as well, with no help + // from the clamp (600 wide → x 300, width 300, both inside the frame). + view.setBounds.mockClear() + mock.getContentSize.mockReturnValue([600, 800]) + onResize() + expect(view.setBounds).toHaveBeenCalledWith({ x: 300, y: 40, width: 300, height: 760 }) + }) + + it('prefers the measured rect over the anchor at the measured viewport', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const mock = win as unknown as { getContentSize: ReturnType } + + // An anchor that disagrees with the measurement must not win while the + // viewport still matches: measurement is authoritative, so a wrong anchor + // can only ever affect the frames of a live resize. + mock.getContentSize.mockReturnValue([1000, 800]) + panel.setPanelBounds({ x: 500, y: 40, width: 500, height: 760 }, undefined, { + viewportWidth: 1000, + viewportHeight: 800, + widthRatio: 0, + }) + + expect(view.setBounds).toHaveBeenLastCalledWith({ x: 500, y: 40, width: 500, height: 760 }) + }) + + it('drops the resize listener while the panel is hidden', () => { + session.ensureTab() + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const onResize = hostResizeHandler(win) + + panel.setPanelBounds(null) + + expect( + (win as unknown as { removeListener: ReturnType }).removeListener + ).toHaveBeenCalledWith('resize', onResize) + }) + + it('creates one real default tab when the browser panel becomes visible', () => { + expect(session.listTabs()).toHaveLength(0) + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + + expect(session.listTabs()).toHaveLength(1) + expect(session.getTabsState().activeTabId).toBe(session.listTabs()[0].tabId) + + const firstTabId = session.listTabs()[0].tabId + session.closeTab(firstTabId) + expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()[0].tabId).not.toBe(firstTabId) + }) + + it('clears a stale attachment without touching a destroyed host window', () => { + // Production replaces the main window through the provider closure + // (`() => getMainWindow()`), never by re-initialising the session — which + // is what keeps the live tab across the swap, and the tab surviving is the + // whole point of re-parenting it. Driving it the same way here. + let host: BrowserWindow = win + session = freshSession(() => host) + const tab = session.ensureTab() + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const staleContent = ( + win as unknown as { + contentView: { + removeChildView: ReturnType + } + } + ).contentView + staleContent.removeChildView.mockClear() + staleContent.removeChildView.mockImplementation(() => { + throw new Error('Object has been destroyed') + }) + vi.mocked(win.isDestroyed).mockReturnValue(true) + + const replacement = mainWindowMock() + host = replacement + + expect(() => panel.setPanelBounds(null)).not.toThrow() + expect(staleContent.removeChildView).not.toHaveBeenCalled() + + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const replacementContent = ( + replacement as unknown as { + contentView: { + addChildView: ReturnType + } + } + ).contentView + expect(replacementContent.addChildView).toHaveBeenCalledWith(tab.view) + }) + + it('clears a stale attachment without touching a destroyed child view', () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const content = ( + win as unknown as { + contentView: { + removeChildView: ReturnType + } + } + ).contentView + content.removeChildView.mockClear() + view.webContents.isDestroyed.mockReturnValue(true) + + expect(() => panel.setPanelBounds(null)).not.toThrow() + expect(content.removeChildView).not.toHaveBeenCalled() + }) + + it('scales panel bounds by the main window zoom factor', () => { + const winZoomed = mainWindowMock() + ;( + winZoomed as unknown as { webContents: { getZoomFactor: ReturnType } } + ).webContents.getZoomFactor = vi.fn(() => 1.5) + // Roomy content box so the clamp stays inert and this covers zoom alone. + ;(winZoomed as unknown as { getContentSize: ReturnType }).getContentSize = vi.fn( + () => [2000, 1400] + ) + const zoomedSession = freshSession(winZoomed) + + const tab = zoomedSession.ensureTab() + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect((tab.view as unknown as MockView).setBounds).toHaveBeenCalledWith({ + x: 150, + y: 75, + width: 1200, + height: 900, + }) + }) + + it('keeps an occluded view attached, and hides it only once its frame is sent', async () => { + const tab = session.ensureTab() + const view = tab.view as unknown as MockView + const content = ( + win as unknown as { + contentView: { + addChildView: ReturnType + removeChildView: ReturnType + } + } + ).contentView + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + content.removeChildView.mockClear() + view.setVisible.mockClear() + + panel.setPanelOccluded(true) + + expect(content.removeChildView).not.toHaveBeenCalled() + // The page stays up until the frame that replaces it is sent: the renderer + // paints its snapshot the moment it reports occlusion, and hiding first + // leaves it showing the previous overlay's frame in the gap. + expect(view.setVisible).not.toHaveBeenCalledWith(false) + await vi.waitFor(() => { + expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:panel-snapshot', { + dataUrl: 'data:image/jpeg;base64,c2lt', + tabId: tab.id, + }) + }) + await vi.waitFor(() => expect(view.setVisible).toHaveBeenLastCalledWith(false)) + + panel.setPanelOccluded(false) + expect(view.setVisible).toHaveBeenLastCalledWith(true) + }) + + it('hardens every tab and keeps http popups inside a new internal tab', () => { + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + expect(contents.session.setPermissionRequestHandler).toHaveBeenCalled() + expect(contents.session.setPermissionCheckHandler).toHaveBeenCalled() + + const openHandler = contents.setWindowOpenHandler.mock.calls[0][0] as (details: { + url: string + }) => { action: string } + expect(openHandler({ url: 'https://example.com/popup' })).toEqual({ action: 'deny' }) + expect(session.listTabs()).toHaveLength(2) + const popupContents = (session.activeTab()?.view as unknown as MockView | undefined) + ?.webContents + expect(popupContents?.loadURL).toHaveBeenCalledWith('https://example.com/popup') + expect(contents.loadURL).not.toHaveBeenCalledWith('https://example.com/popup') + // Non-http(s) popups are denied without navigating anywhere. + contents.loadURL.mockClear() + expect(openHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(contents.loadURL).not.toHaveBeenCalled() + }) + + it('permission handlers deny every request on the agent partition', () => { + const tab = session.ensureTab() + const ses = (tab.view as unknown as MockView).webContents.session + const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void + ) => void + const callback = vi.fn() + requestHandler(null, 'media', callback) + expect(callback).toHaveBeenCalledWith(false) + + const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean + expect(checkHandler()).toBe(false) + }) + + it('leaves nothing of the signed-out user behind in the browser profile', async () => { + const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) + vi.mocked(electronSession.fromPartition).mockReturnValue({ + clearStorageData, + clearCache, + } as unknown as ReturnType) + const save = vi.fn() + session = freshSession(win, {}, { load: () => [], save }) + + panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 }) + const survivor = (session.ensureTab().view as unknown as MockView).webContents + session.closeTab(session.addTab().id) + expect(session.reopenClosedTab()).not.toBeNull() + + await session.clearProfileStorage() + + expect(survivor.close).toHaveBeenCalled() + expect(session.listTabs()).toHaveLength(0) + // Reopen Closed Tab must not resurrect the previous account's browsing. + expect(session.reopenClosedTab()).toBeNull() + expect(save).toHaveBeenLastCalledWith([]) + expect(clearStorageData).toHaveBeenCalled() + expect(clearCache).toHaveBeenCalled() + }) + + it('does not rewrite the settings file when the pinned tabs have not changed', async () => { + const save = vi.fn() + session = freshSession(win, {}, { load: () => [], save }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + const onNavigate = contents.on.mock.calls.find( + ([eventName]) => eventName === 'did-navigate-in-page' + )?.[1] as () => void + save.mockClear() + + // Any single-page app fires this on every route change, and the settings + // store's `===` comparison never matches a freshly built array — so each + // one used to mean a synchronous whole-file write on the main thread. + onNavigate() + onNavigate() + onNavigate() + + expect(save).not.toHaveBeenCalled() + }) + + it('persists once when a tab actually becomes pinned', async () => { + const save = vi.fn() + session = freshSession(win, {}, { load: () => [], save }) + const tab = session.ensureTab() + ;(tab.view as unknown as MockView).webContents.getURL.mockReturnValue('https://example.com/') + save.mockClear() + + session.setTabPinned(tab.id, true) + + expect(save).toHaveBeenCalledTimes(1) + expect(save).toHaveBeenLastCalledWith(['https://example.com/']) + }) + + it('drops a tab whose renderer crashed instead of wedging the session', () => { + const first = session.ensureTab() + const second = session.addTab() + const crashed = (second.view as unknown as MockView).webContents + const onGone = crashed.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as (event: unknown, details: { reason: string }) => void + + onGone({}, { reason: 'crashed' }) + + // Left in place, activeTab() filters the dead view out while activeTabId + // still names it, so requireTab() reports "no page is open" even though + // another tab is right there. + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) + expect(session.requireTab().id).toBe(first.id) + }) + + it('reports the session closed when the only tab crashes', async () => { + const onSessionClosed = vi.fn() + session = freshSession(win, { onSessionClosed }) + const contents = (session.ensureTab().view as unknown as MockView).webContents + const onGone = contents.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as (event: unknown, details: { reason: string }) => void + + onGone({}, { reason: 'oom' }) + + expect(session.listTabs()).toHaveLength(0) + expect(onSessionClosed).toHaveBeenCalled() + }) + + it('hides the panel when the renderer stops renewing its bounds lease', async () => { + vi.useFakeTimers() + try { + session = freshSession(win) + session.ensureTab() + panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 }, win) + const contentView = ( + win as unknown as { contentView: { removeChildView: ReturnType } } + ).contentView + contentView.removeChildView.mockClear() + const view = session.requireTab().view as unknown as MockView + view.setVisible.mockClear() + + // The renderer goes silent — crashed, unmounted, or wedged. Without the + // lease the native view keeps floating over whatever replaced the panel. + await vi.advanceTimersByTimeAsync(6_000) + + expect(view.setVisible).toHaveBeenCalledWith(false) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the panel while the renderer keeps renewing the lease', async () => { + vi.useFakeTimers() + try { + session = freshSession(win) + session.ensureTab() + const bounds = { x: 0, y: 0, width: 800, height: 600 } + panel.setPanelBounds(bounds, win) + const contentView = ( + win as unknown as { contentView: { removeChildView: ReturnType } } + ).contentView + contentView.removeChildView.mockClear() + + // The renderer heartbeats about once a second. + for (let beat = 0; beat < 6; beat++) { + await vi.advanceTimersByTimeAsync(1_000) + panel.setPanelBounds(bounds, win) + } + + expect(contentView.removeChildView).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('hardens every distinct session, not only the first one configured', () => { + // Guards against tracking this with one process-wide flag: the second + // session would then be left with no permission handlers, no SSRF request + // filtering, and no download blocking — silently, and still passing types. + const first = (session.ensureTab().view as unknown as MockView).webContents.session + const second = (session.addTab().view as unknown as MockView).webContents.session + expect(second).not.toBe(first) + + for (const ses of [first, second]) { + expect(ses.setPermissionRequestHandler).toHaveBeenCalled() + expect(ses.setPermissionCheckHandler).toHaveBeenCalled() + } + }) +}) + +/** + * The browser is one native surface shared by every app window, so exactly one + * window may drive it at a time. These cover who is allowed to take it. + */ +describe('browser panel ownership', () => { + const BOUNDS = { x: 0, y: 0, width: 800, height: 600 } + let win: BrowserWindow + let other: BrowserWindow + let session: SessionModule + + beforeEach(async () => { + win = mainWindowMock() + other = mainWindowMock() + session = freshSession(win) + }) + + it('lets any window claim a panel nobody owns yet', () => { + expect(panel.canReportPanelBounds(other, null)).toBe(true) + }) + + it('keeps the owner reporting while Sim sits in the background', () => { + panel.setPanelBounds(BOUNDS, win) + + // Nothing is focused, but the owner has not changed. + expect(panel.canReportPanelBounds(win, null)).toBe(true) + }) + + it('refuses a second window claiming the panel while nothing is focused', () => { + panel.setPanelBounds(BOUNDS, win) + + // Both windows heartbeat their bounds every second. Allowing an unfocused + // claim makes them alternate ownership, re-parenting the native view + // between windows roughly once a second for as long as Sim is unfocused. + expect(panel.canReportPanelBounds(other, null)).toBe(false) + }) + + it('transfers ownership to the window the user focused', () => { + panel.setPanelBounds(BOUNDS, win) + + expect(panel.canReportPanelBounds(other, other)).toBe(true) + }) + + it('frees the panel once the owning window is gone', () => { + panel.setPanelBounds(BOUNDS, win) + vi.mocked(win.isDestroyed).mockReturnValue(true) + + expect(panel.canReportPanelBounds(other, null)).toBe(true) + }) + + it('releases the panel when the owning window closes', () => { + panel.setPanelBounds(BOUNDS, win) + const view = session.ensureTab().view as unknown as MockView + view.setVisible.mockClear() + + // Electron destroys the window before emitting `closed`, so the release + // arrives from an already-destroyed window and must still be honoured. + vi.mocked(win.isDestroyed).mockReturnValue(true) + panel.setPanelBounds(null, win) + + expect(panel.canReportPanelBounds(other, null)).toBe(true) + // Left owned, the next layout would re-parent the browser onto another + // window at the closed window's bounds. + expect(view.setVisible).not.toHaveBeenCalledWith(true) + }) + + it('ignores a live non-owner trying to hide the panel', () => { + panel.setPanelBounds(BOUNDS, win) + + panel.setPanelBounds(null, other) + + expect(panel.canReportPanelBounds(other, null)).toBe(false) + }) + + it('ignores panel updates from a window that does not own the panel', () => { + panel.setPanelBounds(BOUNDS, win) + const view = session.ensureTab().view as unknown as MockView + view.webContents.capturePage.mockClear() + + panel.setPanelOccluded(true, other) + + expect(view.webContents.capturePage).not.toHaveBeenCalled() + }) + + it('accepts panel updates from a live window once the owner is destroyed', () => { + panel.setPanelBounds(BOUNDS, win) + const view = session.ensureTab().view as unknown as MockView + vi.mocked(win.isDestroyed).mockReturnValue(true) + view.webContents.capturePage.mockClear() + + panel.setPanelOccluded(true, other) + + // A stale owner must not keep rejecting the window actually on screen. + expect(view.webContents.capturePage).toHaveBeenCalled() + }) + + it('withholds a captured frame from a window that lost ownership mid-capture', async () => { + panel.setPanelBounds(BOUNDS, win) + session.ensureTab() + const send = vi.mocked(win.webContents.send) + send.mockClear() + + panel.setPanelOccluded(true, win) + panel.setPanelBounds(BOUNDS, other) + await sleep(0) + + // The frame is a picture of the page; the window no longer showing the + // browser has no business receiving it. + expect( + send.mock.calls.filter(([channel]) => channel === 'browser-agent:panel-snapshot') + ).toEqual([]) + }) + + it('delivers a captured frame to an owner that kept the panel', async () => { + panel.setPanelBounds(BOUNDS, win) + session.ensureTab() + const send = vi.mocked(win.webContents.send) + send.mockClear() + + panel.setPanelOccluded(true, win) + await sleep(0) + + expect( + send.mock.calls.filter(([channel]) => channel === 'browser-agent:panel-snapshot').length + ).toBe(1) + }) +}) + +describe('reopening a closed tab', () => { + let win: BrowserWindow + let session: SessionModule + + beforeEach(async () => { + win = mainWindowMock() + session = freshSession(win) + }) + + it('restores an ordinary closed tab', () => { + session.ensureTab() + const closing = session.addTab() + ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue( + 'https://example.com/inbox' + ) + session.closeTab(closing.id) + + const reopened = session.reopenClosedTab() + + expect((reopened?.view as unknown as MockView).webContents.loadURL).toHaveBeenCalledWith( + 'https://example.com/inbox' + ) + }) + + it('never revives a URL carrying embedded credentials', () => { + session.ensureTab() + const closing = session.addTab() + ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue( + 'https://user:secret@example.com/inbox' + ) + session.closeTab(closing.id) + + const reopened = session.reopenClosedTab() + + // Falls back to a blank tab rather than re-sending the credentials. + expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled() + }) + + it('duplicates a tab by loading the same URL in a new one', () => { + session.ensureTab() + const source = session.addTab() + ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue( + 'https://example.com/inbox' + ) + + const copy = session.duplicateTab(source.id) + + expect(copy?.id).not.toBe(source.id) + expect((copy?.view as unknown as MockView).webContents.loadURL).toHaveBeenCalledWith( + 'https://example.com/inbox' + ) + }) + + it('never copies a URL carrying embedded credentials into a duplicate', () => { + session.ensureTab() + const source = session.addTab() + ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue( + 'https://user:pass@example.com/' + ) + + const copy = session.duplicateTab(source.id) + + // Falls back to a blank tab rather than re-sending the credentials. + expect((copy?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled() + }) + + it('returns null when duplicating a tab that is not open', () => { + session.ensureTab() + expect(session.duplicateTab('no-such-tab')).toBeNull() + }) + + it('drops a non-http scheme from the reopen list', () => { + session.ensureTab() + const closing = session.addTab() + ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue('file:///etc/passwd') + session.closeTab(closing.id) + + const reopened = session.reopenClosedTab() + + expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled() + }) +}) + +describe('importAgentCookies', () => { + /** Points the mocked partition at a cookie jar and returns its `set` spy. */ + function withCookieJar(set: ReturnType): SessionModule { + // The partition is resolved per call, not captured at module load, so + // re-mocking it here is enough — no module reload required. + vi.mocked(electronSession.fromPartition).mockReturnValue({ + cookies: { set }, + } as unknown as ReturnType) + return sessionModule + } + + const cookie = (name: string) => ({ + url: 'https://example.com/', + name, + value: 'v', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' as const, + }) + + it('writes every cookie into the dedicated browser profile', async () => { + const set = vi.fn(async () => {}) + const session = withCookieJar(set) + + const result = await session.importAgentCookies([cookie('a'), cookie('b')]) + + expect(result).toEqual({ imported: 2, failed: 0 }) + expect(electronSession.fromPartition).toHaveBeenCalledWith('persist:sim-browser-agent') + expect(set).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenNthCalledWith(1, cookie('a')) + }) + + it('counts a rejected cookie without losing the rest', async () => { + // Chromium refuses cookies whose attributes are inconsistent. That + // rejection must cost one cookie, not the whole import. + const set = vi.fn(async (details: { name: string }) => { + if (details.name === 'bad') throw new Error('Failed to set cookie') + }) + const session = withCookieJar(set) + + const result = await session.importAgentCookies([cookie('a'), cookie('bad'), cookie('c')]) + + expect(result).toEqual({ imported: 2, failed: 1 }) + expect(set).toHaveBeenCalledTimes(3) + }) + + it('does nothing when there is nothing to import', async () => { + const set = vi.fn(async () => {}) + const session = withCookieJar(set) + + await expect(session.importAgentCookies([])).resolves.toEqual({ imported: 0, failed: 0 }) + expect(set).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts new file mode 100644 index 0000000000..1fd2f50b5b --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -0,0 +1,1121 @@ +import { join } from 'node:path' +import { + type BrowserDataKind, + type BrowserFindRequest, + type BrowserFindResult, + type BrowserOmniboxFocusMode, + type BrowserTabState, + type BrowserTabsState, + type BrowserTheme, + MAX_BROWSER_TABS, +} from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { BrowserWindow, CookiesSetDetails, Input, Session, WebContents } from 'electron' +import { session as electronSession, nativeTheme, WebContentsView } from 'electron' +import { attachAgentContextMenu, BASE_ZOOM_FACTOR } from '@/main/browser-agent/context-menu' +import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions' +import { + detachAttachedView, + detachIfAttached, + initPanel, + isPanelVisible, + layout, + panelUpdateAllowed, + panelWindow, +} from '@/main/browser-agent/panel' +import { registerAgentWebContents } from '@/main/browser-agent/registry' +import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard' + +const logger = createLogger('BrowserAgentSession') + +/** Dedicated cookie jar for the agent browser; `persist:` = survives restarts. */ +const AGENT_PARTITION = 'persist:sim-browser-agent' + +class SessionError extends Error {} + +export interface AgentTab { + id: string + view: WebContentsView + pinned: boolean +} + +export interface PinnedTabPersistence { + load: () => unknown + save: (urls: string[]) => void +} + +export interface AgentSessionEvents { + /** The browser session ended (all tabs gone). */ + onSessionClosed: () => void + /** A newly created tab's WebContents, for the driver to instrument. */ + onTabCreated: (contents: WebContents) => void + /** + * A tab navigated, including in-page. Anything bound to the previous + * document — notably a pending credential fill — must be invalidated. + */ + onTabNavigated: (contents: WebContents) => void + /** A tab's WebContents is going away, so per-tab state can be dropped. */ + onTabClosed: (contents: WebContents) => void + /** The active tab changed (new tab, switch, close). */ + onActiveTabChanged: (contents: WebContents) => void + /** The tab list or active tab changed. */ + onTabsChanged: () => void + /** Sim's appearance preference changed for an existing tab. */ + onTabThemeChanged: (contents: WebContents, theme: BrowserTheme) => void + /** A download was blocked on the agent partition. */ + onDownloadBlocked: (filename: string, url: string) => void +} + +/** + * Bounds reports are a LEASE, not a one-shot: the renderer re-reports the + * panel rect continuously while the panel is visible, and the view is hidden + * when the lease expires. This is the liveness guard — a renderer that + * reloads, crashes, or hard-navigates never gets to send "hide", so the view + * must never outlive the reports. + */ +const MAX_RECENTLY_CLOSED_TABS = 10 + +export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find' + +type BrowserShortcutInput = Pick< + Input, + 'type' | 'key' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta' +> + +/** + * Resolves browser-level shortcuts using Command on macOS and Control + * elsewhere. Modified/composing/repeated keystrokes stay with the page. + */ +export function browserShortcutForInput( + input: BrowserShortcutInput, + platform: NodeJS.Platform = process.platform +): BrowserShortcut | null { + if ( + input.type !== 'keyDown' || + input.isAutoRepeat || + input.isComposing || + input.shift || + input.alt + ) { + return null + } + const primaryModifier = platform === 'darwin' ? input.meta : input.control + if (!primaryModifier) return null + + switch (input.key.toLowerCase()) { + case 'l': + return 'focus-omnibox' + case 't': + return 'new-tab' + case 'w': + return 'close-tab' + case 'f': + return 'find' + default: + return null + } +} + +const tabs: AgentTab[] = [] +const recentlyClosedTabUrls: string[] = [] +let activeTabId: string | null = null +let nextTabId = 1 +/** + * Per-session rather than a single boolean: a process-wide flag would make the + * SECOND partition ever configured silently skip every hardening step below — + * a failure that type-checks and passes tests. + */ +const configuredPartitions = new WeakSet() +let events: AgentSessionEvents | null = null +let getMainWindow: () => BrowserWindow | null = () => null +let pinnedTabPersistence: PinnedTabPersistence | null = null +let pinnedTabsRestored = false +/** Serialized form of the last saved pinned-tab list, for change detection. */ +let lastPersistedPinnedTabs: string | null = null +/** Browser-resource focus, including native pages and renderer-owned chrome. */ +let focusedBrowserTabId: string | null = null +let focusedBrowserClearTimer: ReturnType | null = null +/** Raw Sim preference; `system` remains dynamic as the OS theme changes. */ +let browserTheme: BrowserTheme = 'system' +/** Prevent hidden-page throttling only while an agent action needs the page to make progress. */ +let automationActive = false + +/** + * Returns the module to the state it had before any session ran. + * + * {@link initSession} names itself as the session boundary but set three of + * these fields and left the rest, so a second call would inherit the first + * session's tab id counter, theme, pinned-restore latch and persisted-list + * digest — the last of which would then suppress the new session's first save + * as an unchanged write. Nothing re-inits in production today, which is + * exactly why the gap stayed invisible, and why the tests had to reset the + * whole MODULE (`vi.resetModules()`, which the root CLAUDE.md forbids) just to + * get a clean one. + */ +function resetSessionState(): void { + // Tears down live views and clears tabs, the reopen list, the active tab, + // the find, and the focused-tab timer. Notifies the OUTGOING handlers, which + // is why it runs before the new ones are installed. + closeLiveTabs() + nextTabId = 1 + pinnedTabsRestored = false + lastPersistedPinnedTabs = null + pinnedTabPersistence = null + browserTheme = 'system' + automationActive = false +} + +export function initSession( + handlers: AgentSessionEvents, + mainWindowProvider: () => BrowserWindow | null, + persistence?: PinnedTabPersistence +): void { + resetSessionState() + events = handlers + getMainWindow = mainWindowProvider + if (persistence) { + pinnedTabPersistence = persistence + } + initPanel({ + getMainWindow: () => getMainWindow(), + activeTab, + ensureInitialTab: () => { + restorePinnedTabs() + if (!hasSession()) { + ensureTab() + } + }, + onViewDetached: (view) => { + clearFocusedBrowserTab(tabs.find((tab) => tab.view === view)?.id) + }, + }) +} + +/** + * Accepts only what is safe to navigate back to later: http(s), no embedded + * credentials, bounded length. Shared by the pinned-tab list and the + * closed-tab list, both of which outlive the tab they came from and so must + * not be able to revive a `user:pass@host` URL. + */ +function sanitizeRestorableUrl(candidate: unknown): string | null { + if (typeof candidate !== 'string' || candidate.length > 8_192) return null + if (candidate === 'about:blank') return candidate + try { + const url = new URL(candidate) + if ((url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password) { + return url.href + } + } catch {} + return null +} + +function sanitizePinnedTabUrls(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const urls: string[] = [] + for (const candidate of value) { + const url = sanitizeRestorableUrl(candidate) + if (url !== null) urls.push(url) + if (urls.length >= MAX_BROWSER_TABS) break + } + return urls +} + +function pinnedUrl(tab: AgentTab): string { + return tab.view.webContents.getURL() || 'about:blank' +} + +/** + * Writes the pinned-tab list only when it actually changed. + * + * This runs on `did-navigate` and `did-navigate-in-page` for every tab, so any + * single-page app fires it on each route change. The settings store compares + * with `===`, so a freshly built array never matches and every call would + * otherwise mean a synchronous mkdir + write + rename of the whole settings + * file on the main thread — including writing `[]` over `[]` when nothing is + * pinned at all. + */ +function persistPinnedTabs(): void { + if (!pinnedTabPersistence || !pinnedTabsRestored) return + const urls = tabs + .filter((tab) => tab.pinned && !tab.view.webContents.isDestroyed()) + .map((tab) => pinnedUrl(tab)) + const fingerprint = JSON.stringify(urls) + if (fingerprint === lastPersistedPinnedTabs) return + lastPersistedPinnedTabs = fingerprint + pinnedTabPersistence.save(urls) +} + +/** Read cookie metadata from the dedicated profile without exposing values. */ +export async function listAgentCookieSignals(): Promise { + const cookies = await electronSession.fromPartition(AGENT_PARTITION).cookies.get({}) + return cookies.flatMap(({ domain }) => (typeof domain === 'string' ? [{ domain }] : [])) +} + +/** + * Writes imported cookies into the dedicated profile. + * + * Electron's cookie API is deliberately the only writer: Chromium owns the + * destination store's format, and editing that SQLite file directly would + * couple Sim to internals it does not control and risk corrupting the profile. + * It is also the enforcement point — Chromium rejects a cookie whose + * attributes are inconsistent (`SameSite=None` without `Secure`, a domain the + * URL cannot set), so a row that would only import under weaker terms fails + * here and is counted rather than being quietly relaxed. + * + * Failures are per-cookie: one rejected cookie must not cost the user the + * rest. Nothing about a cookie is logged. + */ +export async function importAgentCookies( + cookies: CookiesSetDetails[] +): Promise<{ imported: number; failed: number }> { + const jar = electronSession.fromPartition(AGENT_PARTITION).cookies + let imported = 0 + let failed = 0 + for (const cookie of cookies) { + try { + await jar.set(cookie) + imported += 1 + } catch { + failed += 1 + } + } + return { imported, failed } +} + +/** + * Default-deny hardening for the agent partition: no permission grants of any + * kind, and downloads are cancelled (and surfaced to the driver) rather than + * silently dropped on disk. + */ +function configureAgentPartition(ses: Session): void { + if (configuredPartitions.has(ses)) return + configuredPartitions.add(ses) + ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) + ses.setPermissionCheckHandler(() => false) + // SSRF choke point for the agent partition. Document navigations (top-level + + // iframes) get the full DNS-resolving check — the one seam every navigation + // passes through, including page-initiated ones the driver never sees (server + // redirects, link clicks, location.href, meta-refresh) — so an internal host + // can't slip in that way. Subresources take the cheap synchronous literal-IP + // backstop instead of a DNS lookup per asset. + ses.webRequest.onBeforeRequest((details, callback) => { + if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') { + void checkAgentUrl(details.url) + .then((guard) => { + if (!guard.ok) { + logger.warn('Blocked agent document navigation to a private host') + } + callback({ cancel: !guard.ok }) + }) + .catch((error) => { + // Fail closed: an unexpected rejection must cancel, never leave the + // request suspended with no callback. + logger.error('Agent SSRF check failed; cancelling request', { error }) + callback({ cancel: true }) + }) + return + } + callback({ cancel: isBlockedRequestUrl(details.url) }) + }) + ses.on('will-download', (_event, item) => { + const filename = item.getFilename() + const url = item.getURL() + logger.info('Blocked download in agent browser', { filename }) + item.cancel() + events?.onDownloadBlocked(filename, url) + }) +} + +function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void { + const win = panelWindow() + if (!win || win.isDestroyed()) return + win.webContents.focus() + win.webContents.send('browser-agent:focus-omnibox', mode) +} + +/** + * Opens the renderer's find bar and moves keyboard focus to it. The bar is + * renderer chrome rather than an overlay on the page: a renderer element that + * overlapped the native view would trip the occlusion path and hide the very + * page being searched. + */ +function openRendererFind(): void { + const win = panelWindow() + if (!win || win.isDestroyed()) return + win.webContents.focus() + win.webContents.send('browser-agent:open-find') +} + +/** + * Tab a find is currently running on. Tracked because the find outlives the + * call that started it — Chromium keeps the highlights until it is told to + * stop, so leaving a tab (or navigating it) has to clear the find explicitly + * or the old matches stay lit under a match count that no longer describes + * anything on screen. + */ +let findingTabId: string | null = null + +/** + * Drops a tab's highlights and stops treating it as the tab being searched. + * Leaves the renderer's bar alone — emptying the find box and searching a + * different tab both end a find while the user is still typing in the bar. + */ +function stopFindOnTab(tabId: string | null): void { + if (tabId === null) return + const tab = tabs.find((entry) => entry.id === tabId) + if (tab && !tab.view.webContents.isDestroyed()) { + tab.view.webContents.stopFindInPage('clearSelection') + } + if (findingTabId === tabId) findingTabId = null +} + +/** + * Stops the find and dismisses the renderer's bar, for when the page it was + * run against is gone — a navigation or a tab switch. Chrome dismisses find on + * navigation too, and a count for the previous document is worse than no bar. + */ +function dismissFind(tabId: string | null): void { + if (tabId === null) return + const wasFinding = findingTabId === tabId + stopFindOnTab(tabId) + if (!wasFinding) return + const win = panelWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('browser-agent:close-find') + } +} + +/** + * Runs Chromium's own find against the active tab. An empty query stops the + * find rather than searching for nothing, matching what emptying Chrome's find + * box does — the bar stays open and ready for the next query. + */ +export function findInActiveTab(request: BrowserFindRequest): void { + const tab = activeTab() + if (!tab) return + if (request.query === '') { + stopFindOnTab(tab.id) + return + } + // A find started on another tab has to go before this one begins, or its + // highlights survive on a page the user can no longer see them on. + if (findingTabId !== null && findingTabId !== tab.id) stopFindOnTab(findingTabId) + findingTabId = tab.id + tab.view.webContents.findInPage(request.query, { + forward: request.forward, + findNext: request.findNext, + }) +} + +/** + * Stops the running find. + * + * `focusPage` distinguishes the user dismissing the bar — where focus is being + * pulled out from under them and Chrome leaves it on the page — from the bar + * merely unmounting because the browser panel went away. Only the renderer can + * tell those apart: the panel's own teardown reports bounds after its + * children's cleanups run, so by the time this is reached the panel still + * looks visible either way, and focusing the page on teardown would drag the + * user back to a browser they just navigated away from. + */ +export function stopFindInActiveTab(focusPage: boolean): void { + stopFindOnTab(findingTabId) + if (!focusPage) return + // Deliberately the ACTIVE tab, not whichever tab was being searched: there is + // often no search running at all (the bar was opened and closed without a + // query, or the box was emptied first, both of which clear the searched tab). + // Keying focus off the search left those cases with focus on the input that + // just unmounted, which lands on — from there the page cannot receive + // the next Mod+F for the shell to intercept, and the renderer's own handler + // is scoped to the panel, so find became unopenable until something else was + // clicked. + const tab = activeTab() + if (tab) tab.view.webContents.focus() +} + +/** + * Opens a link from a page in another tab of this browser. Shared by the + * window.open interception and the page's right-click menu — both have to stay + * inside the browser resource rather than spawn a native window, and both are + * reached from an untrusted page, so the scheme is checked here once. + */ +function openTabWithUrl(url: string): void { + if (!/^https?:\/\//i.test(url)) return + try { + const tab = addTab() + void tab.view.webContents.loadURL(url).catch(() => {}) + } catch (error) { + logger.warn('Could not open a link in a new browser tab', { + error: getErrorMessage(error), + }) + } +} + +function createTabView(): WebContentsView { + const view = new WebContentsView({ + webPreferences: { + partition: AGENT_PARTITION, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + // A minimal, isolated preload that reports login-form presence and + // performs user-authorized credential fills. It exposes nothing to the + // page, and runs in the top-level frame only. + preload: join(__dirname, 'browser-preload.cjs'), + // Throttled by default: a hidden tab should idle. The one exception is + // the active tab while a tool waits on it, applied explicitly by + // applyActiveTabThrottling — never blanket across every tab. + backgroundThrottling: true, + spellcheck: false, + // The default every origin this tab visits starts at; a per-origin zoom + // the user sets from the page menu still wins and still persists. + zoomFactor: BASE_ZOOM_FACTOR, + }, + }) + view.setBackgroundColor(browserBackgroundColor()) + const contents = view.webContents + registerAgentWebContents(contents) + configureAgentPartition(contents.session) + attachAgentContextMenu(contents, { openTab: openTabWithUrl }) + + contents.on('focus', () => { + if (focusedBrowserClearTimer !== null) { + clearTimeout(focusedBrowserClearTimer) + focusedBrowserClearTimer = null + } + const tab = tabs.find((entry) => entry.view.webContents === contents) + focusedBrowserTabId = tab?.id ?? activeTabId + }) + contents.on('blur', () => { + const tab = tabs.find((entry) => entry.view.webContents === contents) + if (!tab || focusedBrowserTabId !== tab.id) return + if (focusedBrowserClearTimer !== null) clearTimeout(focusedBrowserClearTimer) + // Electron can emit blur while resolving an application-menu accelerator. + // Defer the clear for one event-loop turn so the synchronous menu callback + // can still identify which native tab owned the keystroke. + focusedBrowserClearTimer = setTimeout(() => { + focusedBrowserClearTimer = null + if (focusedBrowserTabId === tab.id && !contents.isFocused()) { + focusedBrowserTabId = null + } + }, 0) + }) + + // Keep popups inside the browser resource: http(s) window.open and + // target=_blank requests become a new internal tab, never a native window. + contents.setWindowOpenHandler((details) => { + openTabWithUrl(details.url) + return { action: 'deny' } + }) + + // Pages may hold navigation hostage with beforeunload dialogs nobody can + // see; always let the unload proceed. + contents.on('will-prevent-unload', (event) => { + event.preventDefault() + }) + // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()` + // filters it out and returns null while `activeTabId` still names it, so + // `requireTab()` reports "no page is open" even with other tabs open, and + // the panel goes blank with no way back. + contents.on('render-process-gone', (_event, details) => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab) return + logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason }) + forgetTab(tab) + }) + contents.on('before-input-event', (event, input) => { + const shortcut = browserShortcutForInput(input) + if (!shortcut) return + + event.preventDefault() + if (shortcut === 'focus-omnibox') { + focusRendererOmnibox('select') + return + } + if (shortcut === 'find') { + openRendererFind() + return + } + if (shortcut === 'new-tab') { + if (listTabs().length < MAX_BROWSER_TABS) { + addTab() + focusRendererOmnibox('clear') + } + return + } + + const tab = tabs.find((entry) => entry.view === view) + if (tab) closeTabFromUser(tab.id) + }) + contents.on('found-in-page', (_event, result) => { + const tab = tabs.find((entry) => entry.view === view) + // Counts from a tab the user has already left would relabel the bar for + // whatever page is on screen now. + if (!tab || tab.id !== findingTabId) return + const win = panelWindow() + if (!win || win.isDestroyed()) return + const payload: BrowserFindResult = { + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + final: result.finalUpdate, + } + win.webContents.send('browser-agent:find-result', payload) + }) + // A document load replaces what the find was pointing at. Same-document + // route changes do not, and Chromium keeps the highlights across them, so + // only real navigations dismiss the bar. + contents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) return + const tab = tabs.find((entry) => entry.view === view) + if (tab) dismissFind(tab.id) + }) + // A pinned tab persists its latest top-level location, including + // user-driven navigations that do not pass through the driver. + contents.on('did-navigate', persistPinnedTabs) + contents.on('did-navigate-in-page', persistPinnedTabs) + // Both document loads and same-document route changes invalidate anything + // bound to the previous page: a single-page app can replace a login form + // with another site's UI without ever loading a new document. + contents.on('did-start-navigation', () => events?.onTabNavigated(contents)) + contents.on('did-navigate', () => events?.onTabNavigated(contents)) + contents.on('did-navigate-in-page', () => events?.onTabNavigated(contents)) + contents.on('destroyed', () => events?.onTabClosed(contents)) + + events?.onTabCreated(contents) + return view +} + +/** True while any tab exists. */ +export function hasSession(): boolean { + return tabs.some((tab) => !tab.view.webContents.isDestroyed()) +} + +/** + * Keeps the ACTIVE tab responsive during an agent action, then returns it to + * normal background throttling. + * + * Only the active tab, deliberately. The agent drives one tab at a time — the + * active one — possibly while the panel is hidden and even that view is + * detached, so it is the only tab that must not be throttled mid-tool. Waking + * every tab, as this once did, meant an agent run kept all N-1 background + * renderers at full speed for the length of the run, which is the browser + * side of the multi-tab lag. Nothing depends on a background tab staying + * awake: switching to one activates it (and re-applies this) before any tool + * touches it, and network loading is not throttled anyway. + */ +export function setAutomationActive(active: boolean): void { + automationActive = active + applyActiveTabThrottling() +} + +/** + * Unthrottles the active tab while automation is active, and throttles every + * other tab. Call after anything that changes which tab is active, so the + * exemption follows the active tab rather than being stranded on the old one. + */ +function applyActiveTabThrottling(): void { + for (const tab of tabs) { + if (tab.view.webContents.isDestroyed()) continue + const exempt = automationActive && tab.id === activeTabId + tab.view.webContents.setBackgroundThrottling(!exempt) + } +} + +function browserBackgroundColor(): string { + const dark = + browserTheme === 'dark' || (browserTheme === 'system' && nativeTheme.shouldUseDarkColors) + return dark ? '#0c0c0c' : '#ffffff' +} + +function updateTabBackgrounds(): void { + const color = browserBackgroundColor() + for (const tab of tabs) { + if (!tab.view.webContents.isDestroyed()) { + tab.view.setBackgroundColor(color) + } + } +} + +/** + * Applies Sim's raw appearance preference to every current and future tab. + * Page media-query emulation stays in the CDP layer; this module owns the + * native view backdrop used before and between page paints. + */ +export function setBrowserTheme(theme: BrowserTheme): void { + if (browserTheme === theme) return + browserTheme = theme + updateTabBackgrounds() + for (const tab of tabs) { + if (!tab.view.webContents.isDestroyed()) { + events?.onTabThemeChanged(tab.view.webContents, theme) + } + } +} + +export function getBrowserTheme(): BrowserTheme { + return browserTheme +} + +nativeTheme.on('updated', () => { + if (browserTheme === 'system') { + updateTabBackgrounds() + } +}) + +/** The active tab, creating the first tab when none exist. */ +export function ensureTab(): AgentTab { + restorePinnedTabs() + let active = activeTab() + if (!active) { + active = addTabInternal() + } + return active +} + +/** The active tab without creating one. */ +export function requireTab(): AgentTab { + restorePinnedTabs() + const active = activeTab() + if (!active) { + throw new SessionError('No page is open yet — call browser_navigate or browser_open_tab first.') + } + return active +} + +interface AddTabOptions { + pinned?: boolean + activate?: boolean + notify?: boolean +} + +function addTabInternal({ + pinned = false, + activate = true, + notify = true, +}: AddTabOptions = {}): AgentTab { + if (tabs.filter((tab) => !tab.view.webContents.isDestroyed()).length >= MAX_BROWSER_TABS) { + throw new SessionError(`The browser supports up to ${MAX_BROWSER_TABS} open tabs.`) + } + const transferBrowserFocus = + activate && + (focusedBrowserTabId !== null || tabs.some((tab) => tab.view.webContents.isFocused())) + const tab: AgentTab = { id: String(nextTabId++), view: createTabView(), pinned } + if (pinned) { + const firstRegularTab = tabs.findIndex((entry) => !entry.pinned) + tabs.splice(firstRegularTab < 0 ? tabs.length : firstRegularTab, 0, tab) + } else { + tabs.push(tab) + } + if (activate || activeTabId === null) { + activeTabId = tab.id + applyActiveTabThrottling() + layout() + if (transferBrowserFocus) focusedBrowserTabId = tab.id + if (notify) events?.onActiveTabChanged(tab.view.webContents) + } + if (notify) events?.onTabsChanged() + return tab +} + +function restorePinnedTabs(): void { + if (pinnedTabsRestored) return + pinnedTabsRestored = true + const urls = sanitizePinnedTabUrls(pinnedTabPersistence?.load()) + // Seed the change detector from what is already on disk, so the first + // navigation after launch does not rewrite an identical list. + lastPersistedPinnedTabs = JSON.stringify(urls) + for (const url of urls) { + const tab = addTabInternal({ pinned: true, activate: false, notify: false }) + if (url !== 'about:blank') { + void tab.view.webContents.loadURL(url).catch(() => {}) + } + } + const active = activeTab() + if (active) { + layout() + events?.onActiveTabChanged(active.view.webContents) + events?.onTabsChanged() + } +} + +export function addTab(): AgentTab { + restorePinnedTabs() + return addTabInternal() +} + +/** Restores the most recently closed regular tab for the current app session. */ +export function reopenClosedTab(): AgentTab | null { + restorePinnedTabs() + if (listTabs().length >= MAX_BROWSER_TABS) return null + const url = recentlyClosedTabUrls.shift() + if (!url) return null + + const tab = addTabInternal() + if (url !== 'about:blank') { + // No checkAgentUrl here, unlike the tool-driven navigations: the stored + // URL was already sanitized to http(s) on close, and the partition's + // onBeforeRequest still runs the full DNS-resolving SSRF check on the + // document load. Pre-checking would only buy a nicer error, and there is + // no model to report one to — this path is a user keystroke. + void tab.view.webContents.loadURL(url).catch(() => {}) + } + return tab +} + +/** + * Opens a copy of a tab at the same URL. A duplicate is a fresh load rather + * than a clone of the original's session history: the history belongs to the + * WebContents, and there is no way to fork it. + */ +export function duplicateTab(tabId: string): AgentTab | null { + restorePinnedTabs() + const source = tabs.find((entry) => entry.id === tabId) + if (!source || listTabs().length >= MAX_BROWSER_TABS) return null + + const url = sanitizeRestorableUrl(source.view.webContents.getURL()) + const tab = addTabInternal() + if (url && url !== 'about:blank') { + // Sanitized to http(s) without embedded credentials above, and the + // partition's onBeforeRequest still runs the full SSRF check on the load — + // same reasoning as reopenClosedTab, and this is likewise a user action. + void tab.view.webContents.loadURL(url).catch(() => {}) + } + return tab +} + +export function switchTab(tabId: string): AgentTab { + restorePinnedTabs() + const tab = tabs.find((entry) => entry.id === tabId) + if (!tab) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) + // The find belongs to the page it was typed against, not to the browser. + if (findingTabId !== null && findingTabId !== tab.id) dismissFind(findingTabId) + const transferBrowserFocus = + focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused()) + activeTabId = tab.id + // The automation exemption follows the active tab, so a mid-tool switch + // unthrottles the new one and re-throttles the old. + applyActiveTabThrottling() + layout() + if (transferBrowserFocus) focusedBrowserTabId = tab.id + events?.onActiveTabChanged(tab.view.webContents) + events?.onTabsChanged() + return tab +} + +/** + * Moves a tab to a final list index while preserving the pinned/regular + * boundary. Dragging across that boundary moves to its nearest valid edge. + */ +export function reorderTab(tabId: string, targetIndex: number): AgentTab { + restorePinnedTabs() + if (!Number.isFinite(targetIndex)) { + throw new SessionError('Browser tab target index must be a finite number.') + } + const currentIndex = tabs.findIndex((entry) => entry.id === tabId) + if (currentIndex < 0) { + throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) + } + const tab = tabs[currentIndex] + const pinnedCount = tabs.filter((entry) => entry.pinned).length + const minIndex = tab.pinned ? 0 : pinnedCount + const maxIndex = tab.pinned ? pinnedCount - 1 : tabs.length - 1 + const nextIndex = Math.max(minIndex, Math.min(maxIndex, Math.trunc(targetIndex))) + if (nextIndex === currentIndex) return tab + + tabs.splice(currentIndex, 1) + tabs.splice(nextIndex, 0, tab) + if (tab.pinned) persistPinnedTabs() + events?.onTabsChanged() + return tab +} + +/** + * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this + * takes no view down (there is nothing left to close), applies to pinned tabs + * too — a crashed pinned tab is no more usable than any other — and does not + * offer the page for Reopen Closed Tab, since the user did not close it. + */ +function forgetTab(tab: AgentTab): void { + const index = tabs.indexOf(tab) + if (index < 0) return + // Before the splice, while the tab is still resolvable: a find left running + // on a tab that is going away keeps `findingTabId` naming a dead tab and + // leaves the bar open counting matches on a page nobody can see. + dismissFind(tab.id) + tabs.splice(index, 1) + const transferBrowserFocus = focusedBrowserTabId === tab.id + clearFocusedBrowserTab(tab.id) + detachIfAttached(tab.view) + if (tab.pinned) persistPinnedTabs() + if (activeTabId === tab.id) { + activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null + layout() + const active = activeTab() + if (active) { + events?.onActiveTabChanged(active.view.webContents) + } + } + if (!hasSession() && isPanelVisible()) { + addTab() + if (transferBrowserFocus) focusedBrowserTabId = activeTabId + return + } + if (transferBrowserFocus) focusedBrowserTabId = activeTabId + events?.onTabsChanged() + if (!hasSession()) { + events?.onSessionClosed() + } +} + +export function closeTab(tabId: string): void { + restorePinnedTabs() + const index = tabs.findIndex((entry) => entry.id === tabId) + if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) + if (tabs[index].pinned) { + throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.') + } + // Before the splice, while the tab is still resolvable — see forgetTab. + dismissFind(tabId) + const [tab] = tabs.splice(index, 1) + recentlyClosedTabUrls.unshift( + sanitizeRestorableUrl(tab.view.webContents.getURL()) ?? 'about:blank' + ) + if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) { + recentlyClosedTabUrls.length = MAX_RECENTLY_CLOSED_TABS + } + const transferBrowserFocus = focusedBrowserTabId === tab.id || tab.view.webContents.isFocused() + clearFocusedBrowserTab(tab.id) + detachIfAttached(tab.view) + tab.view.webContents.close() + if (activeTabId === tab.id) { + activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null + layout() + const active = activeTab() + if (active) { + events?.onActiveTabChanged(active.view.webContents) + } + } + // Closing the last tab must not leave a visible browser resource with an + // empty strip. Replace it with a fresh New tab, matching normal browser UI. + if (!hasSession() && isPanelVisible()) { + addTab() + if (transferBrowserFocus) focusedBrowserTabId = activeTabId + return + } + if (transferBrowserFocus) focusedBrowserTabId = activeTabId + events?.onTabsChanged() + if (!hasSession()) { + events?.onSessionClosed() + } +} + +/** + * Pins or unpins a live tab. Pinned tabs form a stable group at the far left, + * and their latest URLs are persisted locally for the next browser opening. + */ +export function setTabPinned(tabId: string, pinned: boolean): AgentTab { + restorePinnedTabs() + const index = tabs.findIndex((entry) => entry.id === tabId) + if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) + const tab = tabs[index] + if (tab.pinned === pinned) return tab + + tabs.splice(index, 1) + tab.pinned = pinned + if (pinned) { + const firstRegularTab = tabs.findIndex((entry) => !entry.pinned) + tabs.splice(firstRegularTab < 0 ? tabs.length : firstRegularTab, 0, tab) + } else { + tabs.push(tab) + } + persistPinnedTabs() + events?.onTabsChanged() + return tab +} + +/** + * Closes the active tab when the browser resource currently owns the user's + * interaction context. Application menu accelerators run before a + * WebContentsView's `before-input-event`, so Mod+W must route through this + * function instead of Electron's global close role. Returns false when focus + * belongs to the rest of the app. + */ +export function closeFocusedTab(ownerWindow?: BrowserWindow | null): boolean { + if (!panelUpdateAllowed(ownerWindow ?? undefined)) return false + const focusedTab = tabs.find( + (tab) => + !tab.view.webContents.isDestroyed() && + (tab.id === focusedBrowserTabId || tab.view.webContents.isFocused()) + ) + if (!focusedTab) return false + closeTabFromUser(focusedTab.id) + return true +} + +/** Reopens the latest closed tab only while the browser owns interaction focus. */ +export function reopenFocusedTab(ownerWindow?: BrowserWindow | null): boolean { + if (!panelUpdateAllowed(ownerWindow ?? undefined)) return false + const browserFocused = tabs.some( + (tab) => + !tab.view.webContents.isDestroyed() && + (tab.id === focusedBrowserTabId || tab.view.webContents.isFocused()) + ) + if (!browserFocused) return false + + const reopened = reopenClosedTab() + if (!reopened) return false + reopened.view.webContents.focus() + return true +} + +/** Marks renderer-owned browser chrome as focused or releases browser focus. */ +export function setPanelFocused(focused: boolean, ownerWindow?: BrowserWindow): void { + if (!panelUpdateAllowed(ownerWindow)) return + if (!focused) { + clearFocusedBrowserTab() + return + } + if (focusedBrowserClearTimer !== null) { + clearTimeout(focusedBrowserClearTimer) + focusedBrowserClearTimer = null + } + focusedBrowserTabId = activeTab()?.id ?? null +} + +function clearFocusedBrowserTab(tabId?: string): void { + if (tabId && focusedBrowserTabId !== tabId) return + if (focusedBrowserClearTimer !== null) { + clearTimeout(focusedBrowserClearTimer) + focusedBrowserClearTimer = null + } + focusedBrowserTabId = null +} + +function closeTabFromUser(tabId: string): void { + if (tabs.find((tab) => tab.id === tabId)?.pinned) return + const closingLastTab = listTabs().length === 1 + closeTab(tabId) + const active = activeTab() + if (closingLastTab || !active || !active.view.webContents.getURL()) { + focusRendererOmnibox('clear') + return + } + active.view.webContents.focus() +} + +/** Destroys every live view and forgets which one was active. */ +function closeLiveTabs(): void { + detachAttachedView() + dismissFind(findingTabId) + for (const tab of tabs.splice(0)) { + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.close() + } + } + recentlyClosedTabUrls.length = 0 + activeTabId = null + clearFocusedBrowserTab() +} + +/** + * Ends the live session without touching the profile or the pinned-tab list on + * disk, so the strip comes back intact next time. Turning the agent browser + * off in settings runs this; a sign-out wipe runs {@link clearProfileStorage}. + */ +export function closeSession(): void { + closeLiveTabs() + // Left unrestored so the next opening reads the pinned strip from disk + // rather than the emptied in-memory copy. Persistence is gated on the same + // flag, so nothing can save over that list in the meantime. + pinnedTabsRestored = false + events?.onTabsChanged() + events?.onSessionClosed() + layout() +} + +/** + * Wipes the embedded browser's profile: open tabs, the in-memory list behind + * Reopen Closed Tab, the persisted pinned tabs, and all site data and cache in + * the agent partition. Sim sign-out runs this so the next account signing in + * on this machine cannot inherit the previous user's authenticated sessions, + * pinned tabs, or browsing trail. + */ +export async function clearProfileStorage(): Promise { + closeLiveTabs() + // Stays true so a later restore cannot re-read the list being erased here. + pinnedTabsRestored = true + pinnedTabPersistence?.save([]) + lastPersistedPinnedTabs = '[]' + events?.onTabsChanged() + layout() + + const ses = electronSession.fromPartition(AGENT_PARTITION) + // No `storages` filter: a profile wipe should leave nothing behind, and an + // allowlist would silently miss whatever Chromium adds next. + await ses.clearStorageData() + await ses.clearCache() +} + +/** + * Site storage other than cookies. Named explicitly rather than by omission so + * a new Chromium storage type is not silently swept into "site data" — the + * whole-profile wipe is the one that deliberately takes everything. + */ +const SITE_DATA_STORAGES = [ + 'filesystem', + 'indexdb', + 'localstorage', + 'shadercache', + 'websql', + 'serviceworkers', + 'cachestorage', +] as const + +/** + * Erases selected kinds of browsing data without ending the session. + * + * Unlike {@link clearProfileStorage} this leaves tabs open and the pinned strip + * intact: the user asked to clear data, not to close their browser. Saved + * passwords live in a separate vault and are never touched here. + */ +export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise { + const ses = electronSession.fromPartition(AGENT_PARTITION) + const storages: string[] = [] + if (kinds.includes('cookies')) storages.push('cookies') + if (kinds.includes('site-data')) storages.push(...SITE_DATA_STORAGES) + + if (storages.length > 0) { + await ses.clearStorageData({ storages } as Parameters[0]) + } + if (kinds.includes('cache')) await ses.clearCache() +} + +export function listTabs(): BrowserTabState[] { + restorePinnedTabs() + return tabs + .filter((tab) => !tab.view.webContents.isDestroyed()) + .map((tab) => ({ + tabId: tab.id, + title: tab.view.webContents.getTitle(), + url: tab.view.webContents.getURL(), + loading: tab.view.webContents.isLoading(), + active: tab.id === activeTabId, + pinned: tab.pinned, + })) +} + +export function getTabsState(): BrowserTabsState { + return { + tabs: listTabs(), + activeTabId: activeTab()?.id ?? null, + } +} + +export function activeTab(): AgentTab | null { + const tab = tabs.find((entry) => entry.id === activeTabId) ?? null + if (!tab || tab.view.webContents.isDestroyed()) return null + return tab +} diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts new file mode 100644 index 0000000000..b43d45a7da --- /dev/null +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() })) + +vi.mock('node:dns/promises', () => ({ + default: { lookup: mockLookup }, +})) + +import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard' + +describe('checkAgentUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + }) + + it('rejects non-http(s) schemes without resolving', async () => { + const result = await checkAgentUrl('file:///etc/passwd') + expect(result.ok).toBe(false) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('rejects malformed URLs', async () => { + expect((await checkAgentUrl('not a url')).ok).toBe(false) + }) + + it('blocks private IP literals without resolving', async () => { + expect((await checkAgentUrl('http://169.254.169.254/latest/meta-data')).ok).toBe(false) + expect((await checkAgentUrl('http://10.0.0.5/')).ok).toBe(false) + expect((await checkAgentUrl('http://192.168.1.1/')).ok).toBe(false) + expect((await checkAgentUrl('http://[fd00::1]/')).ok).toBe(false) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('allows loopback, so a local dev server can be opened', async () => { + // The panel exists to browse from this machine, and the agent already has + // an unrestricted shell on it. The LAN and the metadata endpoint above are + // a different matter and stay blocked. + expect((await checkAgentUrl('http://127.0.0.1:3000/')).ok).toBe(true) + expect((await checkAgentUrl('http://[::1]:3000/')).ok).toBe(true) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('allows localhost, which resolves to loopback', async () => { + mockLookup.mockResolvedValue([ + { address: '::1', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ]) + expect((await checkAgentUrl('http://localhost:3000/app')).ok).toBe(true) + }) + + it('still blocks a host that resolves to the LAN alongside loopback', async () => { + mockLookup.mockResolvedValue([ + { address: '127.0.0.1', family: 4 }, + { address: '192.168.0.9', family: 4 }, + ]) + expect((await checkAgentUrl('http://sneaky.test/')).ok).toBe(false) + }) + + it('allows public IP literals without resolving', async () => { + expect((await checkAgentUrl('https://8.8.8.8/')).ok).toBe(true) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('allows hostnames that resolve to public addresses', async () => { + const result = await checkAgentUrl('https://example.com/page') + expect(result.ok).toBe(true) + expect(mockLookup).toHaveBeenCalledWith('example.com', { all: true, verbatim: true }) + }) + + it('blocks hostnames that resolve to a private address (DNS rebinding)', async () => { + mockLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) + expect((await checkAgentUrl('https://rebind.evil.test/')).ok).toBe(false) + }) + + it('blocks when any resolved address is private', async () => { + mockLookup.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '192.168.0.9', family: 4 }, + ]) + expect((await checkAgentUrl('https://mixed.test/')).ok).toBe(false) + }) + + it('fails closed when DNS resolution fails', async () => { + mockLookup.mockRejectedValue(new Error('ENOTFOUND')) + expect((await checkAgentUrl('https://nope.invalid/')).ok).toBe(false) + }) + + it('fails closed when the DNS lookup exceeds the deadline', async () => { + vi.useFakeTimers() + try { + mockLookup.mockReturnValue(new Promise(() => {})) // never resolves + const pending = checkAgentUrl('https://slow.test/') + await vi.advanceTimersByTimeAsync(5_000) + expect((await pending).ok).toBe(false) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('isBlockedRequestUrl', () => { + it('blocks literal private/reserved hosts', () => { + expect(isBlockedRequestUrl('http://169.254.169.254/latest/meta-data')).toBe(true) + expect(isBlockedRequestUrl('http://10.0.0.5/x')).toBe(true) + expect(isBlockedRequestUrl('https://[fd00::1]/')).toBe(true) + }) + + it('allows loopback subresources, so a local page can load its own assets', () => { + expect(isBlockedRequestUrl('http://127.0.0.1:8080/app.js')).toBe(false) + expect(isBlockedRequestUrl('http://[::1]:8080/app.css')).toBe(false) + }) + + it('allows public literals and hostnames (classified at nav time)', () => { + expect(isBlockedRequestUrl('https://8.8.8.8/')).toBe(false) + expect(isBlockedRequestUrl('https://example.com/x')).toBe(false) + }) + + it('does not throw on malformed input', () => { + expect(isBlockedRequestUrl('::::')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts new file mode 100644 index 0000000000..23f493405e --- /dev/null +++ b/apps/desktop/src/main/browser-agent/url-guard.ts @@ -0,0 +1,141 @@ +import dns from 'node:dns/promises' +import { createLogger } from '@sim/logger' +import { + isIpLiteral, + isLoopbackIp, + isPrivateIp, + isPrivateIpHost, + unwrapIpv6Brackets, +} from '@sim/security/ssrf' +import { getErrorMessage } from '@sim/utils/errors' +import { parseHttpUrl } from '@/main/navigation' + +const logger = createLogger('BrowserAgentUrlGuard') + +/** Hard deadline on the SSRF DNS lookup so a slow/hung resolver can't suspend + * the check — and the onBeforeRequest callback that awaits it — indefinitely. + * A timeout rejects, which fails closed (blocks) via the caller's catch. */ +const DNS_TIMEOUT_MS = 5_000 + +/** dns.lookup bounded by {@link DNS_TIMEOUT_MS}; the timer is always cleared so a + * won race never leaves a dangling rejection. */ +async function resolveHost(host: string) { + const lookup = dns.lookup(host, { all: true, verbatim: true }) + // If the timeout wins the race the lookup stays pending; swallow its eventual + // settlement so a late rejection can't surface as an unhandled rejection. + lookup.catch(() => {}) + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + lookup, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('DNS lookup timed out')), DNS_TIMEOUT_MS) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +export interface UrlGuardResult { + ok: boolean + error?: string +} + +const OK: UrlGuardResult = { ok: true } + +/** + * Whether an address is off limits to the embedded browser. + * + * Loopback is deliberately allowed: it is the user's own machine, and opening + * a dev server on localhost is one of the most ordinary things to do in this + * panel — the URL bar already assumes `http://` for it. Nothing is given away + * by it either, since the desktop app hands the same agent an unrestricted + * shell on that machine, so a blocked `http://localhost:3000` is one + * `curl http://localhost:3000` away regardless. + * + * Every other private range stays blocked. Those are a different matter: the + * LAN is other people's machines, and `169.254.169.254` is link-local rather + * than loopback, so the cloud-metadata endpoint this guard exists for is + * unaffected. + */ +function isBlockedAddress(ip: string): boolean { + return isPrivateIp(ip) && !isLoopbackIp(ip) +} +const BLOCKED: UrlGuardResult = { + ok: false, + error: 'That address points to a private or internal network and was blocked.', +} + +/** + * SSRF guard for agent-browser navigation. The embedded browser is a + * general-purpose surface driven by model/tool input, so a navigation to a + * loopback/RFC1918/link-local host (e.g. the `169.254.169.254` cloud-metadata + * endpoint) would let a page's contents be read back through the read/snapshot + * tools. This resolves the host the same way `apps/sim` does for outbound + * fetches and blocks any that land on a private/reserved address — except + * loopback, which is allowed (see {@link isBlockedAddress}). + * + * IP literals are classified directly; hostnames are DNS-resolved and every + * returned address is checked. Resolution failure fails CLOSED (blocks): we + * can't confirm the host is public, and Chromium resolves independently, so it + * could still reach a private address our lookup missed — matching + * `validateUrlWithDNS` in `apps/sim`. The residual DNS-rebinding TOCTOU window + * (our lookup vs Chromium's) is only fully closable with egress firewalling; + * {@link isBlockedRequestUrl} adds a synchronous per-request literal-IP backstop + * for redirects and subresources. + */ +export async function checkAgentUrl(rawUrl: string): Promise { + const url = parseHttpUrl(rawUrl) + if (!url) { + return { ok: false, error: 'URL must be absolute and start with http:// or https://' } + } + + const host = unwrapIpv6Brackets(url.hostname) + + // IP literal: classify directly, no DNS lookup needed. + if (isIpLiteral(host)) { + if (isBlockedAddress(host)) { + logger.warn('Blocked agent navigation to private IP literal', { host }) + return BLOCKED + } + return OK + } + + try { + const resolved = await resolveHost(host) + if (resolved.some(({ address }) => isBlockedAddress(address))) { + logger.warn('Blocked agent navigation resolving to private IP', { host }) + return BLOCKED + } + } catch (error) { + // Fail closed: an unresolved host can't be confirmed public, and Chromium + // resolves independently, so it could still reach a private address. + logger.warn('Agent navigation host did not resolve; blocking', { + host, + error: getErrorMessage(error), + }) + return { ok: false, error: 'That address could not be resolved.' } + } + + return OK +} + +/** + * Synchronous backstop for the agent partition's `onBeforeRequest`: blocks any + * request whose host is a **literal** private/reserved IP. This is cheap enough + * to run per-request and catches redirects and subresources that target the + * metadata endpoint or an internal IP directly, without the cost of a DNS + * lookup on every subresource. Hostnames pass here (they are classified at + * navigation time by {@link checkAgentUrl}). + */ +export function isBlockedRequestUrl(rawUrl: string): boolean { + try { + // isPrivateIpHost strips IPv6 brackets itself; unwrap again for the + // loopback carve-out, which takes a bare address. + const host = new URL(rawUrl).hostname + return isPrivateIpHost(host) && !isLoopbackIp(unwrapIpv6Brackets(host)) + } catch { + return false + } +} diff --git a/apps/desktop/src/main/browser-credentials/fill.test.ts b/apps/desktop/src/main/browser-credentials/fill.test.ts new file mode 100644 index 0000000000..d1822a8700 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/fill.test.ts @@ -0,0 +1,327 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { sleep } from '@sim/utils/helpers' +import type { BrowserWindow, WebContents } from 'electron' +import { Menu } from 'electron' +import { FillCoordinator } from '@/main/browser-credentials/fill' +import type { CredentialVault } from '@/main/browser-credentials/vault' + +const ORIGIN = 'https://example.com' +const WINDOW = {} as BrowserWindow + +function fakeContents(url = `${ORIGIN}/login`) { + return { + getURL: vi.fn(() => url), + isDestroyed: vi.fn(() => false), + send: vi.fn(), + } +} + +function fakeVault(overrides: Partial> = {}) { + return { + isAvailable: vi.fn(() => true), + listForOrigin: vi.fn(async () => [ + { + id: 'c1', + origin: ORIGIN, + username: 'ada', + createdAt: '', + updatedAt: '', + source: 'chrome' as const, + }, + ]), + readForFill: vi.fn(async () => ({ username: 'ada', password: 'hunter2' })), + ...overrides, + } +} + +type Contents = ReturnType + +function setup(contents: Contents = fakeContents(), vault = fakeVault()) { + const onAvailabilityChanged = vi.fn() + let active: Contents | null = contents + const coordinator = new FillCoordinator({ + vault: vault as unknown as CredentialVault, + getActiveContents: () => active as unknown as WebContents | null, + onAvailabilityChanged, + }) + return { + coordinator, + contents, + vault, + onAvailabilityChanged, + setActive: (next: Contents | null) => { + active = next + }, + } +} + +/** Reports a login form, opens the chooser, and returns its menu template. */ +async function openChooser(context: ReturnType) { + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 }) + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as + | Array<{ label: string; click: () => void }> + | undefined + return template ?? [] +} + +/** + * Menu clicks are fire-and-forget, so a test has to wait for the fill's + * promise chain to finish on its own. + * + * Several ticks rather than one: the chain awaits the vault and then + * revalidates, and a single macrotask was enough to make this flaky on a + * loaded machine — the assertion ran before the chain reached `send`. + */ +async function settle(): Promise { + for (let tick = 0; tick < 10; tick++) { + await sleep(0) + } +} + +beforeEach(() => { + vi.mocked(Menu.buildFromTemplate).mockClear() +}) + +describe('fill availability', () => { + it('is available once a login form has a saved match', async () => { + const context = setup() + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(true) + }) + + it.each([ + ['there is no login form', { hasLoginForm: false }], + ['the page origin cannot hold a credential', { origin: 'about:blank' }], + ])('is unavailable when %s', async (_label, report) => { + const context = setup() + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + ...report, + }) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(false) + }) + + it('is unavailable with no saved credential for the origin', async () => { + const context = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) })) + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(false) + }) + + it('is unavailable when secure storage is unavailable', async () => { + const context = setup(fakeContents(), fakeVault({ isAvailable: vi.fn(() => false) })) + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(false) + }) + + it('drops to unavailable as soon as the page navigates', async () => { + const context = setup() + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + + context.coordinator.noteNavigation(context.contents as unknown as WebContents) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(false) + }) + + it('forgets a closed tab', async () => { + const context = setup() + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + + context.coordinator.forget(context.contents as unknown as WebContents) + + await expect(context.coordinator.isFillAvailable()).resolves.toBe(false) + }) +}) + +describe('credential chooser', () => { + it('lists usernames without reading any password', async () => { + const context = setup() + const template = await openChooser(context) + + expect(template.map((item) => item.label)).toEqual(['ada']) + expect(context.vault.readForFill).not.toHaveBeenCalled() + }) + + it('refuses to open without a login form or a match', async () => { + const context = setup() + await expect(context.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false) + + const noMatches = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) })) + noMatches.coordinator.noteFormState(noMatches.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + await expect(noMatches.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false) + }) +}) + +describe('performing a fill', () => { + it('sends the credential to the page the user chose it for', async () => { + const context = setup() + const template = await openChooser(context) + + template[0].click() + await settle() + + expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:fill', { + origin: ORIGIN, + username: 'ada', + password: 'hunter2', + }) + }) + + it('fills the email step of a two-step sign-in without sending the password', async () => { + const context = setup() + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + hasPasswordField: false, + }) + await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 }) + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{ + click: () => void + }> + + template[0].click() + await settle() + + // The page has nowhere to put a password, so it does not get one. + expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:fill', { + origin: ORIGIN, + username: 'ada', + password: undefined, + }) + }) + + it('sends the password to a shell that never reported whether a field exists', async () => { + const context = setup() + // Older preloads omit the flag; assuming a password field keeps them working. + context.coordinator.noteFormState(context.contents as unknown as WebContents, { + origin: ORIGIN, + hasLoginForm: true, + }) + await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 }) + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{ + click: () => void + }> + + template[0].click() + await settle() + + expect(context.contents.send).toHaveBeenCalledWith( + 'browser-credentials:fill', + expect.objectContaining({ password: 'hunter2' }) + ) + }) + + it('refuses after the page navigated between choosing and clicking', async () => { + const context = setup() + const template = await openChooser(context) + + context.coordinator.noteNavigation(context.contents as unknown as WebContents) + template[0].click() + await settle() + + expect(context.vault.readForFill).not.toHaveBeenCalled() + expect(context.contents.send).not.toHaveBeenCalled() + }) + + it('refuses when the live document is no longer the origin that was reported', async () => { + // The preload's report is a claim. If the tab is actually somewhere else + // now, the password must not follow it. + const context = setup() + const template = await openChooser(context) + context.contents.getURL.mockReturnValue('https://evil.test/login') + + template[0].click() + await settle() + + expect(context.vault.readForFill).not.toHaveBeenCalled() + expect(context.contents.send).not.toHaveBeenCalled() + }) + + it('refuses when the user switched to another tab', async () => { + const context = setup() + const template = await openChooser(context) + context.setActive(fakeContents()) + + template[0].click() + await settle() + + expect(context.contents.send).not.toHaveBeenCalled() + }) + + it('refuses when the tab was destroyed', async () => { + const context = setup() + const template = await openChooser(context) + context.contents.isDestroyed.mockReturnValue(true) + + template[0].click() + await settle() + + expect(context.contents.send).not.toHaveBeenCalled() + }) + + it('refuses when the page navigates during the vault read', async () => { + // Reading the vault is asynchronous, so the document can change inside it. + // Revalidating only before the read would let the password land on the + // page that replaced the one the user was looking at. + let releaseRead: () => void = () => {} + const pending = new Promise((resolve) => { + releaseRead = resolve + }) + const vault = fakeVault({ + readForFill: vi.fn(async () => { + await pending + return { username: 'ada', password: 'hunter2' } + }), + }) + const context = setup(fakeContents(), vault) + const template = await openChooser(context) + + template[0].click() + await settle() + context.coordinator.noteNavigation(context.contents as unknown as WebContents) + releaseRead() + await settle() + + expect(context.vault.readForFill).toHaveBeenCalled() + expect(context.contents.send).not.toHaveBeenCalled() + }) + + it('refuses when the vault no longer holds the chosen credential', async () => { + const context = setup(fakeContents(), fakeVault({ readForFill: vi.fn(async () => null) })) + const template = await openChooser(context) + + template[0].click() + await settle() + + expect(context.contents.send).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/fill.ts b/apps/desktop/src/main/browser-credentials/fill.ts new file mode 100644 index 0000000000..ab91150475 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/fill.ts @@ -0,0 +1,197 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow, WebContents } from 'electron' +import { Menu } from 'electron' +import { normalizeOrigin } from '@/main/browser-credentials/origin' +import type { CredentialVault } from '@/main/browser-credentials/vault' + +const logger = createLogger('BrowserCredentialFill') + +/** + * Decides when a credential may be filled, and does the filling. + * + * The page can navigate at any point between "this page has a login form", + * "the user opened the chooser", and "the user picked an account" — and a fill + * aimed at the wrong document means a password handed to the wrong site. So + * every authorization is bound to a specific tab and a specific navigation + * generation, and that binding is revalidated immediately before plaintext + * leaves the vault, not just when the chooser opened. + * + * The chooser is a native menu rather than renderer chrome. That is a security + * property, not a styling choice: the selection happens in a surface the main + * process owns and the page (and the Sim renderer) cannot synthesize, which is + * the main-process-controlled confirmation the design calls for. It also means + * no credential id has to cross the preload bridge at all. + */ + +interface FormState { + origin: string + hasLoginForm: boolean + /** + * Whether the page currently has somewhere to put a password. + * + * False on the first step of an identifier-first sign-in, which asks for an + * email and only reveals the password field after it is submitted. Those + * steps are still worth filling — the username is what they want — so the + * password simply is not sent to a page that has nowhere to put it. + */ + hasPasswordField: boolean + /** Bumped on every navigation, so a stale authorization cannot be replayed. */ + generation: number +} + +export interface FillCoordinatorDeps { + vault: CredentialVault + /** The tab the user is actually looking at. */ + getActiveContents: () => WebContents | null + /** Push the fill affordance's visibility to the Sim renderer. */ + onAvailabilityChanged: (available: boolean) => void +} + +export interface FormStateReport { + origin: string + hasLoginForm: boolean + /** Absent from shells that predate identifier-first support; assumed true. */ + hasPasswordField?: boolean +} + +export class FillCoordinator { + private readonly states = new WeakMap() + private readonly generations = new WeakMap() + private lastAvailability = false + + constructor(private readonly deps: FillCoordinatorDeps) {} + + private generationFor(contents: WebContents): number { + return this.generations.get(contents) ?? 0 + } + + /** + * Records what the browser preload observed. The report is trusted only as + * far as it goes: it can claim a form exists, but the origin it names is + * checked against the live URL before any fill. + */ + noteFormState(contents: WebContents, report: FormStateReport): void { + const origin = normalizeOrigin(report.origin) + if (origin === null) { + this.states.delete(contents) + } else { + this.states.set(contents, { + origin, + hasLoginForm: report.hasLoginForm, + hasPasswordField: report.hasPasswordField ?? true, + generation: this.generationFor(contents), + }) + } + void this.refreshAvailability() + } + + /** + * Invalidates everything known about a tab's page. Called on every + * navigation, including in-page ones — a single-page app can swap a login + * form for a different site's UI without a document load. + */ + noteNavigation(contents: WebContents): void { + this.generations.set(contents, this.generationFor(contents) + 1) + this.states.delete(contents) + void this.refreshAvailability() + } + + forget(contents: WebContents): void { + this.states.delete(contents) + this.generations.delete(contents) + void this.refreshAvailability() + } + + /** Whether the active tab has a login form with at least one saved match. */ + async isFillAvailable(): Promise { + const contents = this.deps.getActiveContents() + if (!contents || contents.isDestroyed()) return false + const state = this.states.get(contents) + if (!state?.hasLoginForm) return false + if (!this.deps.vault.isAvailable()) return false + return (await this.deps.vault.listForOrigin(state.origin)).length > 0 + } + + async refreshAvailability(): Promise { + const available = await this.isFillAvailable() + if (available === this.lastAvailability) return + this.lastAvailability = available + this.deps.onAvailabilityChanged(available) + } + + /** + * Shows the native account chooser near a point in the window. + * + * Only usernames are listed; no password is read until the user picks one. + * The navigation generation is captured here and carried into the fill, so a + * page that moves while the menu is open invalidates the choice. + */ + async showChooser(window: BrowserWindow, anchor: { x: number; y: number }): Promise { + const contents = this.deps.getActiveContents() + if (!contents || contents.isDestroyed()) return false + const state = this.states.get(contents) + if (!state?.hasLoginForm) return false + + const matches = await this.deps.vault.listForOrigin(state.origin) + if (matches.length === 0) return false + + const authorizedGeneration = state.generation + const menu = Menu.buildFromTemplate( + matches.map((credential) => ({ + label: credential.username || '(no username)', + click: () => { + void this.fill(contents, credential.id, authorizedGeneration).catch(() => {}) + }, + })) + ) + menu.popup({ window, x: Math.round(anchor.x), y: Math.round(anchor.y) }) + return true + } + + /** + * Performs one authorized fill. + * + * Every precondition is checked again here rather than trusted from when the + * chooser opened, and once more after the vault read, because that read is + * asynchronous and the page can navigate inside it. + */ + private async fill( + contents: WebContents, + credentialId: string, + authorizedGeneration: number + ): Promise { + if (!this.isStillAuthorized(contents, authorizedGeneration)) return + const state = this.states.get(contents) + if (!state) return + + // The origin the preload reported must still be the document's real + // origin. This is the check that stops a fill following a page that + // navigated to another site. + if (normalizeOrigin(contents.getURL()) !== state.origin) return + + const credential = await this.deps.vault.readForFill(credentialId, state.origin) + if (credential === null) return + + if (!this.isStillAuthorized(contents, authorizedGeneration)) return + if (normalizeOrigin(contents.getURL()) !== state.origin) return + + contents.send('browser-credentials:fill', { + origin: state.origin, + username: credential.username, + // Withheld on an identifier-first step: the page has no password field, + // so sending it would put plaintext in a document that cannot use it. + password: state.hasPasswordField ? credential.password : undefined, + }) + // Counts and outcomes only — never the origin, username, or password. + logger.info('Filled a saved credential at the user\u2019s request') + } + + private isStillAuthorized(contents: WebContents, authorizedGeneration: number): boolean { + if (contents.isDestroyed()) return false + // A fill must land in the tab the user is looking at. Switching tabs + // between choosing and filling cancels it. + if (this.deps.getActiveContents() !== contents) return false + if (this.generationFor(contents) !== authorizedGeneration) return false + return this.states.get(contents)?.generation === authorizedGeneration + } +} diff --git a/apps/desktop/src/main/browser-credentials/index.ts b/apps/desktop/src/main/browser-credentials/index.ts new file mode 100644 index 0000000000..5b9994fbcb --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/index.ts @@ -0,0 +1,145 @@ +import { join } from 'node:path' +import type { + BrowserCredentialConflictPolicy, + BrowserCredentialMetadata, +} from '@sim/desktop-bridge' +import { app, clipboard } from 'electron' +import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-credentials/fill' +import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth' +import { CredentialVault } from '@/main/browser-credentials/vault' +import { clearSites } from '@/main/browser-sites' + +/** How long a copied password may sit on the clipboard before Sim clears it. */ +const CLIPBOARD_CLEAR_MS = 30_000 + +/** + * Composition root for saved passwords: one vault and one fill coordinator for + * the whole app. The IPC layer talks to this module and nothing deeper, so the + * vault instance is never handed to a renderer-facing surface. + */ + +let vaultInstance: CredentialVault | null = null +let coordinatorInstance: FillCoordinator | null = null + +export function credentialVault(): CredentialVault { + if (!vaultInstance) { + vaultInstance = new CredentialVault(join(app.getPath('userData'), 'browser-credentials.json')) + } + return vaultInstance +} + +/** Creates the coordinator once the browser session can report its active tab. */ +export function initFillCoordinator(deps: Omit): FillCoordinator { + coordinatorInstance = new FillCoordinator({ ...deps, vault: credentialVault() }) + return coordinatorInstance +} + +export function fillCoordinator(): FillCoordinator | null { + return coordinatorInstance +} + +export function credentialsAvailable(): boolean { + return credentialVault().isAvailable() +} + +export function listCredentials(): Promise { + return credentialVault().list() +} + +export async function forgetCredential(id: string): Promise { + const vault = credentialVault() + await vault.delete(id) + revokeSecretAuthorization(id) + // A forgotten credential can remove the last match for the open page. + await coordinatorInstance?.refreshAvailability() + return vault.list() +} + +export async function importCredentials( + candidates: Parameters[0], + policy: BrowserCredentialConflictPolicy +): ReturnType { + const outcome = await credentialVault().importCredentials(candidates, policy) + revokeSecretAuthorization() + await coordinatorInstance?.refreshAvailability() + return outcome +} + +/** + * Deletes every saved password at the user's request. + * + * Distinct from {@link clearCredentials}, which runs as part of sign-out + * teardown: this one is a deliberate action from the password manager, so it + * resolves the resulting list for the UI to render. + */ +export async function forgetAllCredentials(): Promise { + await clearCredentials() + return [] +} + +/** + * Reveals one saved password to the settings UI, after the user proves they + * are present. + * + * This is the single path by which password plaintext reaches the Sim + * renderer, and it exists only because a password manager the user cannot read + * is not a password manager. Everything else about it is deliberately narrow: + * one credential per call, a fresh proof of presence, and nothing logged. + */ +export async function revealCredential(id: string): Promise { + const authorized = await authorizeForSecret({ + credentialId: id, + reason: 'show a saved password', + action: 'Show password', + }) + if (!authorized) return null + return credentialVault().revealPassword(id) +} + +/** + * Copies one saved password to the clipboard without it passing through the + * renderer at all, then clears the clipboard again. + * + * The clipboard is only cleared if it still holds this password — overwriting + * whatever the user copied in the meantime would be its own small betrayal. + */ +export async function copyCredential(id: string): Promise { + const authorized = await authorizeForSecret({ + credentialId: id, + reason: 'copy a saved password', + action: 'Copy password', + }) + if (!authorized) return false + const password = await credentialVault().revealPassword(id) + if (password === null) return false + + clipboard.writeText(password) + setTimeout(() => { + if (clipboard.readText() === password) clipboard.clear() + }, CLIPBOARD_CLEAR_MS).unref?.() + return true +} + +/** + * Destroys every saved credential. Sim sign-out runs this so the next account + * on this machine cannot inherit the previous user's passwords. + */ +export async function clearCredentials(): Promise { + // Revoked first, and unconditionally. Both deletions below can reject on a + // file the OS will not let us unlink, and the sign-out caller only logs + // that — so a revoke sequenced after them would be skipped while sign-out + // still reported success, leaving the previous user's OS-auth grant live + // for whoever signs in next. Dropping the grant early only ever fails safe. + revokeSecretAuthorization() + try { + // Both attempted regardless of each other. Sequencing them in one `try` + // made the second conditional on the first, so a vault file the OS would + // not let us unlink also left the imported site directory — hostnames and + // visit counts from the user's other browser — behind after sign-out. + const outcomes = await Promise.allSettled([credentialVault().clear(), clearSites()]) + const failure = outcomes.find((outcome) => outcome.status === 'rejected') + if (failure) throw failure.reason + } finally { + await coordinatorInstance?.refreshAvailability() + } +} diff --git a/apps/desktop/src/main/browser-credentials/origin.test.ts b/apps/desktop/src/main/browser-credentials/origin.test.ts new file mode 100644 index 0000000000..628a5fca8e --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/origin.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin' + +describe('normalizeOrigin', () => { + it.each([ + ['https://example.com/login?next=1', 'https://example.com'], + ['https://EXAMPLE.com', 'https://example.com'], + ['https://example.com:443/', 'https://example.com'], + ['http://example.com:80/', 'http://example.com'], + ['https://example.com:8443/', 'https://example.com:8443'], + ])('reduces %s to %s', (input, expected) => { + expect(normalizeOrigin(input)).toBe(expected) + }) + + it.each([ + ['file:///etc/passwd'], + ['data:text/html,

hi'], + ['javascript:alert(1)'], + ['about:blank'], + ['android://token@com.example/'], + ['not a url'], + [''], + ])('refuses %s, which cannot host a credential', (input) => { + expect(normalizeOrigin(input)).toBeNull() + }) +}) + +describe('exact-origin identity', () => { + it('reduces only identical sites to the same origin', () => { + // Each of these is a different site as far as filling is concerned. + // Widening any of them is how a password reaches somewhere it should not. + expect(normalizeOrigin('https://example.com')).not.toBe( + normalizeOrigin('https://sub.example.com') + ) + expect(normalizeOrigin('https://accounts.google.com')).not.toBe( + normalizeOrigin('https://google.com') + ) + expect(normalizeOrigin('https://example.com')).not.toBe(normalizeOrigin('http://example.com')) + expect(normalizeOrigin('https://example.com')).not.toBe( + normalizeOrigin('https://example.com:8443') + ) + expect(normalizeOrigin('https://example.com/a')).toBe(normalizeOrigin('https://EXAMPLE.com/b')) + }) +}) + +describe('normalizeUsername', () => { + it('trims but preserves case', () => { + expect(normalizeUsername(' Ada ')).toBe('Ada') + expect(normalizeUsername('ada')).not.toBe(normalizeUsername('Ada')) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/origin.ts b/apps/desktop/src/main/browser-credentials/origin.ts new file mode 100644 index 0000000000..b84c2b992c --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/origin.ts @@ -0,0 +1,38 @@ +/** + * Exact-origin normalization for credential matching. + * + * Version one matches on `scheme + host + effective port` and nothing looser: + * `https://accounts.google.com` is not `https://google.com`, `https://x.com` is + * not `http://x.com`, and a subdomain is a different site. Broader affiliation + * is a deliberate non-goal — it is the kind of rule that is easy to widen by + * accident and hard to explain to a user, and getting it wrong means a + * password typed into the wrong site. + */ + +/** Ports that are implied by the scheme and must not change identity. */ +const DEFAULT_PORTS: Record = { 'https:': '443', 'http:': '80' } + +/** + * The canonical origin for `value`, or null when it cannot host a credential. + * + * Rejects everything that is not http(s): `file:`, `data:`, `javascript:`, and + * opaque origins have no trustworthy identity to bind a password to. + */ +export function normalizeOrigin(value: string): string | null { + let url: URL + try { + url = new URL(value) + } catch { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null + if (!url.hostname) return null + + const port = url.port && url.port !== DEFAULT_PORTS[url.protocol] ? `:${url.port}` : '' + return `${url.protocol}//${url.hostname.toLowerCase()}${port}` +} + +/** Usernames compare case-sensitively but ignore surrounding whitespace. */ +export function normalizeUsername(username: string): string { + return username.trim() +} diff --git a/apps/desktop/src/main/browser-credentials/os-auth.test.ts b/apps/desktop/src/main/browser-credentials/os-auth.test.ts new file mode 100644 index 0000000000..cc7f433ae6 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/os-auth.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const promptTouchID = vi.fn(async () => undefined) +const canPromptTouchID = vi.fn(() => true) +const showMessageBox = vi.fn(async () => ({ response: 1 })) + +vi.mock('electron', () => ({ + systemPreferences: { + get canPromptTouchID() { + return canPromptTouchID + }, + get promptTouchID() { + return promptTouchID + }, + }, + dialog: { + get showMessageBox() { + return showMessageBox + }, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + +const { authorizeForSecret, revokeSecretAuthorization } = await import( + '@/main/browser-credentials/os-auth' +) + +const GRACE_MS = 30_000 + +const realPlatform = process.platform + +/** + * Touch ID is reachable only on darwin, so the suite pins the platform rather + * than inheriting the runner's. Without this the biometric expectations below + * pass on a Mac and fail on Linux CI, where the gate sends every call to the + * fallback dialog instead. + */ +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function request(credentialId: string) { + return { credentialId, reason: 'show a saved password', action: 'Show password' } +} + +describe('authorizeForSecret', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + revokeSecretAuthorization() + setPlatform('darwin') + canPromptTouchID.mockReturnValue(true) + promptTouchID.mockResolvedValue(undefined) + }) + + afterEach(() => { + setPlatform(realPlatform) + }) + + it('asks the OS the first time a credential is used', async () => { + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('does not ask again while the proof of presence is fresh', async () => { + await authorizeForSecret(request('c1')) + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + + // The user proved they were here seconds ago and the plaintext is likely + // still on their screen; a second prompt would protect nothing. + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('confines a grant to the credential it was granted for', async () => { + await authorizeForSecret(request('c1')) + await expect(authorizeForSecret(request('c2'))).resolves.toBe(true) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('asks again once the grant lapses', async () => { + vi.useFakeTimers() + await authorizeForSecret(request('c1')) + + vi.advanceTimersByTime(GRACE_MS) + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('holds the grant right up to the boundary', async () => { + vi.useFakeTimers() + await authorizeForSecret(request('c1')) + + vi.advanceTimersByTime(GRACE_MS - 1) + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('grants nothing when the user declines', async () => { + promptTouchID.mockRejectedValueOnce(new Error('cancelled')) + await expect(authorizeForSecret(request('c1'))).resolves.toBe(false) + + // A refusal must not be cached as a grant, nor as a standing denial. + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('asks again after the credential is explicitly revoked', async () => { + await authorizeForSecret(request('c1')) + revokeSecretAuthorization('c1') + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('revokes every credential when given no id', async () => { + await authorizeForSecret(request('c1')) + await authorizeForSecret(request('c2')) + revokeSecretAuthorization() + + await authorizeForSecret(request('c1')) + await authorizeForSecret(request('c2')) + expect(promptTouchID).toHaveBeenCalledTimes(4) + }) + + it('labels the fallback dialog with the action it is authorizing', async () => { + canPromptTouchID.mockReturnValue(false) + await authorizeForSecret({ + credentialId: 'c1', + reason: 'copy a saved password', + action: 'Copy password', + }) + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Copy password?', + buttons: ['Cancel', 'Copy password'], + detail: expect.stringContaining('copy a saved password'), + }) + ) + }) + + it('fails closed when the fallback dialog cannot be shown', async () => { + canPromptTouchID.mockReturnValue(false) + showMessageBox.mockRejectedValueOnce(new Error('no window')) + + await expect(authorizeForSecret(request('c1'))).resolves.toBe(false) + }) + + it('never reaches for Touch ID off darwin', async () => { + // Electron exposes canPromptTouchID on every platform; only the darwin + // guard keeps a non-Mac build out of the biometric path. + setPlatform('linux') + + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + + expect(promptTouchID).not.toHaveBeenCalled() + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts new file mode 100644 index 0000000000..c4c3ed830a --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { dialog, systemPreferences } from 'electron' + +const logger = createLogger('BrowserCredentialAuth') + +/** + * How long proving presence for one credential stands before Sim asks again. + * + * Matches the reveal window in the settings UI, so the prompt comes back at + * the same moment an on-screen password re-masks. Within the window the user + * has already proven they are at the keyboard, and the plaintext they proved + * their way to is typically still on screen — asking a second time to put that + * same string on the clipboard is friction that buys nothing, and teaches + * people to approve prompts without reading them. + */ +const AUTH_GRACE_MS = 30_000 + +/** Credential id to the moment its proof of presence lapses. */ +const provenUntil = new Map() + +export interface SecretAuthRequest { + /** + * The credential the grant covers. Proving presence for one saved password + * says nothing about any other, so grants never widen past the id they were + * granted for. + */ + credentialId: string + /** Completes "Sim is about to ..." in the prompt. */ + reason: string + /** Confirm-button label and title for the non-biometric fallback. */ + action: string +} + +function hasFreshProof(credentialId: string): boolean { + const expiry = provenUntil.get(credentialId) + if (expiry === undefined) return false + if (Date.now() >= expiry) { + provenUntil.delete(credentialId) + return false + } + return true +} + +/** + * Drops standing grants, so a re-import or a delete cannot leave a grant + * attached to an id that now means something else. + * + * Called with no id, it revokes everything. + */ +export function revokeSecretAuthorization(credentialId?: string): void { + if (credentialId === undefined) provenUntil.clear() + else provenUntil.delete(credentialId) +} + +/** + * The authorization step in front of revealing or copying a saved password. + * + * Showing a password is the one place where plaintext leaves the main process + * for the Sim renderer, so it is not enough that the page asked nicely — the + * person at the keyboard has to prove they are there, in a surface the + * renderer cannot drive or fake. + * + * Touch ID is used when the machine has it. Where it does not (a Mac mini + * without a Touch ID keyboard, or a user who has not enrolled), the fallback + * is a native modal that the main process owns. That is genuinely weaker than + * biometric auth — it proves presence, not identity — so it is a deliberate, + * documented trade rather than a silent one, and it still cannot be triggered + * without a real click in the page. + * + * A grant lapses on its own after {@link AUTH_GRACE_MS}; it never removes the + * user-gesture requirement the IPC layer enforces, so it shortens the prompt + * for a present user rather than opening a path for an absent one. + */ +export async function authorizeForSecret({ + credentialId, + reason, + action, +}: SecretAuthRequest): Promise { + if (hasFreshProof(credentialId)) return true + if (!(await promptForSecret(reason, action))) return false + provenUntil.set(credentialId, Date.now() + AUTH_GRACE_MS) + return true +} + +async function promptForSecret(reason: string, action: string): Promise { + if (process.platform === 'darwin' && systemPreferences.canPromptTouchID?.()) { + try { + await systemPreferences.promptTouchID(reason) + return true + } catch { + // A cancelled or failed prompt is a refusal, not an error to report. + return false + } + } + + try { + const { response } = await dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', action], + defaultId: 1, + cancelId: 0, + message: `${action}?`, + detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`, + noLink: true, + }) + return response === 1 + } catch (error) { + // Fail closed: if the confirmation cannot be shown, nothing is revealed. + logger.warn('Could not present the credential confirmation') + return false + } +} diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts new file mode 100644 index 0000000000..a763792d22 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -0,0 +1,211 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { CredentialVault } from '@/main/browser-credentials/vault' + +/** + * A stand-in for Electron `safeStorage`. It is deliberately reversible so the + * vault's own logic can be exercised; the tests that matter for secrecy assert + * that the vault always routes through this provider and refuses to write + * anything when it reports itself unavailable. + */ +function encryption(available = true) { + return { + isEncryptionAvailable: vi.fn(() => available), + encryptString: vi.fn((value: string) => Buffer.from(`sealed:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^sealed:/, '')), + } +} + +let directory: string +let vaultPath: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-vault-test-')) + vaultPath = join(directory, 'browser-credentials.json') +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const CANDIDATES = [ + { origin: 'https://example.com/login', username: 'ada', password: 'hunter2' }, + { origin: 'https://other.test', username: 'grace', password: 'correct-horse' }, +] + +describe('CredentialVault', () => { + it('stores and lists credentials without their passwords', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + + await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({ + added: 2, + updated: 0, + skipped: 0, + }) + + const listed = await vault.list() + expect(listed).toHaveLength(2) + expect(listed[0]).toMatchObject({ origin: 'https://example.com', username: 'ada' }) + expect(listed.every((entry) => !('password' in entry))).toBe(true) + }) + + it('writes only through the encryption provider, and never plaintext', async () => { + const provider = encryption() + const vault = new CredentialVault(vaultPath, provider) + + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect(provider.encryptString).toHaveBeenCalled() + const onDisk = await readFile(vaultPath, 'utf8') + expect(onDisk).not.toContain('hunter2') + expect(onDisk).not.toContain('correct-horse') + expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) }) + }) + + it('writes the vault file owner-only', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect((await stat(vaultPath)).mode & 0o077).toBe(0) + }) + + it('refuses to store anything when secure storage is unavailable', async () => { + // No plaintext fallback: a password file Sim cannot encrypt is worse than + // a feature the user does not get. + const provider = encryption(false) + const vault = new CredentialVault(vaultPath, provider) + + expect(vault.isAvailable()).toBe(false) + await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({ + added: 0, + updated: 0, + skipped: 2, + }) + expect(provider.encryptString).not.toHaveBeenCalled() + await expect(readFile(vaultPath, 'utf8')).rejects.toThrow() + }) + + it('treats exact origin plus username as one credential', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + // A different subdomain and a different username are both new credentials. + await expect( + vault.importCredentials( + [ + { origin: 'https://sub.example.com', username: 'ada', password: 'x' }, + { origin: 'https://example.com', username: 'grace', password: 'y' }, + ], + 'keep-existing' + ) + ).resolves.toMatchObject({ added: 2 }) + }) + + it('keeps the existing password on conflict by default', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await expect( + vault.importCredentials( + [{ origin: 'https://example.com', username: 'ada', password: 'changed' }], + 'keep-existing' + ) + ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 }) + + const stored = await vault.readForFill( + (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '', + 'https://example.com' + ) + expect(stored?.password).toBe('hunter2') + }) + + it('replaces the password on conflict when asked', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await expect( + vault.importCredentials( + [{ origin: 'https://example.com', username: 'ada', password: 'changed' }], + 'replace' + ) + ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 }) + + const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '' + expect((await vault.readForFill(id, 'https://example.com'))?.password).toBe('changed') + }) + + it('skips candidates with no usable origin or an empty password', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + + await expect( + vault.importCredentials( + [ + { origin: 'android://token@com.example/', username: 'a', password: 'p' }, + { origin: 'https://example.com', username: 'b', password: '' }, + ], + 'keep-existing' + ) + ).resolves.toEqual({ added: 0, updated: 0, skipped: 2 }) + }) + + it('only returns a password for the origin it belongs to', async () => { + // The last guard before plaintext exists: an id alone is not enough. + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '' + + expect(await vault.readForFill(id, 'https://example.com')).toMatchObject({ + password: 'hunter2', + }) + expect(await vault.readForFill(id, 'https://evil.test')).toBeNull() + expect(await vault.readForFill('no-such-id', 'https://example.com')).toBeNull() + }) + + it('lists matches for one exact origin only', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect(await vault.listForOrigin('https://example.com/login')).toHaveLength(1) + expect(await vault.listForOrigin('https://sub.example.com')).toHaveLength(0) + expect(await vault.listForOrigin('not-a-url')).toHaveLength(0) + }) + + it('forgets one credential', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + const id = (await vault.list())[0].id + + await expect(vault.delete(id)).resolves.toBe(true) + await expect(vault.delete(id)).resolves.toBe(false) + expect(await vault.list()).toHaveLength(1) + }) + + it('destroys everything on clear', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await vault.clear() + + expect(await vault.list()).toEqual([]) + await expect(readFile(vaultPath, 'utf8')).rejects.toThrow() + // Clearing an already-empty vault is not an error. + await expect(vault.clear()).resolves.toBeUndefined() + }) + + it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => { + const provider = encryption() + provider.decryptString = vi.fn(() => { + throw new Error('wrong key') + }) + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + const brokenVault = new CredentialVault(vaultPath, provider) + await expect(brokenVault.list()).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts new file mode 100644 index 0000000000..3d949235aa --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -0,0 +1,286 @@ +import { readFile } from 'node:fs/promises' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { generateId } from '@sim/utils/id' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin' + +/** + * The encrypted credential store. + * + * The whole record set is encrypted as one blob with Electron `safeStorage` + * (Keychain on macOS), written atomically with restrictive permissions. There + * is no plaintext fallback: when OS-backed encryption is unavailable the vault + * reports itself unavailable and refuses to store anything, because a password + * file Sim cannot encrypt is worse than a feature the user does not get. + * + * Records are decrypted per operation rather than cached. The data is small and + * local, and not holding a decrypted password list in memory for the life of + * the process is worth far more than the saved microseconds. + */ + +const VAULT_VERSION = 1 + +export interface CredentialRecord { + id: string + origin: string + username: string + password: string + /** The site's own icon as a `data:` URL, copied from the source browser. */ + icon?: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' +} + +/** How an import should treat a credential that already exists. */ +export type ConflictPolicy = 'keep-existing' | 'replace' + +export interface ImportCandidate { + origin: string + username: string + password: string + icon?: string +} + +export interface ImportOutcome { + added: number + updated: number + skipped: number +} + +interface EncryptedVaultEnvelope { + version: typeof VAULT_VERSION + ciphertext: string +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +function isCredentialRecord(value: unknown): value is CredentialRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + return ( + typeof record.id === 'string' && + typeof record.origin === 'string' && + typeof record.username === 'string' && + typeof record.password === 'string' && + typeof record.createdAt === 'string' && + typeof record.updatedAt === 'string' && + (record.source === 'chrome' || record.source === 'manual') + ) +} + +function toMetadata(record: CredentialRecord): BrowserCredentialMetadata { + return { + id: record.id, + origin: record.origin, + username: record.username, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + source: record.source, + ...(record.icon ? { icon: record.icon } : {}), + } +} + +export class CredentialVault { + constructor( + private readonly filePath: string, + private readonly encryption: EncryptionProvider = safeStorage, + private readonly now: () => Date = () => new Date() + ) {} + + /** + * Whether credentials can be stored at all. The UI must hide or disable + * password features when this is false rather than degrading to plaintext. + */ + isAvailable(): boolean { + // Guarded: on a Linux box with no keyring this throws rather than + // returning false, and an unguarded call propagated out of a password + // import. The site directory has always defended against it; this did not. + try { + return this.encryption.isEncryptionAvailable() + } catch { + return false + } + } + + /** + * Decrypted records. Private on purpose — nothing outside this class should + * hold a list of passwords, and callers get metadata instead. + */ + private async read(): Promise { + if (!this.isAvailable()) return [] + try { + const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as + | Partial + | undefined + if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') return [] + const parsed = JSON.parse( + this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + ) as unknown + return Array.isArray(parsed) ? parsed.filter(isCredentialRecord) : [] + } catch { + // A missing, corrupt, or undecryptable vault reads as empty rather than + // throwing: the browser must stay usable, and a failed write is where + // the user is told something is wrong. + return [] + } + } + + private async write(records: CredentialRecord[]): Promise { + if (!this.isAvailable()) return false + const envelope: EncryptedVaultEnvelope = { + version: VAULT_VERSION, + ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + } + await writeJsonFileAtomically(this.filePath, envelope) + return true + } + + /** Every stored credential, without passwords, newest activity first. */ + async list(): Promise { + const records = await this.read() + return records + .map(toMetadata) + .sort( + (left, right) => + left.origin.localeCompare(right.origin) || left.username.localeCompare(right.username) + ) + } + + /** Credentials whose origin exactly matches `origin`, without passwords. */ + async listForOrigin(origin: string): Promise { + const normalized = normalizeOrigin(origin) + if (normalized === null) return [] + return (await this.read()) + .filter((record) => record.origin === normalized) + .map(toMetadata) + .sort((left, right) => left.username.localeCompare(right.username)) + } + + /** + * The username and password for one credential. + * + * Main-process only, and only for an authorized fill. These values must + * never be returned through the preload bridge, written to a log, put on a + * span, or persisted anywhere outside this vault. + */ + async readForFill( + id: string, + origin: string + ): Promise<{ username: string; password: string } | null> { + const normalized = normalizeOrigin(origin) + if (normalized === null) return null + // The origin is re-checked here, at the last moment before plaintext is + // produced, so a stale or swapped id cannot pull a password for a site + // other than the one the fill was authorized against. + const record = (await this.read()).find( + (candidate) => candidate.id === id && candidate.origin === normalized + ) + return record ? { username: record.username, password: record.password } : null + } + + /** + * The password for one credential, by id alone. + * + * Unlike {@link readForFill} there is no origin to check against, because + * the password manager lists credentials rather than matching a page. That + * makes this the weakest-guarded read in the vault, so its only caller must + * be the reveal path, behind OS authentication. + */ + async revealPassword(id: string): Promise { + return (await this.read()).find((record) => record.id === id)?.password ?? null + } + + async delete(id: string): Promise { + const records = await this.read() + const remaining = records.filter((record) => record.id !== id) + if (remaining.length === records.length) return false + return this.write(remaining) + } + + /** + * Merges imported credentials. + * + * Identity is exact origin plus username, per the import design: the same + * login on the same site is the same credential, and everything else is a + * new one. A conflicting entry is kept or replaced according to `policy` — + * never silently duplicated. + */ + async importCredentials( + candidates: ImportCandidate[], + policy: ConflictPolicy + ): Promise { + if (!this.isAvailable()) return { added: 0, updated: 0, skipped: candidates.length } + + const records = await this.read() + const byIdentity = new Map( + records.map((record) => [`${record.origin}\u0000${record.username}`, record]) + ) + const timestamp = this.now().toISOString() + const outcome: ImportOutcome = { added: 0, updated: 0, skipped: 0 } + let iconsAdded = false + + for (const candidate of candidates) { + const origin = normalizeOrigin(candidate.origin) + const username = normalizeUsername(candidate.username) + if (origin === null || candidate.password.length === 0) { + outcome.skipped += 1 + continue + } + + const identity = `${origin}\u0000${username}` + const existing = byIdentity.get(identity) + if (existing) { + if (policy === 'keep-existing' || existing.password === candidate.password) { + // A re-import still refreshes a missing icon; that is not a + // credential change, so it does not count as an update. + if (candidate.icon && !existing.icon) { + existing.icon = candidate.icon + iconsAdded = true + } + outcome.skipped += 1 + continue + } + existing.password = candidate.password + existing.updatedAt = timestamp + existing.source = 'chrome' + if (candidate.icon) existing.icon = candidate.icon + outcome.updated += 1 + continue + } + + const record: CredentialRecord = { + id: generateId(), + origin, + username, + password: candidate.password, + ...(candidate.icon ? { icon: candidate.icon } : {}), + createdAt: timestamp, + updatedAt: timestamp, + source: 'chrome', + } + byIdentity.set(identity, record) + records.push(record) + outcome.added += 1 + } + + if (outcome.added === 0 && outcome.updated === 0 && !iconsAdded) return outcome + if (!(await this.write(records))) { + return { added: 0, updated: 0, skipped: candidates.length } + } + return outcome + } + + /** + * Destroys the vault. Sign-out runs this so the next Sim account on this + * machine cannot inherit the previous user's passwords. + */ + async clear(): Promise { + await removeFileIfPresent(this.filePath) + } +} diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts new file mode 100644 index 0000000000..800aecfc61 --- /dev/null +++ b/apps/desktop/src/main/browser-import/browser-sources.ts @@ -0,0 +1,99 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * The Chromium-family browsers Sim can import from on macOS. + * + * Every one of these shares Chromium's profile layout and its `OSCrypt` + * encryption, so a single reader handles all of them. What actually differs + * between browsers is only two things: where the user-data directory lives, + * and which login-Keychain item holds the Safe Storage password. That is what + * this table is. + * + * Safari is deliberately absent and cannot be added here. It stores cookies in + * an undocumented binary format inside a TCC-protected container (reading it + * would require Full Disk Access, a far broader grant than a Keychain prompt), + * and its passwords are iCloud Keychain items with per-item ACLs rather than a + * database — there is no key to derive and nothing to decrypt. The supported + * path for Safari is a user-driven CSV export. + */ +export interface BrowserSource { + /** Stable, opaque identifier used to namespace profile ids. */ + id: string + /** Product name as the user knows it. */ + label: string + /** User-data directory, relative to the home directory. */ + userDataSegments: readonly string[] + /** The login-Keychain generic-password item holding the Safe Storage key. */ + keychain: { service: string; account: string } +} + +export const BROWSER_SOURCES: readonly BrowserSource[] = [ + { + id: 'chrome', + label: 'Chrome', + userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'], + keychain: { service: 'Chrome Safe Storage', account: 'Chrome' }, + }, + { + id: 'arc', + label: 'Arc', + userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'], + keychain: { service: 'Arc Safe Storage', account: 'Arc' }, + }, + { + id: 'dia', + label: 'Dia', + userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'], + keychain: { service: 'Dia Safe Storage', account: 'Dia' }, + }, + { + id: 'brave', + label: 'Brave', + userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'], + keychain: { service: 'Brave Safe Storage', account: 'Brave' }, + }, + { + id: 'edge', + label: 'Microsoft Edge', + userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'], + keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' }, + }, + { + id: 'vivaldi', + label: 'Vivaldi', + userDataSegments: ['Library', 'Application Support', 'Vivaldi'], + keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' }, + }, + { + id: 'chromium', + label: 'Chromium', + userDataSegments: ['Library', 'Application Support', 'Chromium'], + keychain: { service: 'Chromium Safe Storage', account: 'Chromium' }, + }, +] as const + +export function userDataDirFor(source: BrowserSource, home: string = homedir()): string { + return join(home, ...source.userDataSegments) +} + +/** + * Splits a bridge profile id back into its browser and profile directory. + * + * Ids are namespaced (`arc:Profile 1`) because profile directory names repeat + * across browsers — every one of them has a `Default`. Returns null for + * anything malformed; the caller then resolves against discovered profiles + * anyway, so a bad id can never become a path. + */ +export function parseProfileId(profileId: string): { sourceId: string; directory: string } | null { + const separator = profileId.indexOf(':') + if (separator <= 0 || separator === profileId.length - 1) return null + return { + sourceId: profileId.slice(0, separator), + directory: profileId.slice(separator + 1), + } +} + +export function formatProfileId(sourceId: string, directory: string): string { + return `${sourceId}:${directory}` +} diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts new file mode 100644 index 0000000000..2ef3d603e3 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts @@ -0,0 +1,249 @@ +import { createCipheriv, createHash } from 'node:crypto' +import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { sleep } from '@sim/utils/helpers' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' + +/** + * Exercises the reader against a synthetic Chrome profile. Fixtures are built + * here on purpose: a test must never read a developer's real Chrome profile or + * touch their Keychain. + */ +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +const KEY = deriveEncryptionKey('test-safe-storage-password') +const NOW_SECONDS = 1_800_000_000 + +async function stagingDirectories(): Promise> { + const entries = await readdir(tmpdir()) + return new Set(entries.filter((entry) => entry.startsWith('sim-chrome-import-'))) +} + +/** + * Staging copies still present that were not there at `before`. + * + * Every reader in this folder stages through the shared `os.tmpdir()` under one + * prefix, and Vitest runs their suites in parallel workers, so a single snapshot + * can catch a sibling's copy mid-read and read as a leak. A leak of our own + * never clears, so this settles instead of sampling once. + */ +async function stagingLeftBehindSince(before: ReadonlySet): Promise { + let leftover: string[] = [] + for (let attempt = 0; attempt < 20; attempt++) { + leftover = [...(await stagingDirectories())].filter((entry) => !before.has(entry)) + if (leftover.length === 0) return leftover + await sleep(25) + } + return leftover +} + +function chromeTime(unixSeconds: number): bigint { + return BigInt(unixSeconds + 11_644_473_600) * 1_000_000n +} + +function encryptV10(plaintext: Buffer): Uint8Array { + const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()]) +} + +interface FixtureCookie { + hostKey: string + name: string + encryptedValue?: Uint8Array + value?: string + expiresUtc?: bigint + hasExpires?: number + isPersistent?: number + isSecure?: number + sameSite?: number + path?: string +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-chrome-cookies-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeCookieDatabase(cookies: FixtureCookie[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Cookies') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE cookies ( + creation_utc INTEGER NOT NULL, host_key TEXT NOT NULL, name TEXT NOT NULL, + value TEXT NOT NULL, path TEXT NOT NULL, expires_utc INTEGER NOT NULL, + is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL, + has_expires INTEGER NOT NULL, is_persistent INTEGER NOT NULL, + samesite INTEGER NOT NULL, encrypted_value BLOB + ) + `) + const insert = database.prepare(` + INSERT INTO cookies (creation_utc, host_key, name, value, path, expires_utc, + is_secure, is_httponly, has_expires, is_persistent, samesite, encrypted_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + for (const cookie of cookies) { + insert.run( + chromeTime(NOW_SECONDS - 100), + cookie.hostKey, + cookie.name, + cookie.value ?? '', + cookie.path ?? '/', + cookie.expiresUtc ?? chromeTime(NOW_SECONDS + 3600), + cookie.isSecure ?? 1, + 1, + cookie.hasExpires ?? 1, + cookie.isPersistent ?? 1, + cookie.sameSite ?? 1, + cookie.encryptedValue ?? null + ) + } + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { + it('decrypts a v10 cookie and preserves its attributes', async () => { + const path = await writeCookieDatabase([ + { + hostKey: '.example.com', + name: 'session', + encryptedValue: encryptV10(Buffer.from('token-123')), + }, + ]) + + const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(result.rowsSeen).toBe(1) + expect(result.cookies).toHaveLength(1) + const [cookie] = result.cookies + expect(cookie).toMatchObject({ + url: 'https://example.com/', + name: 'session', + value: 'token-123', + domain: '.example.com', + secure: true, + httpOnly: true, + sameSite: 'lax', + }) + expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 3600, 3) + }) + + it('reads Chrome timestamps that overflow a safe integer', async () => { + const path = await writeCookieDatabase([ + { + hostKey: 'example.com', + name: 'a', + encryptedValue: encryptV10(Buffer.from('v')), + expiresUtc: chromeTime(NOW_SECONDS + 86_400), + }, + ]) + + const [cookie] = (await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies + expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 86_400, 3) + }) + + it('removes a verified domain-bound prefix', async () => { + const plaintext = Buffer.concat([ + createHash('sha256').update('.example.com').digest(), + Buffer.from('bound'), + ]) + const path = await writeCookieDatabase([ + { hostKey: '.example.com', name: 'a', encryptedValue: encryptV10(plaintext) }, + ]) + + expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe('bound') + }) + + it('falls back to the plain value column for unencrypted rows', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', value: 'legacy-plaintext' }, + ]) + + expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe( + 'legacy-plaintext' + ) + }) + + it('counts unreadable and expired rows instead of failing the import', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'good', encryptedValue: encryptV10(Buffer.from('v')) }, + { hostKey: 'example.com', name: 'bad', encryptedValue: Buffer.from('v20-not-supported') }, + { + hostKey: 'example.com', + name: 'old', + encryptedValue: encryptV10(Buffer.from('v')), + expiresUtc: chromeTime(NOW_SECONDS - 10), + }, + ]) + + const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(result.rowsSeen).toBe(3) + expect(result.cookies.map(({ name }) => name)).toEqual(['good']) + expect(result.skipped['decrypt-failed']).toBe(1) + expect(result.skipped.expired).toBe(1) + }) + + it('reports an unrecognised schema rather than guessing', async () => { + const path = join(directory, 'Cookies') + const { DatabaseSync } = await import('node:sqlite') + const database = new DatabaseSync(path) + database.exec('CREATE TABLE something_else (a TEXT)') + database.close() + + await expect(readBrowserCookies(path, KEY, NOW_SECONDS)).rejects.toMatchObject({ + code: 'unsupported-schema', + }) + }) + + it('reports an unreadable source rather than throwing raw', async () => { + await expect( + readBrowserCookies(join(directory, 'absent', 'Cookies'), KEY, NOW_SECONDS) + ).rejects.toMatchObject({ code: 'profile-unreadable' }) + }) + + it('leaves the source database untouched', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) }, + ]) + const before = await stat(path) + + await readBrowserCookies(path, KEY, NOW_SECONDS) + + const after = await stat(path) + expect(after.size).toBe(before.size) + expect(after.mtimeMs).toBe(before.mtimeMs) + }) + + it('deletes its decrypted working copy', async () => { + const before = await stagingDirectories() + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) }, + ]) + + await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(await stagingLeftBehindSince(before)).toEqual([]) + }) + + it('cleans up even when the read fails', async () => { + const before = await stagingDirectories() + await writeFile(join(directory, 'Cookies'), 'not a database') + + await expect(readBrowserCookies(join(directory, 'Cookies'), KEY, NOW_SECONDS)).rejects.toThrow() + + expect(await stagingLeftBehindSince(before)).toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.ts b/apps/desktop/src/main/browser-import/chromium-cookies.ts new file mode 100644 index 0000000000..74e79b6cd0 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-cookies.ts @@ -0,0 +1,86 @@ +import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto' +import { translateCookieRow } from '@/main/browser-import/cookie-translate' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { + type ChromiumCookieRow, + type CookieSkipCounts, + emptySkipCounts, + type ImportableCookie, + toNumber, + toText, +} from '@/main/browser-import/types' + +/** + * Decrypts a Chrome profile's cookies. The source database is copied and read + * read-only by {@link queryBrowserDatabase}; nothing here writes to Chrome. + */ + +const MAX_COOKIE_ROWS = 50_000 + +const COOKIE_QUERY = ` + SELECT host_key, name, path, expires_utc, is_secure, is_httponly, + has_expires, is_persistent, samesite, encrypted_value, value + FROM cookies + LIMIT ${MAX_COOKIE_ROWS} +` + +export interface ReadCookiesResult { + cookies: ImportableCookie[] + skipped: CookieSkipCounts + /** Rows examined, so the caller can tell "no cookies" from "none survived". */ + rowsSeen: number +} + +function toRow(raw: Record): ChromiumCookieRow { + return { + hostKey: toText(raw.host_key), + name: toText(raw.name), + path: toText(raw.path), + expiresUtc: toNumber(raw.expires_utc), + isSecure: toNumber(raw.is_secure) !== 0, + isHttpOnly: toNumber(raw.is_httponly) !== 0, + hasExpires: toNumber(raw.has_expires) !== 0, + isPersistent: toNumber(raw.is_persistent) !== 0, + sameSite: toNumber(raw.samesite), + } +} + +/** + * Rows that fail to decrypt or cannot be aimed at a single host are counted, + * not thrown — one unreadable cookie should not cost the user the other + * thousand. + */ +export async function readBrowserCookies( + cookiesPath: string, + key: Buffer, + nowSeconds: number = Date.now() / 1000 +): Promise { + const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', COOKIE_QUERY) + const cookies: ImportableCookie[] = [] + const skipped = emptySkipCounts() + + for (const raw of rows) { + const row = toRow(raw) + const encrypted = raw.encrypted_value + let value: string | null + if (encrypted instanceof Uint8Array && encrypted.length > 0) { + value = decryptChromiumValue(Buffer.from(encrypted), key, { domain: row.hostKey }) + } else { + // Pre-encryption rows keep their value in the plain `value` column. + value = toText(raw.value) + } + if (value === null) { + skipped['decrypt-failed'] += 1 + continue + } + + const outcome = translateCookieRow(row, value, nowSeconds) + if (outcome.ok) { + cookies.push(outcome.cookie) + } else { + skipped[outcome.reason] += 1 + } + } + + return { cookies, skipped, rowsSeen: rows.length } +} diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.test.ts b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts new file mode 100644 index 0000000000..7fcf4dbd10 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts @@ -0,0 +1,88 @@ +import { createCipheriv, createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { + decryptChromiumValue, + deriveEncryptionKey, + readSafeStoragePassword, +} from '@/main/browser-import/chromium-crypto' + +/** Produces a blob in the same shape Chrome writes on macOS. */ +function encryptV10(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()]) +} + +const KEY = deriveEncryptionKey('test-safe-storage-password') + +describe('deriveEncryptionKey', () => { + it('derives a deterministic AES-128 key', () => { + expect(KEY).toHaveLength(16) + expect(deriveEncryptionKey('test-safe-storage-password').equals(KEY)).toBe(true) + expect(deriveEncryptionKey('a-different-password').equals(KEY)).toBe(false) + }) +}) + +describe('decryptChromiumValue', () => { + it('round-trips a v10 value', () => { + const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY) + expect(decryptChromiumValue(encrypted, KEY)).toBe('session-token-abc') + }) + + it('strips a domain hash prefix once it verifies against the host', () => { + const domain = '.example.com' + const plaintext = Buffer.concat([ + createHash('sha256').update(domain).digest(), + Buffer.from('bound-value'), + ]) + expect(decryptChromiumValue(encryptV10(plaintext, KEY), KEY, { domain })).toBe('bound-value') + }) + + it('leaves values from profiles without domain binding intact', () => { + // Cutting a fixed 32 bytes would silently corrupt every value written by + // an older Chrome, so the prefix is only removed when it is really a hash. + const encrypted = encryptV10(Buffer.from('a'.repeat(40)), KEY) + expect(decryptChromiumValue(encrypted, KEY, { domain: '.example.com' })).toBe('a'.repeat(40)) + }) + + it('rejects a blob that is not a supported scheme', () => { + expect(decryptChromiumValue(Buffer.from('v20somethingelse'), KEY)).toBeNull() + expect(decryptChromiumValue(Buffer.alloc(0), KEY)).toBeNull() + expect(decryptChromiumValue(Buffer.from('v10'), KEY)).toBeNull() + }) + + it('rejects a body that is not a whole number of AES blocks', () => { + expect( + decryptChromiumValue(Buffer.concat([Buffer.from('v10'), Buffer.alloc(7)]), KEY) + ).toBeNull() + }) + + it('does not return plaintext when decrypted with the wrong key', () => { + const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY) + expect(decryptChromiumValue(encrypted, deriveEncryptionKey('wrong'))).not.toBe( + 'session-token-abc' + ) + }) +}) + +describe('readSafeStoragePassword', () => { + const ARC_ITEM = { service: 'Arc Safe Storage', account: 'Arc' } + + it('reads the item belonging to the browser being imported', async () => { + // Each browser names its own item. Asking for the wrong one would prompt + // the user about a browser they did not choose. + const read = vi.fn().mockResolvedValue('arc-password') + + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBe('arc-password') + expect(read).toHaveBeenCalledExactlyOnceWith('Arc Safe Storage', 'Arc') + }) + + it('fails closed when the item is denied or missing', async () => { + const read = vi.fn().mockRejectedValue(new Error('User denied access')) + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull() + }) + + it('treats an empty password as unavailable', async () => { + const read = vi.fn().mockResolvedValue(' ') + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull() + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.ts b/apps/desktop/src/main/browser-import/chromium-crypto.ts new file mode 100644 index 0000000000..53f1892066 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-crypto.ts @@ -0,0 +1,157 @@ +import { execFile } from 'node:child_process' +import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto' +import { promisify } from 'node:util' + +/** + * Chrome's macOS value encryption (Chromium's `OSCrypt`, scheme `v10`). + * + * The key is derived from a random password Chrome stores in the login + * Keychain. Reading it goes through the documented `security` tool, so macOS + * applies its own Keychain ACL — the user gets the standard "allow access" + * prompt for a binary that is not on the item's ACL, and a denial is a hard + * failure here. Nothing in this module weakens or works around that check. + * + * On Windows this scheme does not apply: Chrome's App-Bound Encryption is + * designed to stop other applications decrypting profile data, and the + * importer deliberately does not attempt it. + */ + +const execFileAsync = promisify(execFile) + +/** Fixed Chromium `OSCrypt` parameters for macOS. */ +const KEY_SALT = 'saltysalt' +const KEY_ITERATIONS = 1003 +const KEY_LENGTH = 16 +/** Chromium uses 16 spaces as the IV rather than a per-value nonce. */ +const INITIALIZATION_VECTOR = Buffer.alloc(16, 0x20) +const V10_PREFIX = Buffer.from('v10') +const AES_BLOCK_SIZE = 16 +/** Length of the SHA-256(domain) modern Chrome prepends to cookie plaintext. */ +const DOMAIN_HASH_LENGTH = 32 + +/** + * The user could sit on the Keychain prompt for a while, so this is a + * safety net against a wedged child process, not a UI deadline. + */ +const KEYCHAIN_TIMEOUT_MS = 120_000 + +export type KeychainPasswordReader = (service: string, account: string) => Promise + +const readKeychainItem: KeychainPasswordReader = async (service, account) => { + const { stdout } = await execFileAsync( + '/usr/bin/security', + ['find-generic-password', '-w', '-s', service, '-a', account], + { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 64 * 1024 } + ) + return stdout.trim() +} + +/** + * The Safe Storage password for one browser's Keychain item, or null when the + * item does not exist or the user refused access. + * + * Each browser names its own item (`Arc Safe Storage`, `Brave Safe Storage`, + * and so on), so the item is passed in rather than guessed at: asking for the + * wrong one would prompt the user about a browser they did not choose. + * + * The return value is a secret. It must never be logged, persisted, or sent + * anywhere. Callers should derive a key and drop the reference immediately. + */ +export async function readSafeStoragePassword( + item: { service: string; account: string }, + read: KeychainPasswordReader = readKeychainItem +): Promise { + try { + const password = (await read(item.service, item.account)).trim() + return password.length > 0 ? password : null + } catch { + // Item absent, or access denied. Fail closed. + return null + } +} + +/** Derives the AES-128 key. The result is key material — zero it after use. */ +export function deriveEncryptionKey(safeStoragePassword: string): Buffer { + return pbkdf2Sync(safeStoragePassword, KEY_SALT, KEY_ITERATIONS, KEY_LENGTH, 'sha1') +} + +/** + * Strips Chromium's PKCS#7 padding, tolerating values that carry none. + * + * A wrong key usually surfaces here or as mojibake rather than as a thrown + * error, which is why the caller treats an implausible result as a failed + * decrypt rather than trusting it. + */ +function stripPkcs7Padding(plaintext: Buffer): Buffer { + const padding = plaintext.at(-1) + if (padding === undefined || padding === 0 || padding > AES_BLOCK_SIZE) return plaintext + if (padding > plaintext.length) return plaintext + return plaintext.subarray(0, plaintext.length - padding) +} + +/** + * Removes the SHA-256(domain) prefix modern Chrome binds to cookie plaintext, + * but only after confirming the prefix really is that hash. + * + * Verifying rather than blindly dropping 32 bytes is what makes this work + * across Chrome versions: profiles written before domain binding have no + * prefix, and cutting one off them would silently corrupt every value. + */ +function stripVerifiedDomainHash(plaintext: Buffer, hostKey: string): Buffer { + if (plaintext.length < DOMAIN_HASH_LENGTH) return plaintext + const prefix = plaintext.subarray(0, DOMAIN_HASH_LENGTH) + const bareHost = hostKey.replace(/^\./, '') + for (const candidate of [hostKey, bareHost, `.${bareHost}`]) { + if (createHash('sha256').update(candidate).digest().equals(prefix)) { + return plaintext.subarray(DOMAIN_HASH_LENGTH) + } + } + return plaintext +} + +export interface DecryptOptions { + /** + * The row's `host_key`. Supply it for cookies so a domain-bound prefix can + * be recognised and removed; omit it for values that carry no prefix. + */ + domain?: string +} + +/** + * Decrypts one `v10` value, or returns null when the blob is not a supported + * scheme or does not decrypt to usable text. + * + * Never throws: a single unreadable row must not abort an import, and the + * caller counts failures instead. A null is also how a `v20`/app-bound value + * surfaces, which is the signal that this profile needs a different path + * rather than a weaker one. + */ +export function decryptChromiumValue( + encrypted: Buffer, + key: Buffer, + { domain }: DecryptOptions = {} +): string | null { + const body = encrypted.subarray(V10_PREFIX.length) + if ( + encrypted.length <= V10_PREFIX.length || + !encrypted.subarray(0, V10_PREFIX.length).equals(V10_PREFIX) || + body.length % AES_BLOCK_SIZE !== 0 + ) { + return null + } + try { + const decipher = createDecipheriv('aes-128-cbc', key, INITIALIZATION_VECTOR) + decipher.setAutoPadding(false) + let plaintext = stripPkcs7Padding(Buffer.concat([decipher.update(body), decipher.final()])) + if (domain !== undefined) { + plaintext = stripVerifiedDomainHash(plaintext, domain) + } + const value = plaintext.toString('utf8') + // A wrong key decrypts to bytes that are not a cookie value. Rejecting + // replacement characters and control bytes keeps that garbage out of the + // browser profile instead of storing it as a live cookie. + return value.includes('\uFFFD') || /[\u0000-\u001F\u007F]/.test(value) ? null : value + } catch { + return null + } +} diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.test.ts b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts new file mode 100644 index 0000000000..f2ec2679a3 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons' + +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +/** A minimal but genuinely valid 1x1 PNG. */ +const PNG = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6300010000050001' + + '0d0a2db40000000049454e44ae426082', + 'hex' +) + +interface FixtureIcon { + pageUrl: string + width?: number + data?: Uint8Array +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-favicons-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeFaviconDatabase(icons: FixtureIcon[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Favicons') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE icon_mapping (id INTEGER PRIMARY KEY, page_url TEXT, icon_id INTEGER); + CREATE TABLE favicon_bitmaps (id INTEGER PRIMARY KEY, icon_id INTEGER, image_data BLOB, width INTEGER); + `) + const mapping = database.prepare('INSERT INTO icon_mapping (page_url, icon_id) VALUES (?, ?)') + const bitmap = database.prepare( + 'INSERT INTO favicon_bitmaps (icon_id, image_data, width) VALUES (?, ?, ?)' + ) + icons.forEach((icon, index) => { + mapping.run(icon.pageUrl, index + 1) + bitmap.run(index + 1, icon.data ?? PNG, icon.width ?? 32) + }) + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserFavicons', () => { + it('returns an icon keyed by origin as a data URL', async () => { + const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/login' }]) + + const icons = await readBrowserFavicons(path, new Set(['https://example.com'])) + + expect(icons.get('https://example.com')).toMatch(/^data:image\/png;base64,/) + }) + + it('reads only the origins being imported', async () => { + // A password import must not drag the browser's whole history along. + const path = await writeFaviconDatabase([ + { pageUrl: 'https://wanted.test/' }, + { pageUrl: 'https://unrelated.test/' }, + ]) + + const icons = await readBrowserFavicons(path, new Set(['https://wanted.test'])) + + expect([...icons.keys()]).toEqual(['https://wanted.test']) + }) + + it('does nothing when no origins are requested', async () => { + const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/' }]) + + await expect(readBrowserFavicons(path, new Set())).resolves.toEqual(new Map()) + }) + + it('prefers the bitmap closest to the size actually displayed', async () => { + const large = Buffer.concat([PNG, Buffer.alloc(16)]) + const path = await writeFaviconDatabase([ + { pageUrl: 'https://example.com/', width: 16 }, + { pageUrl: 'https://example.com/other', width: 32, data: large }, + ]) + + const icon = await readBrowserFavicons(path, new Set(['https://example.com'])) + + expect(icon.get('https://example.com')).toBe( + `data:image/png;base64,${large.toString('base64')}` + ) + }) + + it('ignores rows that are not usable icons', async () => { + const path = await writeFaviconDatabase([ + { pageUrl: 'https://notpng.test/', data: Buffer.from('') }, + { pageUrl: 'https://huge.test/', data: Buffer.concat([PNG, Buffer.alloc(17 * 1024)]) }, + { pageUrl: 'https://oversized.test/', width: 512 }, + ]) + + const icons = await readBrowserFavicons( + path, + new Set(['https://notpng.test', 'https://huge.test', 'https://oversized.test']) + ) + + expect(icons.size).toBe(0) + }) + + it('treats an unreadable favicon store as simply having no icons', async () => { + // Icons are decoration; failing to read them must not fail an import. + await expect( + readBrowserFavicons(join(directory, 'absent'), new Set(['https://example.com'])) + ).resolves.toEqual(new Map()) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.ts b/apps/desktop/src/main/browser-import/chromium-favicons.ts new file mode 100644 index 0000000000..304991909c --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-favicons.ts @@ -0,0 +1,87 @@ +import { normalizeOrigin } from '@/main/browser-credentials/origin' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Reads site icons out of a Chromium profile's `Favicons` database. + * + * Deliberately local. The obvious way to get a favicon is to ask a public + * service for it, which would hand that service the domain of every site the + * user has a password for — the exact list this feature exists to protect. + * The browser being imported from already has these icons on disk, so they + * come from there and never touch the network. + */ + +/** Chromium stores several sizes per icon; this is the one worth keeping. */ +const PREFERRED_SIZE = 32 +const MAX_SIZE = 128 +/** A favicon larger than this is not worth carrying inside the vault. */ +const MAX_BYTES = 16 * 1024 +const MAX_ROWS = 20_000 + +const FAVICON_QUERY = ` + SELECT icon_mapping.page_url AS page_url, + favicon_bitmaps.image_data AS image_data, + favicon_bitmaps.width AS width + FROM icon_mapping + JOIN favicon_bitmaps ON favicon_bitmaps.icon_id = icon_mapping.icon_id + WHERE favicon_bitmaps.image_data IS NOT NULL + LIMIT ${MAX_ROWS} +` + +/** PNG magic number — the only format written back out as a data URL. */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function isPng(data: Uint8Array): boolean { + return data.length > PNG_SIGNATURE.length && PNG_SIGNATURE.equals(data.subarray(0, 8)) +} + +/** How far a bitmap's width is from the size worth keeping. */ +function sizeDistance(width: number): number { + return Math.abs((width || PREFERRED_SIZE) - PREFERRED_SIZE) +} + +/** + * Site icons for `origins`, keyed by origin, as `data:` URLs. + * + * Only the requested origins are returned, so a profile's entire browsing + * history does not come along with a password import. Never throws: an + * unreadable or unfamiliar favicon store just means no icons. + */ +export async function readBrowserFavicons( + faviconsPath: string, + origins: ReadonlySet +): Promise> { + if (origins.size === 0) return new Map() + + let rows: Record[] + try { + rows = await queryBrowserDatabase(faviconsPath, 'Favicons', FAVICON_QUERY) + } catch { + // Icons are decoration. Failing to read them must not fail the import. + return new Map() + } + + const best = new Map() + for (const row of rows) { + const origin = normalizeOrigin(toText(row.page_url)) + if (origin === null || !origins.has(origin)) continue + + const data = row.image_data + if (!(data instanceof Uint8Array) || data.length > MAX_BYTES || !isPng(data)) continue + + const width = toNumber(row.width) + if (width > MAX_SIZE) continue + + const distance = sizeDistance(width) + const current = best.get(origin) + if (!current || distance < current.distance) best.set(origin, { distance, data }) + } + + return new Map( + [...best].map(([origin, { data }]) => [ + origin, + `data:image/png;base64,${Buffer.from(data).toString('base64')}`, + ]) + ) +} diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.test.ts b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts new file mode 100644 index 0000000000..00b992b393 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts @@ -0,0 +1,152 @@ +import { createCipheriv } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' +import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords' + +/** + * Runs against a synthetic `Login Data` database. Tests never read a + * developer's real Chrome profile or Keychain. + */ +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +const KEY = deriveEncryptionKey('test-safe-storage-password') + +function encryptV10(plaintext: string): Uint8Array { + const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(Buffer.from(plaintext)), cipher.final()]) +} + +interface FixtureLogin { + signonRealm: string + username: string + passwordValue?: Uint8Array + blacklisted?: number + originUrl?: string +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-chrome-logins-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeLoginDatabase(logins: FixtureLogin[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Login Data') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE logins ( + origin_url TEXT NOT NULL, signon_realm TEXT NOT NULL, + username_value TEXT NOT NULL, password_value BLOB, + blacklisted_by_user INTEGER NOT NULL + ) + `) + const insert = database.prepare( + 'INSERT INTO logins (origin_url, signon_realm, username_value, password_value, blacklisted_by_user) VALUES (?, ?, ?, ?, ?)' + ) + for (const login of logins) { + insert.run( + login.originUrl ?? `${login.signonRealm}login`, + login.signonRealm, + login.username, + login.passwordValue ?? null, + login.blacklisted ?? 0 + ) + } + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserPasswords', () => { + it('decrypts saved logins', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: 'https://example.com/', + username: 'ada', + passwordValue: encryptV10('hunter2'), + }, + ]) + + const result = await readBrowserPasswords(path, KEY) + + expect(result.rowsSeen).toBe(1) + expect(result.credentials).toEqual([ + { origin: 'https://example.com/', username: 'ada', password: 'hunter2' }, + ]) + }) + + it('does not strip a prefix from password plaintext', async () => { + // Unlike cookies, saved passwords carry no domain-bound prefix. Removing + // 32 bytes here would silently corrupt every password. + const password = 'x'.repeat(48) + const path = await writeLoginDatabase([ + { + signonRealm: 'https://example.com/', + username: 'ada', + passwordValue: encryptV10(password), + }, + ]) + + expect((await readBrowserPasswords(path, KEY)).credentials[0].password).toBe(password) + }) + + it('skips never-saved sites, empty rows, and undecryptable values', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: 'https://good.test/', + username: 'ada', + passwordValue: encryptV10('keep'), + }, + { signonRealm: 'https://blocked.test/', username: '', blacklisted: 1 }, + { signonRealm: 'https://empty.test/', username: 'a' }, + { + signonRealm: 'https://broken.test/', + username: 'b', + passwordValue: Buffer.from('v20-app-bound'), + }, + ]) + + const result = await readBrowserPasswords(path, KEY) + + expect(result.rowsSeen).toBe(4) + expect(result.credentials.map(({ origin }) => origin)).toEqual(['https://good.test/']) + expect(result.skipped).toBe(3) + }) + + it('falls back to the origin url when there is no signon realm', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: '', + originUrl: 'https://fallback.test/login', + username: 'ada', + passwordValue: encryptV10('p'), + }, + ]) + + expect((await readBrowserPasswords(path, KEY)).credentials[0].origin).toBe( + 'https://fallback.test/login' + ) + }) + + it('reports an unrecognised schema rather than guessing', async () => { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Login Data') + const database = new DatabaseSync(path) + database.exec('CREATE TABLE not_logins (a TEXT)') + database.close() + + await expect(readBrowserPasswords(path, KEY)).rejects.toMatchObject({ + code: 'unsupported-schema', + }) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.ts b/apps/desktop/src/main/browser-import/chromium-passwords.ts new file mode 100644 index 0000000000..f240864167 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-passwords.ts @@ -0,0 +1,70 @@ +import type { ImportCandidate } from '@/main/browser-credentials/vault' +import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Decrypts a Chrome profile's saved passwords. + * + * Same Keychain key and `v10` scheme as the cookie reader, with one + * difference: password plaintext carries no domain-bound prefix, so no + * `domain` is passed to the decryptor and nothing is stripped. + * + * The values produced here are the most sensitive material the importer + * handles. They are passed straight to the encrypted vault and are never + * logged, counted by site, or returned to a renderer. + */ + +const MAX_LOGIN_ROWS = 20_000 + +const LOGIN_QUERY = ` + SELECT signon_realm, origin_url, username_value, password_value, blacklisted_by_user + FROM logins + LIMIT ${MAX_LOGIN_ROWS} +` + +export interface ReadPasswordsResult { + credentials: ImportCandidate[] + skipped: number + /** Rows examined, so the caller can tell "no passwords" from "none decrypted". */ + rowsSeen: number +} + +export async function readBrowserPasswords( + loginDataPath: string, + key: Buffer +): Promise { + const rows = await queryBrowserDatabase(loginDataPath, 'Login Data', LOGIN_QUERY) + const credentials: ImportCandidate[] = [] + let skipped = 0 + + for (const raw of rows) { + // "Never saved for this site" rows exist to suppress Chrome's save prompt + // and carry no usable password. + if (toNumber(raw.blacklisted_by_user) !== 0) { + skipped += 1 + continue + } + + const encrypted = raw.password_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + skipped += 1 + continue + } + + const password = decryptChromiumValue(Buffer.from(encrypted), key) + if (password === null || password.length === 0) { + skipped += 1 + continue + } + + // `signon_realm` is Chrome's authority for where a credential belongs; + // `origin_url` is the page it was captured on. Federated and Android + // realms do not reduce to an http(s) origin and are dropped by the vault's + // own normalization rather than being coerced into one here. + const origin = toText(raw.signon_realm) || toText(raw.origin_url) + credentials.push({ origin, username: toText(raw.username_value), password }) + } + + return { credentials, skipped, rowsSeen: rows.length } +} diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.test.ts b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts new file mode 100644 index 0000000000..2d62feb776 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + BROWSER_SOURCES, + type BrowserSource, + userDataDirFor, +} from '@/main/browser-import/browser-sources' +import { + listAllBrowserProfiles, + listBrowserProfiles, +} from '@/main/browser-import/chromium-profiles' + +const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource +const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'sim-browser-profiles-test-')) +}) + +afterEach(async () => { + await rm(home, { recursive: true, force: true }) +}) + +/** Writes a profile directory holding a database at `relativePath`. */ +async function addProfile( + source: BrowserSource, + dir: string, + relativePath = join('Network', 'Cookies') +): Promise { + const path = join(userDataDirFor(source, home), dir, relativePath) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, '') +} + +async function writeLocalState( + source: BrowserSource, + infoCache: Record +): Promise { + const userDataDir = userDataDirFor(source, home) + await mkdir(userDataDir, { recursive: true }) + await writeFile( + join(userDataDir, 'Local State'), + JSON.stringify({ profile: { info_cache: infoCache } }) + ) +} + +describe('listBrowserProfiles', () => { + it('returns display names, default profile first, with namespaced ids', async () => { + await addProfile(CHROME, 'Profile 2') + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name: 'Person 1' }, 'Profile 2': { name: 'Work' } }) + + const profiles = await listBrowserProfiles(CHROME, home) + + expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([ + // `Person 1` is Chromium's placeholder, so it reads as unnamed. + { id: 'chrome:Default', label: '' }, + { id: 'chrome:Profile 2', label: 'Work' }, + ]) + expect(profiles[0].cookiesPath).toBe( + join(userDataDirFor(CHROME, home), 'Default/Network/Cookies') + ) + expect(profiles[0].source.id).toBe('chrome') + }) + + it('orders numbered profiles numerically rather than as strings', async () => { + for (const dir of ['Profile 10', 'Profile 2', 'Default']) await addProfile(CHROME, dir) + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual([ + 'chrome:Default', + 'chrome:Profile 2', + 'chrome:Profile 10', + ]) + }) + + it('finds the cookie database at the pre-M96 location', async () => { + await addProfile(CHROME, 'Default', 'Cookies') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.cookiesPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Cookies')) + }) + + it('finds the saved-password database alongside the cookies', async () => { + await addProfile(CHROME, 'Default') + await addProfile(CHROME, 'Default', 'Login Data') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.loginDataPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Login Data')) + }) + + it('lists a profile that has only saved passwords', async () => { + // Passwords and cookies import independently, so a profile with one and + // not the other must still be offered. + await addProfile(CHROME, 'Default', 'Login Data') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile).toMatchObject({ id: 'chrome:Default', cookiesPath: null }) + }) + + it('omits profiles with no readable database', async () => { + await mkdir(join(userDataDirFor(CHROME, home), 'Profile 3'), { recursive: true }) + await addProfile(CHROME, 'Default') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('ignores internal profiles', async () => { + await addProfile(CHROME, 'System Profile') + await addProfile(CHROME, 'Guest Profile') + await addProfile(CHROME, 'Default') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('refuses profile keys that would escape the user-data directory', async () => { + // Local State is data this process does not own, so a crafted key must not + // become a path. + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { + Default: { name: 'Person 1' }, + '../../../../etc': { name: 'Escaped' }, + '/absolute/elsewhere': { name: 'Absolute' }, + }) + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('falls back to directory names when Local State is unreadable', async () => { + await addProfile(CHROME, 'Default') + await writeFile(join(userDataDirFor(CHROME, home), 'Local State'), 'not json{{') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([ + { id: 'chrome:Default', label: '' }, + ]) + }) + + it('skips a browser\u2019s internal profiles', async () => { + // Arc keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory, + // so only the name gives it away. + await addProfile(ARC, 'Default') + await addProfile(ARC, 'Profile 1') + await addProfile(ARC, 'Profile 2') + await writeLocalState(ARC, { + Default: { name: 'Your Chromium' }, + 'Profile 1': { name: '__ARC_SYSTEM_PROFILE' }, + 'Profile 2': { name: 'Microtrades' }, + }) + + const profiles = await listBrowserProfiles(ARC, home) + + expect(profiles.map(({ id }) => id)).toEqual(['arc:Default', 'arc:Profile 2']) + }) + + it.each([['Your Chrome'], ['Your Chromium'], ['Person 1'], ['Default'], ['Chrome']])( + 'treats the placeholder name %s as unnamed', + async (name) => { + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name } }) + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.label).toBe('') + } + ) + + it('keeps a name the user actually chose', async () => { + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name: 'sim.ai' } }) + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.label).toBe('sim.ai') + }) + + it('reports no profiles when the browser is not installed', async () => { + await expect(listBrowserProfiles(ARC, home)).resolves.toEqual([]) + }) +}) + +describe('listAllBrowserProfiles', () => { + it('collects profiles from every installed browser', async () => { + await addProfile(CHROME, 'Default') + await addProfile(ARC, 'Default') + await addProfile(ARC, 'Profile 1') + await writeLocalState(ARC, { Default: { name: 'Personal' }, 'Profile 1': { name: 'Work' } }) + + const profiles = await listAllBrowserProfiles([CHROME, ARC], home) + + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default', 'arc:Default', 'arc:Profile 1']) + expect(profiles.map(({ source }) => source.label)).toEqual(['Chrome', 'Arc', 'Arc']) + }) + + it('keeps every profile distinct even though each browser has a Default', async () => { + // The namespaced id is what stops one browser's Default from resolving to + // another's. + await addProfile(CHROME, 'Default') + await addProfile(ARC, 'Default') + + const profiles = await listAllBrowserProfiles([CHROME, ARC], home) + expect(new Set(profiles.map(({ id }) => id)).size).toBe(2) + }) + + it('does not let one broken browser hide the others', async () => { + await addProfile(CHROME, 'Default') + const broken: BrowserSource = { + ...ARC, + // A path that cannot be enumerated stands in for a damaged install. + userDataSegments: ['\u0000invalid'], + } + + const profiles = await listAllBrowserProfiles([broken, CHROME], home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('reports nothing when no supported browser is installed', async () => { + await expect(listAllBrowserProfiles([CHROME, ARC], home)).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.ts b/apps/desktop/src/main/browser-import/chromium-profiles.ts new file mode 100644 index 0000000000..c955b1dd7d --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-profiles.ts @@ -0,0 +1,178 @@ +import { constants } from 'node:fs' +import { access, readdir, readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + BROWSER_SOURCES, + type BrowserSource, + formatProfileId, + userDataDirFor, +} from '@/main/browser-import/browser-sources' +import type { BrowserProfile } from '@/main/browser-import/types' + +/** + * Discovers profiles across the Chromium-family browsers on this device. + * + * Strictly read-only: a browser's own directory is never written to, and a + * profile is only offered when its database is actually readable, so the UI + * cannot advertise an import that is guaranteed to fail. + */ + +/** Chromium moved the cookie database under `Network/` in M96. */ +const COOKIE_DB_RELATIVE_PATHS = [join('Network', 'Cookies'), 'Cookies'] +/** Saved passwords stayed at the profile root across that move. */ +const LOGIN_DB_RELATIVE_PATHS = ['Login Data'] +/** Site icons, used to give saved passwords a recognisable face. */ +const FAVICON_DB_RELATIVE_PATHS = ['Favicons'] +/** Page titles, read only to learn what the imported sites are called. */ +const HISTORY_DB_RELATIVE_PATHS = ['History'] +/** Internal profiles that never hold a user's browsing session. */ +const IGNORED_PROFILE_DIRS = new Set(['System Profile', 'Guest Profile']) +const PROFILE_DIR_PATTERN = /^(Default|Profile \d{1,4})$/ +const MAX_PROFILES_PER_BROWSER = 32 + +/** + * Names a browser uses for its own machinery rather than for a person. Arc + * keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory, so the + * directory name alone does not reveal it — offering it as something to import + * would be meaningless at best. + */ +function isInternalProfileName(name: string): boolean { + return name.startsWith('__') +} + +/** + * Whether a profile's name is just Chromium's placeholder rather than + * something the user chose. `Your Chrome`, `Your Chromium`, and `Person 1` all + * mean "unnamed" — showing them as a profile's identity is noise, and shows + * the same string twice when two browsers are both unnamed. + */ +function isGenericProfileName(name: string, directory: string, browserLabel: string): boolean { + return ( + name === directory || + name === browserLabel || + /^Person \d+$/.test(name) || + /^Your [\w ]+$/.test(name) + ) +} + +async function isReadableFile(path: string): Promise { + try { + await access(path, constants.R_OK) + return true + } catch { + return false + } +} + +async function resolveFirstReadable( + profileDir: string, + relativePaths: readonly string[] +): Promise { + for (const relative of relativePaths) { + const candidate = join(profileDir, relative) + if (await isReadableFile(candidate)) return candidate + } + return null +} + +/** + * A browser's display names, keyed by profile directory. + * + * `Local State` is JSON this process does not own, so every key is checked + * against {@link PROFILE_DIR_PATTERN} before it is used in a path — that check + * is what keeps a crafted key from escaping the user-data directory. + */ +async function readProfileDisplayNames(userDataDir: string): Promise> { + const names = new Map() + try { + const raw = await readFile(join(userDataDir, 'Local State'), 'utf8') + const infoCache = (JSON.parse(raw) as { profile?: { info_cache?: unknown } }).profile + ?.info_cache + if (infoCache && typeof infoCache === 'object' && !Array.isArray(infoCache)) { + for (const [dir, info] of Object.entries(infoCache as Record)) { + if (!PROFILE_DIR_PATTERN.test(dir)) continue + const name = (info as { name?: unknown })?.name + names.set(dir, typeof name === 'string' && name.trim().length > 0 ? name.trim() : dir) + } + } + } catch { + // No or unreadable Local State: fall back to directory scanning below. + } + return names +} + +async function listProfileDirNames(userDataDir: string): Promise { + try { + const entries = await readdir(userDataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory() && PROFILE_DIR_PATTERN.test(entry.name)) + .map((entry) => entry.name) + } catch { + return [] + } +} + +/** `Default` first, then `Profile 2`, `Profile 10`, … in numeric order. */ +function compareProfileDirs(left: string, right: string): number { + if (left === right) return 0 + if (left === 'Default') return -1 + if (right === 'Default') return 1 + const index = (dir: string): number => Number(dir.slice('Profile '.length)) + return index(left) - index(right) +} + +/** + * Profiles belonging to one browser. Resolves to an empty list when that + * browser is not installed — absence is a normal state, not an error. + */ +export async function listBrowserProfiles( + source: BrowserSource, + home: string = homedir() +): Promise { + const userDataDir = userDataDirFor(source, home) + const displayNames = await readProfileDisplayNames(userDataDir) + const dirNames = new Set([...displayNames.keys(), ...(await listProfileDirNames(userDataDir))]) + + const profiles: BrowserProfile[] = [] + for (const dir of [...dirNames].sort(compareProfileDirs).slice(0, MAX_PROFILES_PER_BROWSER)) { + if (IGNORED_PROFILE_DIRS.has(dir) || !PROFILE_DIR_PATTERN.test(dir)) continue + const name = displayNames.get(dir) ?? dir + if (isInternalProfileName(name)) continue + const profileDir = join(userDataDir, dir) + const cookiesPath = await resolveFirstReadable(profileDir, COOKIE_DB_RELATIVE_PATHS) + const loginDataPath = await resolveFirstReadable(profileDir, LOGIN_DB_RELATIVE_PATHS) + const faviconsPath = await resolveFirstReadable(profileDir, FAVICON_DB_RELATIVE_PATHS) + const historyPath = await resolveFirstReadable(profileDir, HISTORY_DB_RELATIVE_PATHS) + if (cookiesPath === null && loginDataPath === null) continue + profiles.push({ + id: formatProfileId(source.id, dir), + directory: dir, + // Empty means "the user never named this one", which lets the caller + // fall back to the browser's name instead of printing a placeholder. + label: isGenericProfileName(name, dir, source.label) ? '' : name, + source, + cookiesPath, + loginDataPath, + faviconsPath, + historyPath, + }) + } + return profiles +} + +/** + * Every importable profile on this device, across every supported browser. + * + * One browser failing to enumerate must not hide the others — a broken Brave + * install should not cost the user their Chrome and Arc profiles. + */ +export async function listAllBrowserProfiles( + sources: readonly BrowserSource[] = BROWSER_SOURCES, + home: string = homedir() +): Promise { + const perBrowser = await Promise.all( + sources.map((source) => listBrowserProfiles(source, home).catch((): BrowserProfile[] => [])) + ) + return perBrowser.flat() +} diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.test.ts b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts new file mode 100644 index 0000000000..e31bfdd56b --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts @@ -0,0 +1,314 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + isCoveredByDomain, + MAX_IMPORTED_SITES, + readBrowserSites, +} from '@/main/browser-import/chromium-site-names' + +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +interface FixturePage { + url: string + /** Omitted for the rows Chromium stores with no title at all. */ + title?: string + visitCount?: number + /** Chromium's own flag for redirect hops nobody chose to open. */ + hidden?: boolean +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-site-names-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeHistoryDatabase(pages: FixturePage[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'History') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url LONGVARCHAR, + title LONGVARCHAR, + visit_count INTEGER DEFAULT 0 NOT NULL, + typed_count INTEGER DEFAULT 0 NOT NULL, + last_visit_time INTEGER NOT NULL, + hidden INTEGER DEFAULT 0 NOT NULL + ); + `) + const insert = database.prepare( + 'INSERT INTO urls (url, title, visit_count, last_visit_time, hidden) VALUES (?, ?, ?, 0, ?)' + ) + for (const page of pages) { + insert.run(page.url, page.title ?? null, page.visitCount ?? 1, page.hidden ? 1 : 0) + } + database.close() + return path +} + +function hostnames(sites: { hostname: string }[]): string[] { + return sites.map((site) => site.hostname) +} + +describe.skipIf(!sqliteAvailable)('readBrowserSites', () => { + it('learns a site’s name from the part of its titles that never changes', async () => { + const path = await writeHistoryDatabase([ + { + url: 'https://mail.google.com/mail/u/0/#inbox', + title: 'Inbox (12) - ada@example.com - Gmail', + }, + { url: 'https://mail.google.com/mail/u/0/#sent', title: 'Sent - ada@example.com - Gmail' }, + { + url: 'https://mail.google.com/mail/u/0/#drafts', + title: 'Drafts (2) - ada@example.com - Gmail', + }, + ]) + + const sites = await readBrowserSites(path, new Set(['mail.google.com'])) + + // Nothing here hardcodes Gmail — it is the only segment on every page. + expect(sites[0]?.name).toBe('Gmail') + }) + + it('finds a name that leads the title rather than trailing it', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://github.com/', title: 'GitHub - Where the world builds software' }, + { url: 'https://github.com/pulls', title: 'GitHub - Pull requests' }, + ]) + + const sites = await readBrowserSites(path, new Set(['github.com'])) + + expect(sites[0]?.name).toBe('GitHub') + }) + + it('handles a title with no separator at all', async () => { + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites[0]?.name).toBe('Example') + }) + + it('prefers the shorter candidate when pages are split evenly', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://linear.app/a', title: 'Linear - Issue tracking' }, + { url: 'https://linear.app/b', title: 'Issue tracking - Linear' }, + ]) + + const sites = await readBrowserSites(path, new Set(['linear.app'])) + + expect(sites[0]?.name).toBe('Linear') + }) + + it('rejects a whole-title tagline as a name while still offering the site', async () => { + const long = 'A very long marketing sentence that is plainly not what this site is called' + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: long }]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['example.com']) + expect(sites[0]?.name).toBeUndefined() + }) + + it('offers a host that is visited but has never carried a title', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://untitled.example.com/feed', visitCount: 7 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites).toEqual([{ hostname: 'untitled.example.com', name: undefined, visits: 7 }]) + }) + + it('ignores pages that are not on the web', async () => { + const path = await writeHistoryDatabase([ + { url: 'chrome-extension://abc/page.html', title: 'Extension' }, + { url: 'file:///Users/ada/notes.html', title: 'Notes' }, + ]) + + const sites = await readBrowserSites(path, new Set(['abc', ''])) + + expect(sites).toEqual([]) + }) + + it('imports the same list every time for the same profile', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://example.com/a', title: 'Alpha - Example' }, + { url: 'https://example.com/b', title: 'Beta - Sample' }, + ]) + + const first = await readBrowserSites(path, new Set(['example.com'])) + const second = await readBrowserSites(path, new Set(['example.com'])) + + expect(first).toEqual(second) + }) + + /** + * The regression this pins: the shared reader enables BigInt reads, so every + * SQLite integer arrives as `bigint` and a `typeof value === 'number'` guard + * scored every imported site zero, flattening the omnibox ordering. + */ + it('carries the source browser’s visit count across as a real number', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://example.com/', title: 'Example', visitCount: 4242 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites[0]?.visits).toBe(4242) + expect(typeof sites[0]?.visits).toBe('number') + }) + + it('ranks a site used across many pages above one reached by refreshing a single page', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://deep.example.com/dashboard', title: 'Deep', visitCount: 100 }, + { url: 'https://broad.example.com/a', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/b', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/c', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/d', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/e', title: 'Broad', visitCount: 30 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['broad.example.com', 'deep.example.com']) + expect(sites[0]?.visits).toBe(150) + }) + + it('orders the most-used first and settles ties alphabetically', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://zeta.example.com/', title: 'Zeta', visitCount: 10 }, + { url: 'https://alpha.example.com/', title: 'Alpha', visitCount: 10 }, + { url: 'https://middle.example.com/', title: 'Middle', visitCount: 50 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual([ + 'middle.example.com', + 'alpha.example.com', + 'zeta.example.com', + ]) + }) + + it('leaves out the redirect hops Chromium hides from its own omnibox', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://redirect.example.com/', title: 'Redirect', visitCount: 900, hidden: true }, + { url: 'https://real.example.com/', title: 'Real', visitCount: 3 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['real.example.com']) + }) + + it('admits a subdomain the imported apex domain covers', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://www.google.com/search?q=sim', title: 'sim - Google Search' }, + ]) + + // The cookie jar contributes `.google.com` with its leading dot stripped; + // the page the user actually opened is the subdomain. + const sites = await readBrowserSites(path, new Set(['google.com'])) + + expect(hostnames(sites)).toEqual(['www.google.com']) + }) + + it('imports only hosts the imported domains cover, never the rest of the history', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://mail.google.com/', title: 'Gmail' }, + { url: 'https://somewhere-private.example/', title: 'Private' }, + ]) + + const sites = await readBrowserSites(path, new Set(['google.com'])) + + expect(hostnames(sites)).toEqual(['mail.google.com']) + }) + + it('drops the profile’s most-visited host when nothing imported covers it', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://uncovered.example.org/', title: 'Uncovered', visitCount: 5000 }, + { url: 'https://docs.example.com/', title: 'Docs', visitCount: 2 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['docs.example.com']) + }) + + it('contributes no more hosts than one import may, keeping the most-used', async () => { + const overflow = MAX_IMPORTED_SITES + 50 + const pages = Array.from({ length: overflow }, (_, index) => ({ + url: `https://site-${String(index).padStart(3, '0')}.example.com/`, + title: `Site ${index}`, + visitCount: overflow - index, + })) + const path = await writeHistoryDatabase(pages) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites).toHaveLength(MAX_IMPORTED_SITES) + expect(sites[0]?.hostname).toBe('site-000.example.com') + expect(sites.at(-1)?.hostname).toBe( + `site-${String(MAX_IMPORTED_SITES - 1).padStart(3, '0')}.example.com` + ) + }) + + it('survives an unreadable history rather than failing the import', async () => { + const sites = await readBrowserSites(join(directory, 'absent'), new Set(['example.com'])) + + expect(sites).toEqual([]) + }) + + it('asks for nothing when no domain was imported to cover it', async () => { + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }]) + + expect(await readBrowserSites(path, new Set())).toEqual([]) + }) +}) + +describe('isCoveredByDomain', () => { + it('covers a host that is itself an imported domain', () => { + expect(isCoveredByDomain('example.com', new Set(['example.com']))).toBe(true) + }) + + it('covers a subdomain of an imported domain, however deep', () => { + const domains = new Set(['example.com']) + + expect(isCoveredByDomain('mail.example.com', domains)).toBe(true) + expect(isCoveredByDomain('a.b.c.example.com', domains)).toBe(true) + }) + + it('does not cover an apex whose subdomain is all that was imported', () => { + expect(isCoveredByDomain('example.com', new Set(['mail.example.com']))).toBe(false) + }) + + it('matches on label boundaries, not bare string suffixes', () => { + expect(isCoveredByDomain('notexample.com', new Set(['example.com']))).toBe(false) + }) + + it('covers nothing when no domain was imported', () => { + expect(isCoveredByDomain('example.com', new Set())).toBe(false) + }) + + /** + * The documented boundary: coverage is a pure label walk with no public + * suffix list, so a cookie on a registry suffix such as `github.io` admits + * every user site under it. Should a PSL guard ever be added, this test is + * meant to fail loudly rather than let the change land unnoticed. + */ + it('lets a public-suffix domain cover the sites beneath it', () => { + expect(isCoveredByDomain('alice.github.io', new Set(['github.io']))).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.ts b/apps/desktop/src/main/browser-import/chromium-site-names.ts new file mode 100644 index 0000000000..2485e6e907 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-site-names.ts @@ -0,0 +1,205 @@ +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Reads which sites a Chromium profile's owner actually uses, out of its + * `History` database. + * + * This is the seed for the omnibox, and history is the only honest source for + * it. The obvious alternative — the cookie jar — is not a list of sites anyone + * visits: it is every origin that ever set state, which is dominated by ad + * networks, analytics, and embedded widgets the user never navigated to. Rows + * in `urls` are pages someone opened, so trackers are structurally absent. + * + * Cookies still get a say, as a filter: only hosts covered by a cookie the + * import brought over come back, so what is remembered stays within the data + * the user chose to bring. Sim's browser records no history of its own, and + * none is reconstructed here — no visit times, no URLs beyond the host, no + * sequence. What survives is a host, what its own titles call it, and how much + * it is used relative to the others. + */ + +/** + * Rows scanned from one profile. Set well past the size of a real history — + * Chrome expires visits at 90 days — so the cut is a guard against a pathological + * file rather than something a normal profile meets. + * + * Ordering by `visit_count` before the cut matters: per-host counts are summed, + * so truncating has to drop the rows that contribute least to any sum. A profile + * large enough to hit this still ranks on its most-visited pages. + */ +const MAX_ROWS = 200_000 +/** Longer than this is a page title, not what the site is called. */ +const MAX_NAME_LENGTH = 40 +/** Hosts one import may contribute, most-used first. Bounds the favicon lookup too. */ +export const MAX_IMPORTED_SITES = 200 + +/** + * `hidden` marks rows Chromium itself keeps out of autocomplete — redirect + * hops and other URLs nobody chose to open. Excluding them is the same + * predicate Chrome's own omnibox uses. + */ +const SITE_QUERY = ` + SELECT url, title, visit_count + FROM urls + WHERE hidden = 0 + ORDER BY visit_count DESC + LIMIT ${MAX_ROWS} +` + +/** Separators sites put between the page and their own name. */ +const TITLE_SEPARATOR = /\s+[|·•‧–—]\s+|\s+-\s+|\s+:\s+/ + +/** A host the source browser's owner visited, and what it is called there. */ +export interface ImportedSite { + hostname: string + name?: string + /** + * The source browser's own visit count. An aggregate popularity signal used + * to order suggestions — never a timestamp, a URL, or a sequence of visits. + */ + visits: number +} + +function hostnameOf(url: string): string | null { + try { + const { hostname, protocol } = new URL(url) + // Extension and file pages are not sites the omnibox can offer. + if (protocol !== 'https:' && protocol !== 'http:') return null + return hostname || null + } catch { + return null + } +} + +/** + * `visit_count` for one row. Goes through {@link toNumber} because the shared + * reader enables BigInt reads, so every SQLite integer arrives as `bigint` — a + * bare `typeof value === 'number'` guard silently scores every site zero. + */ +function visitsOf(value: unknown): number { + const count = toNumber(value) + return count > 0 ? Math.floor(count) : 0 +} + +/** + * Whether a cookie covers this host. + * + * Cookie hosts arrive with the domain-cookie dot already stripped, so + * `.google.com` reaches here as `google.com` while the page the user opened is + * `www.google.com` or `mail.google.com`. Matching on equality alone would miss + * every site not served from its apex — which is most of them — so each of the + * host's parent suffixes is tried. Walking labels keeps this O(labels) per row + * rather than scanning the whole cookie set. + */ +export function isCoveredByDomain(hostname: string, domains: ReadonlySet): boolean { + if (domains.has(hostname)) return true + let dot = hostname.indexOf('.') + while (dot !== -1) { + if (domains.has(hostname.slice(dot + 1))) return true + dot = hostname.indexOf('.', dot + 1) + } + return false +} + +/** + * The parts of a page title that could be the site's name. + * + * A title is typically the page, then the site: "Inbox (12) - Gmail". Which + * end holds the name is not consistent enough to pick by position — "GitHub - + * Where software is built" puts it first — so every segment is a candidate and + * frequency decides between them. + */ +function nameCandidates(title: string): string[] { + return title + .split(TITLE_SEPARATOR) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment.length <= MAX_NAME_LENGTH) +} + +/** + * The site's name is the part of its titles that does not change: every Gmail + * page ends in "Gmail" while the rest of each title differs, so the segment + * appearing across the most distinct pages of a host is its name. Ties go to + * the shorter candidate, which prefers "GitHub" over a tagline, and then to + * alphabetical order so the same profile always imports the same name. + */ +function bestName(candidates: Map>): string | undefined { + let best: { name: string; pages: number } | null = null + for (const [candidate, seenIn] of candidates) { + const pages = seenIn.size + if ( + !best || + pages > best.pages || + (pages === best.pages && + (candidate.length < best.name.length || + (candidate.length === best.name.length && candidate < best.name))) + ) { + best = { name: candidate, pages } + } + } + return best?.name +} + +/** + * The most-used hosts in this profile's history that `domains` covers, keyed by + * the host actually visited — the same string the omnibox and the favicon store + * use, so lookups downstream cannot miss. + * + * Never throws. A profile with no readable history contributes no sites, and + * the caller carries on with whatever the rest of the import found. + */ +export async function readBrowserSites( + historyPath: string, + domains: ReadonlySet +): Promise { + if (domains.size === 0) return [] + + let rows: Record[] + try { + rows = await queryBrowserDatabase(historyPath, 'History', SITE_QUERY) + } catch { + // History is a nicety layered on the cookies that were already imported. + // Chrome holding a lock on it must not fail the import. + return [] + } + + /** host -> candidate name -> the distinct page titles that produced it. */ + const tally = new Map>>() + const visits = new Map() + + for (const row of rows) { + const hostname = hostnameOf(toText(row.url)) + if (hostname === null || !isCoveredByDomain(hostname, domains)) continue + + // Summed across the host's pages, not maxed: how much a site is used is + // every page opened there, so a site visited broadly must not lose to one + // visited only through a single much-refreshed page. + visits.set(hostname, (visits.get(hostname) ?? 0) + visitsOf(row.visit_count)) + + const title = toText(row.title) + if (title === '') continue + const candidates = tally.get(hostname) ?? new Map>() + for (const candidate of nameCandidates(title)) { + const seenIn = candidates.get(candidate) ?? new Set() + seenIn.add(title) + candidates.set(candidate, seenIn) + } + tally.set(hostname, candidates) + } + + const sites: ImportedSite[] = [] + for (const [hostname, count] of visits) { + const candidates = tally.get(hostname) + sites.push({ + hostname, + name: candidates ? bestName(candidates) : undefined, + visits: count, + }) + } + + // Most-used first, then alphabetical so the same profile always imports the + // same list, and only as many as the directory is willing to carry. + sites.sort((a, b) => b.visits - a.visits || a.hostname.localeCompare(b.hostname)) + return sites.slice(0, MAX_IMPORTED_SITES) +} diff --git a/apps/desktop/src/main/browser-import/cookie-translate.test.ts b/apps/desktop/src/main/browser-import/cookie-translate.test.ts new file mode 100644 index 0000000000..7b6ee7b08c --- /dev/null +++ b/apps/desktop/src/main/browser-import/cookie-translate.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { chromeTimeToUnixSeconds, translateCookieRow } from '@/main/browser-import/cookie-translate' +import type { ChromiumCookieRow } from '@/main/browser-import/types' + +const NOW_SECONDS = 1_800_000_000 + +/** Chrome's microsecond-since-1601 encoding of a Unix timestamp. */ +function chromeTime(unixSeconds: number): number { + return (unixSeconds + 11_644_473_600) * 1_000_000 +} + +function row(overrides: Partial = {}): ChromiumCookieRow { + return { + hostKey: 'www.example.com', + name: 'session', + path: '/', + expiresUtc: chromeTime(NOW_SECONDS + 3600), + isSecure: true, + isHttpOnly: true, + hasExpires: true, + isPersistent: true, + sameSite: 1, + ...overrides, + } +} + +describe('chromeTimeToUnixSeconds', () => { + it('converts from the 1601 epoch', () => { + expect(chromeTimeToUnixSeconds(chromeTime(1_700_000_000))).toBe(1_700_000_000) + }) +}) + +describe('translateCookieRow', () => { + it('preserves the security attributes of a host-only cookie', () => { + const outcome = translateCookieRow(row(), 'abc', NOW_SECONDS) + + expect(outcome).toEqual({ + ok: true, + cookie: { + url: 'https://www.example.com/', + name: 'session', + value: 'abc', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: NOW_SECONDS + 3600, + }, + }) + }) + + it('omits domain for host-only cookies so they are not widened to subdomains', () => { + const outcome = translateCookieRow(row({ hostKey: 'www.example.com' }), 'abc', NOW_SECONDS) + expect(outcome.ok && 'domain' in outcome.cookie).toBe(false) + }) + + it('carries the leading dot through for domain cookies', () => { + const outcome = translateCookieRow(row({ hostKey: '.example.com' }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.domain).toBe('.example.com') + expect(outcome.ok && outcome.cookie.url).toBe('https://example.com/') + }) + + it('uses an http target for cookies that are not Secure', () => { + const outcome = translateCookieRow(row({ isSecure: false }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.url).toBe('http://www.example.com/') + expect(outcome.ok && outcome.cookie.secure).toBe(false) + }) + + it.each([ + [-1, 'unspecified'], + [0, 'no_restriction'], + [1, 'lax'], + [2, 'strict'], + [99, 'unspecified'], + ])('maps Chrome samesite %i to %s', (sameSite, expected) => { + const outcome = translateCookieRow(row({ sameSite }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.sameSite).toBe(expected) + }) + + it('drops expired cookies', () => { + const outcome = translateCookieRow( + row({ expiresUtc: chromeTime(NOW_SECONDS - 1) }), + 'abc', + NOW_SECONDS + ) + expect(outcome).toEqual({ ok: false, reason: 'expired' }) + }) + + it('keeps session cookies without an expiry', () => { + const outcome = translateCookieRow( + row({ hasExpires: false, isPersistent: false }), + 'abc', + NOW_SECONDS + ) + expect(outcome.ok && 'expirationDate' in outcome.cookie).toBe(false) + }) + + it('normalizes a path that is missing its leading slash', () => { + const outcome = translateCookieRow(row({ path: 'account' }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.path).toBe('/account') + expect(outcome.ok && outcome.cookie.url).toBe('https://www.example.com/account') + }) + + it.each([ + ['evil.com/path@good.com'], + ['good.com:8443'], + ['user:pass@good.com'], + [''], + ['.'], + ['has space.com'], + ])('refuses a host_key that cannot be trusted to name one host: %s', (hostKey) => { + expect(translateCookieRow(row({ hostKey }), 'abc', NOW_SECONDS)).toEqual({ + ok: false, + reason: 'invalid-target', + }) + }) + + it('drops a row with neither a name nor a value', () => { + expect(translateCookieRow(row({ name: '' }), '', NOW_SECONDS)).toEqual({ + ok: false, + reason: 'empty', + }) + }) +}) diff --git a/apps/desktop/src/main/browser-import/cookie-translate.ts b/apps/desktop/src/main/browser-import/cookie-translate.ts new file mode 100644 index 0000000000..91dbdf47cf --- /dev/null +++ b/apps/desktop/src/main/browser-import/cookie-translate.ts @@ -0,0 +1,109 @@ +import type { + ChromiumCookieRow, + CookieSkipReason, + ImportableCookie, +} from '@/main/browser-import/types' + +/** + * Translates Chrome cookie rows into the shape Electron's cookie API accepts. + * + * Two rules govern everything here. Security attributes are preserved exactly + * — `Secure`, `HttpOnly`, `SameSite`, host-only versus domain scope, and path + * are carried across unchanged, and a cookie is dropped rather than imported + * under weaker terms. And the destination is derived, never trusted: a corrupt + * or hostile `host_key` must not be able to steer a cookie at a host other + * than the one it came from. + */ + +/** Chrome stores timestamps as microseconds since 1601-01-01 UTC. */ +const WINDOWS_EPOCH_OFFSET_SECONDS = 11_644_473_600 +const MICROSECONDS_PER_SECOND = 1_000_000 + +export function chromeTimeToUnixSeconds(chromeTime: number): number { + return chromeTime / MICROSECONDS_PER_SECOND - WINDOWS_EPOCH_OFFSET_SECONDS +} + +/** Chrome's `samesite` column: -1 unspecified, 0 None, 1 Lax, 2 Strict. */ +function toSameSite(value: number): ImportableCookie['sameSite'] { + switch (value) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +/** + * Builds the URL the cookie is written against, or null when the row cannot + * be trusted to describe one host. + * + * `host_key` comes from a database this process does not own, so the parsed + * URL is checked back against it: anything that reparses to a different host, + * or that smuggles in credentials, a port, or extra path structure, is + * refused instead of being normalised into something plausible. + */ +export function buildCookieUrl(hostKey: string, path: string, secure: boolean): string | null { + const bareHost = hostKey.replace(/^\./, '').toLowerCase() + if (bareHost.length === 0 || bareHost.length > 253) return null + const normalizedPath = path.startsWith('/') ? path : `/${path}` + try { + const url = new URL(`${secure ? 'https' : 'http'}://${bareHost}${normalizedPath}`) + if (url.hostname !== bareHost || url.username || url.password || url.port) return null + return url.toString() + } catch { + return null + } +} + +export type TranslationOutcome = + | { ok: true; cookie: ImportableCookie } + | { ok: false; reason: CookieSkipReason } + +/** + * Converts one decrypted row, or explains why it was dropped. + * + * `nowSeconds` is injected so expiry is evaluated against a single instant for + * the whole import rather than drifting row to row. + */ +export function translateCookieRow( + row: ChromiumCookieRow, + value: string, + nowSeconds: number +): TranslationOutcome { + if (row.name.length === 0 && value.length === 0) { + return { ok: false, reason: 'empty' } + } + + const url = buildCookieUrl(row.hostKey, row.path, row.isSecure) + if (url === null) { + return { ok: false, reason: 'invalid-target' } + } + + const cookie: ImportableCookie = { + url, + name: row.name, + value, + // A leading dot is Chrome's marker for a domain cookie. Host-only cookies + // omit `domain` entirely so Electron scopes them to the URL's host — + // sending the bare host instead would widen them to every subdomain. + ...(row.hostKey.startsWith('.') ? { domain: row.hostKey } : {}), + path: row.path.startsWith('/') ? row.path : `/${row.path}`, + secure: row.isSecure, + httpOnly: row.isHttpOnly, + sameSite: toSameSite(row.sameSite), + } + + if (row.hasExpires && row.isPersistent) { + const expirationDate = chromeTimeToUnixSeconds(row.expiresUtc) + if (!Number.isFinite(expirationDate) || expirationDate <= nowSeconds) { + return { ok: false, reason: 'expired' } + } + cookie.expirationDate = expirationDate + } + + return { ok: true, cookie } +} diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts new file mode 100644 index 0000000000..2c66c825f8 --- /dev/null +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -0,0 +1,928 @@ +import { describe, expect, it, vi } from 'vitest' +import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources' +import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies' +import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords' +import type { ImportedSite } from '@/main/browser-import/chromium-site-names' +import { + type ImportServiceDeps, + importChromeCookies, + importChromeData, + importChromePasswords, + listImportableProfiles, + toDisplayProfiles, +} from '@/main/browser-import/import-service' +import { + type BrowserProfile, + emptySkipCounts, + type ImportableCookie, + ImportFailure, +} from '@/main/browser-import/types' + +const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource +const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource +const DIA = BROWSER_SOURCES.find(({ id }) => id === 'dia') as BrowserSource + +const PROFILES: BrowserProfile[] = [ + { + id: 'chrome:Default', + directory: 'Default', + label: 'Person 1', + source: CHROME, + cookiesPath: '/chrome/Default/Cookies', + loginDataPath: '/chrome/Default/Login Data', + faviconsPath: '/chrome/Default/Favicons', + historyPath: '/chrome/Default/History', + }, + { + id: 'arc:Profile 2', + directory: 'Profile 2', + label: 'Work', + source: ARC, + cookiesPath: '/arc/Profile 2/Cookies', + loginDataPath: '/arc/Profile 2/Login Data', + faviconsPath: '/arc/Profile 2/Favicons', + historyPath: '/arc/Profile 2/History', + }, +] + +function cookie(name = 'session'): ImportableCookie { + return { + url: 'https://example.com/', + name, + value: 'value', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + } +} + +function read(overrides: Partial = {}): ReadCookiesResult { + return { cookies: [cookie()], skipped: emptySkipCounts(), rowsSeen: 1, ...overrides } +} + +function readPasswords(overrides: Partial = {}): ReadPasswordsResult { + return { + credentials: [{ origin: 'https://example.com', username: 'ada', password: 'hunter2' }], + skipped: 0, + rowsSeen: 1, + ...overrides, + } +} + +function createDeps(overrides: Partial = {}): ImportServiceDeps { + return { + platform: 'darwin', + listProfiles: async () => PROFILES, + readSafeStoragePassword: async () => 'safe-storage-password', + readCookies: async () => read(), + writeCookies: async (cookies) => ({ imported: cookies.length, failed: 0 }), + readPasswords: async () => readPasswords(), + readFavicons: async () => new Map(), + readSites: async () => [], + rememberSites: async () => {}, + vault: { + isAvailable: () => true, + importCredentials: async (candidates) => ({ + added: candidates.length, + updated: 0, + skipped: 0, + }), + }, + ...overrides, + } +} + +describe('toDisplayProfiles', () => { + function profile(source: BrowserSource, directory: string, label: string): BrowserProfile { + return { + id: `${source.id}:${directory}`, + directory, + label, + source, + cookiesPath: '/cookies', + loginDataPath: null, + faviconsPath: null, + historyPath: null, + } + } + + it('leads with the browser, which is the identity that matters', () => { + // Regression: labelling by profile name alone produced a list like + // "Your Chrome / sim.ai / Your Chromium / Microtrades" with no way to tell + // which browser any of them belonged to. + const labels = toDisplayProfiles([ + profile(CHROME, 'Default', ''), + profile(CHROME, 'Profile 1', 'sim.ai'), + profile(ARC, 'Default', ''), + profile(ARC, 'Profile 2', 'Microtrades'), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Chrome', 'Chrome · sim.ai', 'Arc', 'Arc · Microtrades']) + }) + + it('uses the browser alone when the profile was never named', () => { + expect(toDisplayProfiles([profile(CHROME, 'Default', '')])[0].label).toBe('Chrome') + }) + + it('keeps two unnamed profiles of one browser distinguishable', () => { + const labels = toDisplayProfiles([ + profile(CHROME, 'Default', ''), + profile(CHROME, 'Profile 1', ''), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Chrome · Default', 'Chrome · Profile 1']) + expect(new Set(labels).size).toBe(2) + }) + + it('does not disambiguate across browsers, which are already distinct', () => { + const labels = toDisplayProfiles([ + profile(ARC, 'Default', ''), + profile(DIA, 'Default', ''), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Arc', 'Dia']) + }) + + it('names an unnamed profile by its directory in a profile-only picker', () => { + // The browser dropdown already says "Chrome", so the profile dropdown + // beside it needs something to show rather than a blank row. + expect( + toDisplayProfiles([profile(CHROME, 'Default', '')]).map(({ profileLabel }) => profileLabel) + ).toEqual(['Default']) + + expect( + toDisplayProfiles([profile(CHROME, 'Profile 1', 'sim.ai')]).map( + ({ profileLabel }) => profileLabel + ) + ).toEqual(['sim.ai']) + }) +}) + +describe('listImportableProfiles', () => { + it('exposes ids, labels, and the owning browser — never a profile path', async () => { + await expect(listImportableProfiles(createDeps())).resolves.toEqual([ + { + id: 'chrome:Default', + label: 'Chrome · Person 1', + browserId: 'chrome', + browserLabel: 'Chrome', + profileLabel: 'Person 1', + }, + { + id: 'arc:Profile 2', + label: 'Arc · Work', + browserId: 'arc', + browserLabel: 'Arc', + profileLabel: 'Work', + }, + ]) + }) + + it('is empty off macOS without consulting the disk', async () => { + const listProfiles = vi.fn() + await expect( + listImportableProfiles(createDeps({ platform: 'win32', listProfiles })) + ).resolves.toEqual([]) + expect(listProfiles).not.toHaveBeenCalled() + }) + + it('reports no profiles rather than throwing when discovery fails', async () => { + const listProfiles = async () => { + throw new Error('unreadable') + } + await expect(listImportableProfiles(createDeps({ listProfiles }))).resolves.toEqual([]) + }) +}) + +describe('importChromeCookies', () => { + it('imports the requested profile and reports counts', async () => { + const readCookies = vi.fn(async () => read({ cookies: [cookie('a'), cookie('b')] })) + const result = await importChromeCookies('arc:Profile 2', createDeps({ readCookies })) + + expect(result).toEqual({ cookiesImported: 2, cookiesSkipped: 0 }) + expect(readCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer)) + }) + + it('defaults to the first profile when none is named', async () => { + const readCookies = vi.fn(async () => read()) + await importChromeCookies(undefined, createDeps({ readCookies })) + expect(readCookies).toHaveBeenCalledWith('/chrome/Default/Cookies', expect.any(Buffer)) + }) + + it('refuses an unknown profile instead of falling back to the default', async () => { + const readCookies = vi.fn(async () => read()) + const result = await importChromeCookies('../../elsewhere', createDeps({ readCookies })) + + expect(result.error).toBe('chrome-not-found') + expect(readCookies).not.toHaveBeenCalled() + }) + + it('counts cookies the browser profile rejected as skipped', async () => { + const deps = createDeps({ + readCookies: async () => + read({ + cookies: [cookie('a'), cookie('b'), cookie('c')], + skipped: { ...emptySkipCounts(), expired: 4 }, + }), + writeCookies: async () => ({ imported: 2, failed: 1 }), + }) + + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 2, + cookiesSkipped: 5, + }) + }) + + it('zeroes the derived key once the rows are decrypted', async () => { + let observed: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + observed = key + expect(key.some((byte) => byte !== 0)).toBe(true) + return read() + }, + }) + + await importChromeCookies(undefined, deps) + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it('zeroes the derived key even when reading throws', async () => { + let observed: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + observed = key + throw new ImportFailure('profile-unreadable', 'locked') + }, + }) + + await importChromeCookies(undefined, deps) + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it.each([ + ['unsupported-platform', createDeps({ platform: 'win32' })], + ['chrome-not-found', createDeps({ listProfiles: async () => [] })], + ['keychain-unavailable', createDeps({ readSafeStoragePassword: async () => null })], + ] as const)('fails closed with %s', async (error, deps) => { + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error, + }) + }) + + it('never reaches the Keychain on an unsupported platform', async () => { + const readSafeStoragePassword = vi.fn() + await importChromeCookies(undefined, createDeps({ platform: 'linux', readSafeStoragePassword })) + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('surfaces a reader failure as its own category', async () => { + const deps = createDeps({ + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown table shape') + }, + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unsupported-schema', + }) + }) + + it('reports rows that all failed to decrypt as an import failure', async () => { + const deps = createDeps({ + readCookies: async () => + read({ cookies: [], skipped: { ...emptySkipCounts(), 'decrypt-failed': 9 }, rowsSeen: 9 }), + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 9, + error: 'nothing-imported', + }) + }) + + it('treats a genuinely empty profile as a success', async () => { + const deps = createDeps({ readCookies: async () => read({ cookies: [], rowsSeen: 0 }) }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + }) + }) + + it('does not leak an unexpected error to the caller', async () => { + const deps = createDeps({ + writeCookies: async () => { + throw new Error('/Users/someone/Library/Application Support/Google/Chrome exploded') + }, + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + }) + + it('reports a profile with no cookie database rather than reading another one', async () => { + const deps = createDeps({ + listProfiles: async () => [ + { + id: 'chrome:Default', + directory: 'Default', + label: 'Person 1', + source: CHROME, + cookiesPath: null, + loginDataPath: '/chrome/Login Data', + faviconsPath: null, + historyPath: null, + }, + ], + }) + await expect(importChromeCookies(undefined, deps)).resolves.toMatchObject({ + error: 'profile-unreadable', + }) + }) +}) + +describe('importChromePasswords', () => { + it('stores decrypted passwords in the vault and reports counts', async () => { + const importCredentials = vi.fn(async () => ({ added: 2, updated: 1, skipped: 0 })) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [ + { origin: 'https://example.com', username: 'ada', password: 'a' }, + { origin: 'https://other.test', username: 'grace', password: 'b' }, + ], + skipped: 3, + rowsSeen: 5, + }), + vault: { isAvailable: () => true, importCredentials }, + }) + + await expect(importChromePasswords('arc:Profile 2', 'replace', deps)).resolves.toEqual({ + passwordsAdded: 2, + passwordsUpdated: 1, + passwordsSkipped: 3, + }) + expect(importCredentials).toHaveBeenCalledWith(expect.any(Array), 'replace') + }) + + it('attaches each site\u2019s icon from the same profile', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const readFavicons = vi.fn( + async () => new Map([['https://example.com', 'data:image/png;base64,AA']]) + ) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [{ origin: 'https://example.com/login', username: 'ada', password: 'p' }], + }), + readFavicons, + vault: { isAvailable: () => true, importCredentials }, + }) + + await importChromePasswords('chrome:Default', 'keep-existing', deps) + + // Only the origins being imported are looked up, never the whole history. + expect(readFavicons).toHaveBeenCalledWith( + '/chrome/Default/Favicons', + new Set(['https://example.com']) + ) + expect(importCredentials).toHaveBeenCalledWith( + [expect.objectContaining({ icon: 'data:image/png;base64,AA' })], + 'keep-existing' + ) + }) + + it('imports without icons when the profile has no favicon store', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const readFavicons = vi.fn() + const deps = createDeps({ + listProfiles: async () => [{ ...PROFILES[0], faviconsPath: null, historyPath: null }], + readFavicons, + vault: { isAvailable: () => true, importCredentials }, + }) + + await importChromePasswords(undefined, 'keep-existing', deps) + + expect(readFavicons).not.toHaveBeenCalled() + expect(importCredentials).toHaveBeenCalledWith( + [expect.not.objectContaining({ icon: expect.anything() })], + 'keep-existing' + ) + }) + + it('still imports passwords when the favicon store will not read', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const deps = createDeps({ + readFavicons: async () => { + throw new Error('corrupt') + }, + vault: { isAvailable: () => true, importCredentials }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toMatchObject({ + passwordsAdded: 1, + }) + }) + + it('reads the requested profile\u2019s password database', async () => { + const readPasswordsSpy = vi.fn(async () => readPasswords()) + await importChromePasswords( + 'arc:Profile 2', + 'keep-existing', + createDeps({ readPasswords: readPasswordsSpy }) + ) + + expect(readPasswordsSpy).toHaveBeenCalledWith('/arc/Profile 2/Login Data', expect.any(Buffer)) + }) + + it('uses the chosen browser\u2019s own Keychain item', async () => { + // Reading Chrome's item to import Arc would prompt the user about the + // wrong browser — and would derive a key that cannot decrypt anything. + const readSafeStoragePassword = vi.fn(async () => 'password') + await importChromePasswords( + 'arc:Profile 2', + 'keep-existing', + createDeps({ readSafeStoragePassword }) + ) + + expect(readSafeStoragePassword).toHaveBeenCalledWith({ + service: 'Arc Safe Storage', + account: 'Arc', + }) + }) + + it('refuses an unknown profile rather than falling back to the default', async () => { + const readPasswordsSpy = vi.fn(async () => readPasswords()) + const result = await importChromePasswords( + '../../elsewhere', + 'keep-existing', + createDeps({ readPasswords: readPasswordsSpy }) + ) + + expect(result.error).toBe('chrome-not-found') + expect(readPasswordsSpy).not.toHaveBeenCalled() + }) + + it('will not run without secure storage, and never reaches the Keychain', async () => { + // There is no plaintext fallback for passwords: an unavailable vault ends + // the import instead of degrading it. + const readSafeStoragePassword = vi.fn() + const deps = createDeps({ + readSafeStoragePassword, + vault: { isAvailable: () => false, importCredentials: vi.fn() }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'vault-unavailable', + }) + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('zeroes the derived key after reading, including on failure', async () => { + let observed: Buffer | undefined + await importChromePasswords( + undefined, + 'keep-existing', + createDeps({ + readPasswords: async (_path, key) => { + observed = key + throw new ImportFailure('unsupported-schema', 'unknown logins table') + }, + }) + ) + + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it('reports rows that all failed to decrypt as an import failure', async () => { + const deps = createDeps({ + readPasswords: async () => readPasswords({ credentials: [], skipped: 7, rowsSeen: 7 }), + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 7, + error: 'nothing-imported', + }) + }) + + it('treats a profile with no saved passwords as a success', async () => { + const deps = createDeps({ + readPasswords: async () => readPasswords({ credentials: [], skipped: 0, rowsSeen: 0 }), + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + }) + }) + + it.each([ + ['unsupported-platform', { platform: 'win32' as const }], + ['chrome-not-found', { listProfiles: async () => [] }], + ['keychain-unavailable', { readSafeStoragePassword: async () => null }], + ])('fails closed with %s', async (error, overrides) => { + await expect( + importChromePasswords(undefined, 'keep-existing', createDeps(overrides)) + ).resolves.toMatchObject({ error }) + }) + + it('does not leak an unexpected error to the caller', async () => { + const deps = createDeps({ + vault: { + isAvailable: () => true, + importCredentials: async () => { + throw new Error('/Users/someone/Library/.../Login Data exploded') + }, + }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }) + }) +}) + +describe('importChromeData', () => { + const COOKIES_IMPORTED = { cookiesImported: 1, cookiesSkipped: 0 } + const PASSWORDS_IMPORTED = { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 } + + /** Runs a combined import while holding on to the key the halves were handed. */ + async function importObservingKey(overrides: Partial = {}) { + const base = createDeps(overrides) + let observed: Buffer | undefined + const result = await importChromeData(undefined, 'keep-existing', { + ...base, + readCookies: (path, key) => { + observed = key + return base.readCookies(path, key) + }, + readPasswords: (path, key) => { + observed = key + return base.readPasswords(path, key) + }, + }) + return { result, observed } + } + + it('brings over both halves in one action', async () => { + await expect(importChromeData('arc:Profile 2', 'keep-existing', createDeps())).resolves.toEqual( + { cookies: COOKIES_IMPORTED, passwords: PASSWORDS_IMPORTED } + ) + }) + + it('refuses both halves off macOS without consulting the disk', async () => { + const listProfiles = vi.fn() + const readSafeStoragePassword = vi.fn() + + await expect( + importChromeData( + undefined, + 'keep-existing', + createDeps({ platform: 'win32', listProfiles, readSafeStoragePassword }) + ) + ).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-platform' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unsupported-platform', + }, + }) + expect(listProfiles).not.toHaveBeenCalled() + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('prompts for the Keychain once, not once per half', async () => { + // Regression: running the two exported halves in sequence read the Safe + // Storage item twice, so a user who answered the first prompt with "Allow" + // rather than "Always Allow" got a second prompt mid-import — one arriving + // without a user gesture behind it, which silently fails the password half. + const listProfiles = vi.fn(async () => PROFILES) + const readSafeStoragePassword = vi.fn(async () => 'safe-storage-password') + + await importChromeData( + 'chrome:Default', + 'keep-existing', + createDeps({ listProfiles, readSafeStoragePassword }) + ) + + expect(readSafeStoragePassword).toHaveBeenCalledTimes(1) + expect(listProfiles).toHaveBeenCalledTimes(1) + }) + + it('hands both halves the one key that open derived', async () => { + let cookieKey: Buffer | undefined + let passwordKey: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + cookieKey = key + return read() + }, + readPasswords: async (_path, key) => { + passwordKey = key + // The cookie half must not wipe the key out from under this one. + expect(key.some((byte) => byte !== 0)).toBe(true) + return readPasswords() + }, + }) + + await importChromeData(undefined, 'keep-existing', deps) + + expect(passwordKey).toBe(cookieKey) + }) + + it('reports the same reason for both halves when the profile will not open', async () => { + await expect( + importChromeData('../../elsewhere', 'keep-existing', createDeps()) + ).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'chrome-not-found' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'chrome-not-found', + }, + }) + }) + + it('still imports passwords when the cookie half throws', async () => { + const deps = createDeps({ + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown cookies table') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-schema' }, + passwords: PASSWORDS_IMPORTED, + }) + }) + + it('still reports the imported cookies when the password half throws', async () => { + const deps = createDeps({ + readPasswords: async () => { + throw new ImportFailure('unsupported-schema', 'unknown logins table') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: COOKIES_IMPORTED, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unsupported-schema', + }, + }) + }) + + it('imports cookies even though secure storage cannot take the passwords', async () => { + // There is no plaintext fallback for passwords, but cookies do not need + // the vault — so an unavailable one must not cost the user both halves. + const importCredentials = vi.fn() + const deps = createDeps({ vault: { isAvailable: () => false, importCredentials } }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: COOKIES_IMPORTED, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'vault-unavailable', + }, + }) + expect(importCredentials).not.toHaveBeenCalled() + }) + + it('filters history by the cookie hosts and the password origins together', async () => { + // Either half alone is a partial picture of what the user brought over. + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readCookies: async () => + read({ cookies: [{ ...cookie(), url: 'https://news.example.com/' }] }), + readPasswords: async () => + readPasswords({ + credentials: [{ origin: 'https://accounts.other.test', username: 'ada', password: 'p' }], + }), + readSites, + }) + + await importChromeData(undefined, 'keep-existing', deps) + + expect(readSites).toHaveBeenCalledTimes(1) + expect(readSites).toHaveBeenCalledWith( + '/chrome/Default/History', + new Set(['news.example.com', 'accounts.other.test']) + ) + }) + + const KEY_PATHS: [string, Partial][] = [ + ['both halves land', {}], + [ + 'the cookie half throws', + { + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown cookies table') + }, + }, + ], + [ + 'the password half throws', + { + readPasswords: async () => { + throw new Error('/Users/someone/Library/.../Login Data exploded') + }, + }, + ], + [ + 'secure storage is unavailable', + { vault: { isAvailable: () => false, importCredentials: vi.fn() } }, + ], + [ + 'remembering the sites throws', + { + rememberSites: async () => { + throw new Error('directory is unwritable') + }, + }, + ], + ] + + it.each(KEY_PATHS)('zeroes the derived key when %s', async (_path, overrides) => { + const { observed } = await importObservingKey(overrides) + + expect(observed).toBeDefined() + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) +}) + +describe('remembering the sites an import brought over', () => { + const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + + it('records the host history knows, not the cookie domain it was filtered by', async () => { + // The filter is `google.com` (a domain cookie with its dot stripped) but + // the site the user opened is `mail.google.com`. The favicon lookup and the + // omnibox both key off the recorded string, so it must be the visited host. + const rememberSites = vi.fn(async () => {}) + const readSites = vi.fn(async () => [ + { hostname: 'mail.google.com', name: 'Gmail', visits: 412 }, + ]) + const readFavicons = vi.fn( + async () => new Map([['https://mail.google.com', 'data:image/png;base64,AA']]) + ) + const deps = createDeps({ + readCookies: async () => read({ cookies: [{ ...cookie(), url: 'https://google.com/' }] }), + readSites, + readFavicons, + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['google.com'])) + expect(readFavicons).toHaveBeenCalledWith( + '/chrome/Default/Favicons', + new Set(['https://mail.google.com']) + ) + expect(rememberSites).toHaveBeenCalledWith([ + { + hostname: 'mail.google.com', + name: 'Gmail', + icon: 'data:image/png;base64,AA', + visits: 412, + importedAt: expect.stringMatching(ISO_TIMESTAMP), + }, + ]) + }) + + it('carries the visit count and the import time, which order the suggestions', async () => { + const rememberSites = vi.fn(async () => {}) + const deps = createDeps({ + readSites: async () => [ + { hostname: 'example.com', name: 'Example', visits: 90 }, + { hostname: 'docs.example.com', visits: 4 }, + ], + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + const [records] = rememberSites.mock.calls[0] + expect(records.map(({ hostname, visits }) => ({ hostname, visits }))).toEqual([ + { hostname: 'example.com', visits: 90 }, + { hostname: 'docs.example.com', visits: 4 }, + ]) + // One wall-clock stamp for the whole import, never the source browser's + // own last-visit time — that would be the visit log this store avoids. + expect(new Set(records.map(({ importedAt }) => importedAt)).size).toBe(1) + expect(records[0].importedAt).toMatch(ISO_TIMESTAMP) + }) + + it('leaves a finished import alone when reading history throws synchronously', async () => { + // Regression: a dependency that threw before returning its promise escaped + // the `.catch()` chain and rewrote a completed import as + // { cookiesImported: 0, error: 'unknown' } — losing cookies already written. + const readSites = vi.fn((): Promise => { + throw new Error('History is locked by the source browser') + }) + + await expect(importChromeCookies(undefined, createDeps({ readSites }))).resolves.toEqual({ + cookiesImported: 1, + cookiesSkipped: 0, + }) + expect(readSites).toHaveBeenCalled() + }) + + it('leaves a finished import alone when writing the directory throws synchronously', async () => { + const rememberSites = vi.fn((): Promise => { + throw new Error('site directory is unwritable') + }) + const deps = createDeps({ + readSites: async () => [{ hostname: 'example.com', visits: 3 }], + rememberSites, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 1, + passwordsUpdated: 0, + passwordsSkipped: 0, + }) + expect(rememberSites).toHaveBeenCalled() + }) + + it('does not reject the combined import when remembering the sites fails', async () => { + // The combined entry point awaits this last, outside its own guard, so a + // throw here would reach the IPC caller as a rejected promise. + const deps = createDeps({ + readSites: async () => [{ hostname: 'example.com', visits: 3 }], + rememberSites: async () => { + throw new Error('directory is unwritable') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: { cookiesImported: 1, cookiesSkipped: 0 }, + passwords: { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 }, + }) + }) + + it('never records an Android package name a saved credential carried', async () => { + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [ + { origin: 'android://hash@com.example.app/', username: 'ada', password: 'p' }, + { origin: 'https://example.com', username: 'ada', password: 'p' }, + ], + }), + readSites, + }) + + await importChromePasswords(undefined, 'keep-existing', deps) + + expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['example.com'])) + }) + + it('does not open history for a profile that has none', async () => { + const readSites = vi.fn(async () => []) + const deps = createDeps({ + listProfiles: async () => [{ ...PROFILES[0], historyPath: null }], + readSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).not.toHaveBeenCalled() + }) + + it('writes nothing when the import vouched for no hosts', async () => { + const rememberSites = vi.fn(async () => {}) + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readCookies: async () => read({ cookies: [], rowsSeen: 0 }), + readSites, + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).not.toHaveBeenCalled() + expect(rememberSites).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts new file mode 100644 index 0000000000..38d3fbc9be --- /dev/null +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -0,0 +1,511 @@ +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, +} from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { normalizeOrigin } from '@/main/browser-credentials/origin' +import type { ImportCandidate, ImportOutcome } from '@/main/browser-credentials/vault' +import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' +import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords' +import type { ImportedSite } from '@/main/browser-import/chromium-site-names' +import { + type BrowserProfile, + type ImportableCookie, + ImportFailure, + totalSkipped, +} from '@/main/browser-import/types' +import type { SiteRecord } from '@/main/browser-sites/directory' + +const logger = createLogger('BrowserImport') + +/** + * Orchestrates Chrome imports. + * + * Every dependency is injected so the policy in this module — platform gating, + * profile resolution, key lifetime, failure categorisation — can be tested + * without a Keychain, a Chrome profile, an Electron session, or a real vault. + */ +export interface ImportServiceDeps { + platform: NodeJS.Platform + listProfiles: () => Promise + /** Reads one browser's Safe Storage key; the item comes from its source. */ + readSafeStoragePassword: (item: { service: string; account: string }) => Promise + readCookies: (cookiesPath: string, key: Buffer) => Promise + writeCookies: (cookies: ImportableCookie[]) => Promise<{ imported: number; failed: number }> + readPasswords: (loginDataPath: string, key: Buffer) => Promise + /** Site icons for the given origins, read from the source browser's own store. */ + readFavicons: (faviconsPath: string, origins: ReadonlySet) => Promise> + /** The most-used hosts in the source browser's history that `domains` covers. */ + readSites: (historyPath: string, domains: ReadonlySet) => Promise + /** Records the hosts an import brought over, with their names and icons. */ + rememberSites: (records: readonly SiteRecord[]) => Promise + vault: { + isAvailable: () => boolean + importCredentials: ( + candidates: ImportCandidate[], + policy: BrowserCredentialConflictPolicy + ) => Promise + } +} + +function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult { + return { cookiesImported: 0, cookiesSkipped: 0, error } +} + +function passwordFailure(error: BrowserImportResult['error']): BrowserPasswordImportResult { + return { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0, error } +} + +/** + * Resolves the profile to read and derives its decryption key. + * + * A renderer-supplied id is matched against what was actually discovered and + * is never joined into a path, so it cannot escape Chrome's directory, and an + * unknown id is refused rather than quietly falling back to the default + * profile — that would read the wrong account. + */ +interface OpenProfile { + profile: BrowserProfile + key: Buffer +} + +/** + * Wipes the derived key once every read that needs it is done. (The password + * string itself is immutable and cannot be wiped; it is never persisted or + * logged.) + */ +function zeroKey({ key }: OpenProfile): void { + key.fill(0) +} + +async function openBrowserProfile( + profileId: string | undefined, + deps: ImportServiceDeps +): Promise { + const profiles = await deps.listProfiles() + const profile = profileId ? profiles.find(({ id }) => id === profileId) : profiles[0] + if (!profile) { + throw new ImportFailure('chrome-not-found', 'No matching browser profile.') + } + // Each browser has its own Safe Storage item, so the prompt the user sees + // names the browser they actually chose. + const safeStoragePassword = await deps.readSafeStoragePassword(profile.source.keychain) + if (safeStoragePassword === null) { + throw new ImportFailure('keychain-unavailable', 'Keychain access was unavailable.') + } + return { profile, key: deriveEncryptionKey(safeStoragePassword) } +} + +/** + * Chrome profiles offered to the user, stripped to what the UI needs. Returns + * an empty list rather than throwing when Chrome is absent or unsupported. + */ +export async function listImportableProfiles( + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return [] + try { + return toDisplayProfiles(await deps.listProfiles()) + } catch { + return [] + } +} + +/** + * Turns discovered profiles into things a person can pick between. + * + * The browser is the identity that matters — "Arc", not "Microtrades" — so + * every label leads with it, and a profile name is only appended when the user + * actually gave the profile one. Two unnamed profiles in the same browser fall + * back to the directory, because a list with the same entry twice is worse + * than one with an ugly entry. + */ +export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProfile[] { + const unnamedPerBrowser = new Map() + for (const { source, label } of profiles) { + if (label === '') { + unnamedPerBrowser.set(source.id, (unnamedPerBrowser.get(source.id) ?? 0) + 1) + } + } + + return profiles.map(({ id, label, directory, source }) => { + let suffix = label + if (suffix === '' && (unnamedPerBrowser.get(source.id) ?? 0) > 1) { + suffix = directory + } + return { + id, + label: suffix === '' ? source.label : `${source.label} · ${suffix}`, + browserId: source.id, + browserLabel: source.label, + // Shown on its own in a profile picker, where the browser is already + // chosen — so an unnamed profile reads as "Default" rather than blank. + profileLabel: label === '' ? directory : label, + } + }) +} + +/** + * Copies one Chrome profile's cookies into the built-in browser. + * + * Fails closed at every step: an unsupported platform, an absent Chrome, a + * refused Keychain prompt, or an unrecognised database all stop the import + * with a category instead of falling back to a weaker path. The result carries + * counts only — no cookie material, domain, or path reaches the caller. + */ +export async function importChromeCookies( + profileId: string | undefined, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return cookieFailure('unsupported-platform') + + try { + const open = await openBrowserProfile(profileId, deps) + let outcome: CookieOutcome + try { + outcome = await runCookieImport(open, deps) + } finally { + zeroKey(open) + } + await rememberImportedSites(outcome.domains, open.profile, deps) + return outcome.result + } catch (error) { + return cookieFailure(categorize(error, 'cookie')) + } +} + +/** What an import half brought over, and the hosts it can vouch for. */ +interface CookieOutcome { + result: BrowserImportResult + domains: Set +} + +interface PasswordOutcome { + result: BrowserPasswordImportResult + domains: Set +} + +/** + * The cookie half against an already-open profile. + * + * Key lifetime belongs to the caller: the combined import shares one key across + * both halves, so this must not wipe it out from under the password half. + */ +async function runCookieImport( + { profile, key }: OpenProfile, + deps: ImportServiceDeps +): Promise { + if (profile.cookiesPath === null) { + return { result: cookieFailure('profile-unreadable'), domains: new Set() } + } + + const read = await deps.readCookies(profile.cookiesPath, key) + const skippedReading = totalSkipped(read.skipped) + // Rows present but nothing usable means the profile did not decrypt — + // typically a scheme this importer does not support. Report it as a + // failure rather than as a successful import of zero cookies. + if (read.cookies.length === 0) { + return { + result: + read.rowsSeen > 0 + ? { cookiesImported: 0, cookiesSkipped: skippedReading, error: 'nothing-imported' } + : { cookiesImported: 0, cookiesSkipped: 0 }, + domains: new Set(), + } + } + + const written = await deps.writeCookies(read.cookies) + const result: BrowserImportResult = { + cookiesImported: written.imported, + cookiesSkipped: skippedReading + written.failed, + } + // Counts only: cookie names, values, domains, and the profile path are + // deliberately absent from local diagnostics. + logger.info('Chrome cookie import finished', { + imported: result.cookiesImported, + skipped: result.cookiesSkipped, + }) + return { result, domains: cookieHostnames(read.cookies) } +} + +/** + * Copies one Chrome profile's saved passwords into the encrypted vault. + * + * Refuses to run when secure storage is unavailable: there is no plaintext + * fallback for passwords, so an unavailable vault ends the import rather than + * degrading it. Decrypted passwords pass straight from the reader into the + * vault and are never logged or counted per site. + */ +export async function importChromePasswords( + profileId: string | undefined, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return passwordFailure('unsupported-platform') + if (!deps.vault.isAvailable()) return passwordFailure('vault-unavailable') + + try { + const open = await openBrowserProfile(profileId, deps) + let outcome: PasswordOutcome + try { + outcome = await runPasswordImport(open, policy, deps) + } finally { + zeroKey(open) + } + await rememberImportedSites(outcome.domains, open.profile, deps) + return outcome.result + } catch (error) { + return passwordFailure(categorize(error, 'password')) + } +} + +/** The password half against an already-open profile. @see runCookieImport */ +async function runPasswordImport( + { profile, key }: OpenProfile, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (profile.loginDataPath === null) { + return { result: passwordFailure('profile-unreadable'), domains: new Set() } + } + + const read: ReadPasswordsResult = await deps.readPasswords(profile.loginDataPath, key) + if (read.credentials.length === 0) { + return { + result: + read.rowsSeen > 0 + ? { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: read.skipped, + error: 'nothing-imported', + } + : { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0 }, + domains: new Set(), + } + } + + const outcome = await deps.vault.importCredentials( + await withFavicons(read.credentials, profile.faviconsPath, deps), + policy + ) + const result: BrowserPasswordImportResult = { + passwordsAdded: outcome.added, + passwordsUpdated: outcome.updated, + passwordsSkipped: outcome.skipped + read.skipped, + } + logger.info('Chrome password import finished', { + added: result.passwordsAdded, + updated: result.passwordsUpdated, + skipped: result.passwordsSkipped, + }) + return { result, domains: credentialHostnames(read.credentials) } +} + +/** + * Imports cookies and saved passwords in one action. + * + * Deliberately one call rather than two from the UI: the Keychain prompt can + * easily outlive the page's transient user activation, so a second gated call + * afterwards would be refused for a user who did nothing wrong. Each half + * still reports its own outcome, so one failing does not hide the other. + * + * The profile is opened ONCE for both halves. Calling the two exported halves + * in sequence would read the Safe Storage item twice, and a user who answered + * the first Keychain prompt with "Allow" rather than "Always Allow" gets a + * second prompt they did not ask for — which, arriving without a user gesture + * behind it, is exactly the failure this combined entry point exists to + * prevent. One open also means one history read, over the union of what both + * halves vouched for. (The favicon store is still read twice — once for the + * saved-password origins, once for the history hosts — because the two sets are + * only known at different points.) + */ +export async function importChromeData( + profileId: string | undefined, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') { + return { + cookies: cookieFailure('unsupported-platform'), + passwords: passwordFailure('unsupported-platform'), + } + } + + let open: OpenProfile + try { + open = await openBrowserProfile(profileId, deps) + } catch (error) { + // Neither half ran, so both report the same reason rather than one of them + // claiming a success it never attempted. + return { + cookies: cookieFailure(categorize(error, 'cookie')), + passwords: passwordFailure(categorize(error, 'password')), + } + } + + let cookies: CookieOutcome = { result: cookieFailure('unknown'), domains: new Set() } + let passwords: PasswordOutcome = { result: passwordFailure('unknown'), domains: new Set() } + try { + try { + cookies = await runCookieImport(open, deps) + } catch (error) { + cookies = { result: cookieFailure(categorize(error, 'cookie')), domains: new Set() } + } + // One half failing must not take the other with it. + try { + passwords = deps.vault.isAvailable() + ? await runPasswordImport(open, policy, deps) + : { result: passwordFailure('vault-unavailable'), domains: new Set() } + } catch (error) { + passwords = { result: passwordFailure(categorize(error, 'password')), domains: new Set() } + } + } finally { + zeroKey(open) + } + + await rememberImportedSites(union(cookies.domains, passwords.domains), open.profile, deps) + return { cookies: cookies.result, passwords: passwords.result } +} + +function union(first: ReadonlySet, second: ReadonlySet): Set { + return new Set([...first, ...second]) +} + +/** + * Attaches each credential's site icon, where the source browser has one. + * + * Icons are cosmetic, so a profile without a favicon store — or a favicon + * store that will not read — returns the credentials unchanged rather than + * failing an import that otherwise succeeded. + */ +async function withFavicons( + credentials: ImportCandidate[], + faviconsPath: string | null, + deps: ImportServiceDeps +): Promise { + if (faviconsPath === null) return credentials + // Only the origins being imported are looked up, so a password import never + // drags the browser's whole history along with it. + const origins = new Set( + credentials.flatMap((candidate) => { + const origin = normalizeOrigin(candidate.origin) + return origin === null ? [] : [origin] + }) + ) + + const icons = await deps + .readFavicons(faviconsPath, origins) + .catch(() => new Map()) + if (icons.size === 0) return credentials + + return credentials.map((candidate) => { + const icon = icons.get(normalizeOrigin(candidate.origin) ?? '') + return icon ? { ...candidate, icon } : candidate + }) +} + +/** The hosts a set of cookies belongs to, with the domain-cookie dot removed. */ +function cookieHostnames(cookies: readonly ImportableCookie[]): Set { + const hostnames = new Set() + for (const cookie of cookies) { + const hostname = hostnameOf(cookie.url) + if (hostname) hostnames.add(hostname) + } + return hostnames +} + +function credentialHostnames(credentials: readonly ImportCandidate[]): Set { + const hostnames = new Set() + for (const candidate of credentials) { + const hostname = hostnameOf(candidate.origin) + if (hostname) hostnames.add(hostname) + } + return hostnames +} + +/** + * Chrome stores Android app credentials with an `android://…@com.example.app/` + * realm, so without a scheme guard a package name would pass as a host and + * reach the omnibox as a site nothing can navigate to. + */ +function hostnameOf(url: string): string | null { + try { + const { hostname, protocol } = new URL(url) + if (protocol !== 'https:' && protocol !== 'http:') return null + return hostname || null + } catch { + return null + } +} + +/** + * Records the sites this import brought over, so the omnibox can offer them — + * as "Gmail" rather than `mail.google.com` where the source browser knew that. + * + * `domains` are the hosts the import has evidence for: cookie hosts with the + * domain dot stripped, plus saved-password origins. They are the FILTER; the + * source browser's history is the SOURCE. Being sourced from pages someone + * opened is what keeps trackers out — a cookie jar would be mostly ad networks. + * The filter's job is narrower: it holds what gets remembered inside the data + * the user chose to bring over. See {@link readBrowserSites}. + * + * Each record is keyed by the host actually visited, so the favicon lookup and + * the omnibox agree on the string. + * + * Cannot throw. A name and an icon are a nicety layered on cookies and + * passwords that have already landed, so no failure in here — including a + * synchronous one from a dependency — may reach the caller and rewrite a + * finished import as a failed one. + */ +async function rememberImportedSites( + domains: ReadonlySet, + profile: BrowserProfile, + deps: ImportServiceDeps +): Promise { + if (domains.size === 0 || !profile.historyPath) return + + try { + const sites = await deps.readSites(profile.historyPath, domains) + if (sites.length === 0) return + + const origins = new Set(sites.map((site) => originOf(site.hostname))) + const icons = profile.faviconsPath + ? await deps + .readFavicons(profile.faviconsPath, origins) + .catch(() => new Map()) + : new Map() + + const importedAt = new Date().toISOString() + await deps.rememberSites( + sites.map((site) => ({ + hostname: site.hostname, + name: site.name, + icon: icons.get(originOf(site.hostname)), + visits: site.visits, + importedAt, + })) + ) + } catch { + // Category only, like every other failure path here: the detail that would + // make this message useful is the hostnames, which is exactly what must not + // reach a local log. Without the line, a directory that can never be + // written leaves the omnibox permanently empty with no signal anywhere. + logger.warn('Could not record the sites an import brought over') + } +} + +const originOf = (hostname: string) => `https://${hostname}` + +function categorize(error: unknown, kind: 'cookie' | 'password'): BrowserImportResult['error'] { + if (error instanceof ImportFailure) { + logger.warn(`Chrome ${kind} import failed`, { code: error.code }) + return error.code + } + logger.warn(`Chrome ${kind} import failed with an unexpected error`) + return 'unknown' +} diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts new file mode 100644 index 0000000000..92ec971868 --- /dev/null +++ b/apps/desktop/src/main/browser-import/index.ts @@ -0,0 +1,68 @@ +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, +} from '@sim/desktop-bridge' +import { importAgentCookies } from '@/main/browser-agent/session' +import { credentialsAvailable, importCredentials } from '@/main/browser-credentials' +import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' +import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto' +import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons' +import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords' +import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles' +import { readBrowserSites } from '@/main/browser-import/chromium-site-names' +import { + type ImportServiceDeps, + listImportableProfiles, + importChromeCookies as runCookieImport, + importChromeData as runDataImport, + importChromePasswords as runPasswordImport, +} from '@/main/browser-import/import-service' +import { rememberSites } from '@/main/browser-sites' + +/** + * Composition root for the Chrome importer: binds the real Keychain, profile, + * database, browser-profile, and vault implementations to the policy in + * `import-service`. The IPC layer talks to this module and nothing deeper. + */ +function deps(): ImportServiceDeps { + return { + platform: process.platform, + listProfiles: () => listAllBrowserProfiles(), + readSafeStoragePassword: (item) => readSafeStoragePassword(item), + readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key), + writeCookies: (cookies) => importAgentCookies(cookies), + readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key), + readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins), + readSites: (historyPath, domains) => readBrowserSites(historyPath, domains), + rememberSites: (records) => rememberSites(records), + vault: { + isAvailable: () => credentialsAvailable(), + importCredentials: (candidates, policy) => importCredentials(candidates, policy), + }, + } +} + +export function listChromeImportProfiles(): Promise { + return listImportableProfiles(deps()) +} + +export function importChromeCookies(profileId?: string): Promise { + return runCookieImport(profileId, deps()) +} + +export function importChromePasswords( + profileId?: string, + policy: BrowserCredentialConflictPolicy = 'keep-existing' +): Promise { + return runPasswordImport(profileId, policy, deps()) +} + +export function importChromeData( + profileId?: string, + policy: BrowserCredentialConflictPolicy = 'keep-existing' +): Promise { + return runDataImport(profileId, policy, deps()) +} diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts new file mode 100644 index 0000000000..69b6d6c2be --- /dev/null +++ b/apps/desktop/src/main/browser-import/sqlite-source.ts @@ -0,0 +1,76 @@ +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ImportFailure } from '@/main/browser-import/types' + +/** + * Reads a Chrome SQLite database without touching the original. + * + * Chrome keeps its databases open and may hold a write-ahead log, so every + * read works from a private copy: the source is only ever read, never opened + * for writing and never locked, and the copy lives in a fresh temporary + * directory removed on success, failure, and cancellation alike. Both the + * cookie and password readers go through here so that guarantee is written + * once rather than reimplemented per database. + */ + +/** SQLite keeps recent writes beside the main file; both are needed for a faithful copy. */ +const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm'] + +async function queryCopy(databasePath: string, query: string): Promise[]> { + // Imported lazily: `node:sqlite` is only needed on the import path, and this + // keeps module load working on runtimes that lack it. + const { DatabaseSync } = await import('node:sqlite') + + let database: InstanceType + try { + database = new DatabaseSync(databasePath, { readOnly: true }) + } catch { + // A write-ahead log that needs recovery cannot be opened read-only. + // Reopening the *copy* writable is safe — Chrome's own file is not this one. + try { + database = new DatabaseSync(databasePath) + } catch { + throw new ImportFailure('profile-unreadable', 'Could not open the copied database.') + } + } + + try { + const statement = database.prepare(query) + // Chrome's microsecond timestamps overflow a JS number's safe integer + // range, which this API rejects unless BigInt reads are enabled. + statement.setReadBigInts(true) + return statement.all() as Record[] + } catch { + throw new ImportFailure('unsupported-schema', 'The Chrome table is not in a recognised shape.') + } finally { + database.close() + } +} + +/** + * Copies `sourcePath` (plus any SQLite sidecars) to private temporary storage + * and runs `query` against the copy. The copy is always deleted. + */ +export async function queryBrowserDatabase( + sourcePath: string, + fileName: string, + query: string +): Promise[]> { + const staging = await mkdtemp(join(tmpdir(), 'sim-chrome-import-')) + try { + const workingCopy = join(staging, fileName) + try { + await copyFile(sourcePath, workingCopy) + } catch { + throw new ImportFailure('profile-unreadable', 'Could not read the Chrome database.') + } + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { + // Absent sidecars are normal: they only exist while a WAL is live. + await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {}) + } + return await queryCopy(workingCopy, query) + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/apps/desktop/src/main/browser-import/types.ts b/apps/desktop/src/main/browser-import/types.ts new file mode 100644 index 0000000000..e7bb5f2f96 --- /dev/null +++ b/apps/desktop/src/main/browser-import/types.ts @@ -0,0 +1,103 @@ +import type { BrowserImportError } from '@sim/desktop-bridge' +import type { BrowserSource } from '@/main/browser-import/browser-sources' + +/** + * Shapes internal to the Chrome importer. + * + * Nothing declared here may cross the preload bridge: cookie values, cookie + * names, and Chrome's host paths stay inside the Electron main process. Only + * the counts in `BrowserImportResult` are ever returned to a renderer. + */ + +/** One browser profile discovered on this device. */ +export interface BrowserProfile { + /** Namespaced `:` id, opaque to the renderer. */ + id: string + /** The profile directory, used to tell unnamed profiles apart. */ + directory: string + /** The name the user gave this profile, or empty when they never named it. */ + label: string + /** Which browser it belongs to, carrying that browser's Keychain item. */ + source: BrowserSource + /** That profile's cookie database, or null when it has none. Stays in main. */ + cookiesPath: string | null + /** That profile's saved-password database, or null when it has none. */ + loginDataPath: string | null + /** That profile's favicon store, or null when it has none. */ + faviconsPath: string | null + /** That profile's history, read only for the names sites go by. */ + historyPath: string | null +} + +/** One row of Chrome's `cookies` table, coerced to plain JS types. */ +export interface ChromiumCookieRow { + hostKey: string + name: string + path: string + expiresUtc: number + isSecure: boolean + isHttpOnly: boolean + hasExpires: boolean + isPersistent: boolean + sameSite: number +} + +/** + * A cookie ready for Electron's `cookies.set`. Field names and the `sameSite` + * union mirror `Electron.CookiesSetDetails` so the writer passes it straight + * through without a second translation step. + */ +export interface ImportableCookie { + url: string + name: string + value: string + /** Set only for domain cookies (a leading-dot `host_key`). */ + domain?: string + path: string + secure: boolean + httpOnly: boolean + /** Omitted for session cookies. */ + expirationDate?: number + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' +} + +/** + * Why a row was dropped. Aggregated into counts for local diagnostics — a skip + * reason is never paired with the cookie it came from, in a log or anywhere + * else. + */ +export type CookieSkipReason = 'decrypt-failed' | 'expired' | 'invalid-target' | 'empty' + +export type CookieSkipCounts = Record + +export function emptySkipCounts(): CookieSkipCounts { + return { 'decrypt-failed': 0, expired: 0, 'invalid-target': 0, empty: 0 } +} + +export function totalSkipped(counts: CookieSkipCounts): number { + return Object.values(counts).reduce((sum, count) => sum + count, 0) +} + +/** SQLite integers arrive as BigInt once BigInt reads are enabled. */ +export function toNumber(value: unknown): number { + if (typeof value === 'bigint') return Number(value) + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +export function toText(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * A failure carrying a category that is safe to hand to a renderer. The + * message stays in the main process for local logs; only `code` is exposed. + */ +export class ImportFailure extends Error { + constructor( + readonly code: BrowserImportError, + message: string + ) { + super(message) + this.name = 'ImportFailure' + } +} diff --git a/apps/desktop/src/main/browser-sites/directory.test.ts b/apps/desktop/src/main/browser-sites/directory.test.ts new file mode 100644 index 0000000000..af3c1919f4 --- /dev/null +++ b/apps/desktop/src/main/browser-sites/directory.test.ts @@ -0,0 +1,256 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SiteRecord } from '@/main/browser-sites/directory' + +vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) + +const { SiteDirectory } = await import('@/main/browser-sites/directory') + +/** + * The cap is module-private, so it is read back out of the source instead of + * copied here. A hardcoded `500` would keep passing on the one day the + * assertion matters — the day someone changes the cap. + */ +const MAX_SITES = Number( + /MAX_SITES = (\d+)/.exec(await readFile(new URL('directory.ts', import.meta.url), 'utf8'))?.[1] +) +if (!Number.isInteger(MAX_SITES)) throw new Error('could not read MAX_SITES out of directory.ts') + +/** Reversible stand-in for the OS keychain, so tests assert real round-trips. */ +const encryption = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`sealed:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').replace(/^sealed:/, ''), +} + +/** `count` distinct hosts numbered from `from`, each as used as `visits` says. */ +const sites = (count: number, visits: (index: number) => number, from = 0): SiteRecord[] => + Array.from({ length: count }, (_, offset) => ({ + hostname: `site-${String(from + offset).padStart(4, '0')}.example.com`, + visits: visits(from + offset), + })) + +let directory: string +let path: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-site-directory-')) + path = join(directory, 'browser-sites.json') +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const open = () => new SiteDirectory(path, encryption) + +describe('SiteDirectory', () => { + it('remembers a site’s name and icon across restarts', async () => { + await open().remember([{ hostname: 'mail.google.com', name: 'Gmail', icon: 'data:png' }]) + + expect(await open().list()).toEqual([ + { hostname: 'mail.google.com', name: 'Gmail', icon: 'data:png' }, + ]) + }) + + it('refreshes a site that has been renamed', async () => { + const store = open() + await store.remember([{ hostname: 'x.com', name: 'Twitter' }]) + await store.remember([{ hostname: 'x.com', name: 'X' }]) + + expect(await store.list()).toEqual([{ hostname: 'x.com', name: 'X' }]) + }) + + it('keeps sites a later import says nothing about', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + await store.remember([{ hostname: 'linear.app', name: 'Linear' }]) + + expect((await store.list()).map((site) => site.hostname).sort()).toEqual([ + 'github.com', + 'linear.app', + ]) + }) + + it('keeps an existing icon when a later import only learns a name', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', icon: 'data:png' }]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await store.list()).toEqual([ + { hostname: 'github.com', name: 'GitHub', icon: 'data:png' }, + ]) + }) + + it('remembers how used a site was and when it was imported', async () => { + await open().remember([ + { + hostname: 'mail.google.com', + name: 'Gmail', + visits: 412, + importedAt: '2026-07-27T09:15:00.000Z', + }, + ]) + + expect(await open().list()).toEqual([ + { + hostname: 'mail.google.com', + name: 'Gmail', + visits: 412, + importedAt: '2026-07-27T09:15:00.000Z', + }, + ]) + }) + + it('keeps the busiest profile’s visit count when a host is imported twice', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + await store.remember([{ hostname: 'github.com', visits: 2 }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + }) + + it('raises a visit count when a later profile used the site more', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', visits: 2 }]) + await store.remember([{ hostname: 'github.com', visits: 300 }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', visits: 300 }]) + }) + + it('keeps a known visit count when a later import measures nothing', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', visits: 300 }]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + }) + + it('stops at the cap when two imports together overflow it', async () => { + const store = open() + const perImport = Math.ceil(MAX_SITES * 0.6) + expect(perImport * 2).toBeGreaterThan(MAX_SITES) + + await store.remember(sites(perImport, () => 10)) + await store.remember(sites(perImport, () => 10, perImport)) + + expect(await store.list()).toHaveLength(MAX_SITES) + }) + + it('evicts the least-used sites and keeps the most-used ones', async () => { + const overflow = 20 + await open().remember(sites(MAX_SITES + overflow, (index) => index)) + + const kept = new Set((await open().list()).map((site) => site.hostname)) + expect(kept.size).toBe(MAX_SITES) + expect([...kept].sort()[0]).toBe(`site-${String(overflow).padStart(4, '0')}.example.com`) + expect(kept).toContain(`site-${String(MAX_SITES + overflow - 1).padStart(4, '0')}.example.com`) + expect(kept).not.toContain('site-0000.example.com') + expect(kept).not.toContain(`site-${String(overflow - 1).padStart(4, '0')}.example.com`) + }) + + it('evicts a site with no measured use before one with any', async () => { + // No usage evidence must not outrank measured usage: treating a missing + // count as infinitely used would evict a real site to keep this one. + await open().remember([{ hostname: 'unmeasured.example.com' }, ...sites(MAX_SITES, () => 1)]) + + const kept = (await open().list()).map((site) => site.hostname) + expect(kept).toHaveLength(MAX_SITES) + expect(kept).not.toContain('unmeasured.example.com') + expect(kept).toContain('site-0000.example.com') + }) + + it('evicts the same record no matter what order the records arrived in', async () => { + const tied: SiteRecord[] = [ + { hostname: 'tie-a.example.com', visits: 5, importedAt: '2026-01-01T00:00:00.000Z' }, + { hostname: 'tie-b.example.com', visits: 5, importedAt: '2026-02-01T00:00:00.000Z' }, + { hostname: 'tie-c.example.com', visits: 5, importedAt: '2026-02-01T00:00:00.000Z' }, + ] + const busier = sites(MAX_SITES - 1, () => 100) + + const survivors = await Promise.all( + [[...busier, ...tied], [...tied].reverse().concat(busier)].map(async (records, index) => { + const store = new SiteDirectory(join(directory, `order-${index}.json`), encryption) + await store.remember(records) + return (await store.list()) + .map((site) => site.hostname) + .filter((hostname) => hostname.startsWith('tie-')) + }) + ) + + // One slot, three equally used hosts: the newer import wins, and hostname + // settles what is left — `tie-b` and `tie-c` share an import time. + expect(survivors).toEqual([['tie-b.example.com'], ['tie-b.example.com']]) + }) + + it('never writes the site list in the clear', async () => { + await open().remember([{ hostname: 'mail.google.com', name: 'Gmail' }]) + + // Which sites someone uses is exactly what this file must not leak. + expect((await readFile(path)).toString('utf8')).not.toContain('mail.google.com') + }) + + it('stores nothing at all when the OS cannot encrypt', async () => { + const unavailable = new SiteDirectory(path, { + ...encryption, + isEncryptionAvailable: () => false, + }) + + await unavailable.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await unavailable.list()).toEqual([]) + await expect(readFile(path)).rejects.toThrow() + }) + + it('reads as empty rather than throwing on a corrupt file', async () => { + await writeFile(path, 'not json at all') + + expect(await open().list()).toEqual([]) + }) + + it('ignores a directory written by a future version', async () => { + await writeFile(path, JSON.stringify({ version: 99, payload: 'whatever' })) + + expect(await open().list()).toEqual([]) + }) + + it('drops a directory written before imported hosts became suggestions', async () => { + // Version 1 was seeded from imported cookie hosts — mostly ad and analytics + // origins — and those records only ever decorated a host the omnibox already + // had. Version 2 records are offered as suggestions in their own right, so + // carrying the old set forward would put exactly those origins in the + // dropdown. The payload below decrypts cleanly; it is discarded on meaning, + // not on damage. + const version1: SiteRecord[] = [{ hostname: 'doubleclick.net', name: 'DoubleClick' }] + await writeFile( + path, + JSON.stringify({ + version: 1, + payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), + }) + ) + + expect(await open().list()).toEqual([]) + }) + + it('skips an entry with no hostname to key it by', async () => { + await open().remember([{ hostname: '', name: 'Nowhere' }]) + + expect(await open().list()).toEqual([]) + }) + + it('forgets everything on clear', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + await store.clear() + + expect(await store.list()).toEqual([]) + }) + + it('clears cleanly when there is nothing to clear', async () => { + await expect(open().clear()).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts new file mode 100644 index 0000000000..ba135bb283 --- /dev/null +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -0,0 +1,197 @@ +import { readFile } from 'node:fs/promises' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' + +/** + * What the sites brought over from another browser are called, and what they + * look like. + * + * Sim's browser records no history, so it cannot learn on its own that + * `mail.google.com` is Gmail. This is the answer to that, captured once at + * import time from the browser that did know, for the hosts being imported + * anyway. It is a name and an icon per host — never a visit log, never a URL + * beyond the host itself. + * + * Encrypted with the same OS-backed key as the credential vault. The hostnames + * in here include ones only a saved password knows about, and those live in an + * encrypted vault today; writing them to a plaintext file beside it would + * quietly undo that. + */ + +/** + * Bumped when what a record MEANS changes, not merely when a field is added. + * + * Version 1 was seeded from the imported cookie hosts, which are mostly ad and + * analytics origins nobody navigated to. Those records only ever decorated a + * host the omnibox already had, so they were harmless; version 2 hosts are + * offered as suggestions in their own right, and carrying the old set forward + * would put exactly the origins this design rejects into the dropdown. `read` + * discards a foreign version, so the upgrade costs a re-import — the right + * trade for a cache of someone else's data that is now user-visible. + */ +const DIRECTORY_VERSION = 2 + +/** + * Hosts kept across all imports. Bounded because every record can carry an + * inline favicon, and the whole directory crosses IPC whenever the browser + * panel appears. Least-used is evicted first — see {@link evictExcess} for how + * ties settle. + */ +const MAX_SITES = 500 + +export interface SiteRecord { + hostname: string + /** What the source browser's page titles call this site. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from. Present only for + * hosts an import found in the source browser's history; absent for one known + * solely through a saved password. + * + * An aggregate, deliberately: no visit times, no URLs, no ordering of one + * visit against another. Enough to rank suggestions, not enough to + * reconstruct where someone went or when. + */ + visits?: number + /** + * When Sim imported this host — wall clock at import, never the source + * browser's own last-visit time, which would be the visit log this store + * exists to avoid keeping. + */ + importedAt?: string +} + +interface EncryptedDirectoryEnvelope { + version: number + payload: string +} + +function isSiteRecord(value: unknown): value is SiteRecord { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SiteRecord).hostname === 'string' && + (value as SiteRecord).hostname !== '' + ) +} + +function maxDefined(first: number | undefined, second: number | undefined): number | undefined { + if (first === undefined) return second + if (second === undefined) return first + return Math.max(first, second) +} + +/** + * Trims the directory to {@link MAX_SITES}, dropping the least-used first and + * breaking ties by most recent import, then alphabetically so the same inputs + * always evict the same records. + * + * A record with no visit count is a record with no evidence of use, so it goes + * first rather than last — that is the only reading under which eviction order + * cannot be gamed by a malformed entry. + */ +function evictExcess(records: SiteRecord[]): SiteRecord[] { + if (records.length <= MAX_SITES) return records + return [...records] + .sort( + (a, b) => + (b.visits ?? 0) - (a.visits ?? 0) || + (b.importedAt ?? '').localeCompare(a.importedAt ?? '') || + a.hostname.localeCompare(b.hostname) + ) + .slice(0, MAX_SITES) +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +export class SiteDirectory { + constructor( + private readonly filePath: string, + private readonly encryption: EncryptionProvider = safeStorage + ) {} + + /** + * Whether this device can store the directory at all. Without OS-backed + * encryption nothing is written, matching the vault: a site list is not + * worth leaving in plaintext for the next person on this machine. + */ + isAvailable(): boolean { + try { + return this.encryption.isEncryptionAvailable() + } catch { + return false + } + } + + private async read(): Promise { + if (!this.isAvailable()) return [] + try { + const raw = await readFile(this.filePath) + const envelope = JSON.parse(raw.toString('utf8')) as EncryptedDirectoryEnvelope + if (envelope.version !== DIRECTORY_VERSION) return [] + const decrypted = this.encryption.decryptString(Buffer.from(envelope.payload, 'base64')) + const records = JSON.parse(decrypted) as SiteRecord[] + // Per-record, not just per-array: everything downstream sorts and merges + // on `hostname`, so one entry without it is a TypeError in the middle of + // an import rather than a record that is quietly skipped. + return Array.isArray(records) ? records.filter(isSiteRecord) : [] + } catch { + // A missing, truncated, or foreign-keyed file reads as empty rather than + // taking the omnibox down with it. + return [] + } + } + + private async write(records: SiteRecord[]): Promise { + if (!this.isAvailable()) return false + const envelope: EncryptedDirectoryEnvelope = { + version: DIRECTORY_VERSION, + payload: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + } + await writeJsonFileAtomically(this.filePath, envelope) + return true + } + + async list(): Promise { + return this.read() + } + + /** + * Records what an import learned, keeping anything it did not. + * + * A re-import refreshes a site that has been renamed or re-iconed, but an + * entry the new import says nothing about is left alone rather than blanked: + * importing a second profile should add to what the browser knows, not + * strip the first profile's sites of their names. + */ + async remember(records: readonly SiteRecord[]): Promise { + if (records.length === 0 || !this.isAvailable()) return + const merged = new Map() + for (const existing of await this.read()) merged.set(existing.hostname, existing) + for (const incoming of records) { + if (!incoming.hostname) continue + const existing = merged.get(incoming.hostname) + merged.set(incoming.hostname, { + hostname: incoming.hostname, + name: incoming.name ?? existing?.name, + icon: incoming.icon ?? existing?.icon, + // The most-used of the profiles a host was seen in wins, so re-importing + // a profile that barely touches a site cannot demote it. + visits: maxDefined(incoming.visits, existing?.visits), + importedAt: incoming.importedAt ?? existing?.importedAt, + }) + } + await this.write(evictExcess([...merged.values()])) + } + + /** Forgets every site. Runs with the rest of the browser teardown. */ + async clear(): Promise { + await removeFileIfPresent(this.filePath) + } +} diff --git a/apps/desktop/src/main/browser-sites/index.ts b/apps/desktop/src/main/browser-sites/index.ts new file mode 100644 index 0000000000..65301f2718 --- /dev/null +++ b/apps/desktop/src/main/browser-sites/index.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { app } from 'electron' +import { SiteDirectory, type SiteRecord } from '@/main/browser-sites/directory' + +/** + * Composition root for the imported site directory: one store for the whole + * app, holding what each imported host is called and what it looks like. + */ + +let instance: SiteDirectory | null = null + +export function siteDirectory(): SiteDirectory { + if (!instance) { + instance = new SiteDirectory(join(app.getPath('userData'), 'browser-sites.json')) + } + return instance +} + +export function listSites(): Promise { + return siteDirectory().list() +} + +export function rememberSites(records: readonly SiteRecord[]): Promise { + return siteDirectory().remember(records) +} + +/** Runs with the rest of the browser teardown, so no site list outlives sign-out. */ +export function clearSites(): Promise { + return siteDirectory().clear() +} diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts new file mode 100644 index 0000000000..11133620b9 --- /dev/null +++ b/apps/desktop/src/main/config.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + APP_NAME_FOR_CHANNEL, + channelForOrigin, + createConfigStore, + DEFAULT_ORIGIN, + isSafeInternalPath, + partitionForOrigin, + validateOriginInput, +} from '@/main/config' + +function tempSettingsPath(): string { + return join(mkdtempSync(join(tmpdir(), 'sim-desktop-config-')), 'settings.json') +} + +describe('validateOriginInput', () => { + it('accepts https and normalizes to the origin', () => { + expect(validateOriginInput('https://sim.ai')).toEqual({ ok: true, origin: 'https://sim.ai' }) + expect(validateOriginInput(' https://sim.example.com/path?q=1 ')).toEqual({ + ok: true, + origin: 'https://sim.example.com', + }) + expect(validateOriginInput('https://sim.example.com:8443')).toEqual({ + ok: true, + origin: 'https://sim.example.com:8443', + }) + }) + + it('accepts http only for loopback hosts', () => { + expect(validateOriginInput('http://localhost:3000')).toEqual({ + ok: true, + origin: 'http://localhost:3000', + }) + expect(validateOriginInput('http://127.0.0.1:3000').ok).toBe(true) + expect(validateOriginInput('http://evil.example').ok).toBe(false) + }) + + it('rejects credentials, bad schemes, and garbage', () => { + expect(validateOriginInput('https://user:pass@sim.ai').ok).toBe(false) + expect(validateOriginInput('ftp://sim.ai').ok).toBe(false) + expect(validateOriginInput('sim.ai').ok).toBe(false) + expect(validateOriginInput('').ok).toBe(false) + }) +}) + +describe('partitionForOrigin', () => { + it('uses the canonical partition for the default origin', () => { + expect(partitionForOrigin(DEFAULT_ORIGIN)).toBe('persist:sim') + }) + + it('gives every other origin an isolated persistent partition', () => { + const partition = partitionForOrigin('https://self-hosted.example:8443') + expect(partition).toMatch(/^persist:sim-/) + expect(partition).not.toBe(partitionForOrigin('https://other.example')) + }) +}) + +describe('isSafeInternalPath', () => { + it('accepts absolute same-origin paths with query', () => { + expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true) + expect(isSafeInternalPath('/')).toBe(true) + }) + + it('rejects protocol-relative, backslash, absolute, and oversized values', () => { + expect(isSafeInternalPath('//evil.example')).toBe(false) + expect(isSafeInternalPath('/a\\evil')).toBe(false) + expect(isSafeInternalPath('https://evil.example/x')).toBe(false) + expect(isSafeInternalPath('workspace')).toBe(false) + expect(isSafeInternalPath('')).toBe(false) + expect(isSafeInternalPath(`/${'a'.repeat(2100)}`)).toBe(false) + expect(isSafeInternalPath(42)).toBe(false) + }) +}) + +describe('createConfigStore', () => { + it('round-trips settings through disk', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + store.set('zoomLevel', 1.5) + store.set('lastRoute', '/workspace/ws1') + // Writes coalesce; quitting flushes them. Without this the file on disk is + // still empty, which is the point of the debounce. + store.flush() + + const reloaded = createConfigStore(filePath, {}) + expect(reloaded.get('zoomLevel')).toBe(1.5) + expect(reloaded.get('lastRoute')).toBe('/workspace/ws1') + }) + + it('persists a validated origin and rejects invalid input', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + expect(store.setOrigin('https://self-hosted.example').ok).toBe(true) + expect(store.getOrigin()).toBe('https://self-hosted.example') + expect(store.setOrigin('http://evil.example').ok).toBe(false) + expect(store.getOrigin()).toBe('https://self-hosted.example') + + const reloaded = createConfigStore(filePath, {}) + expect(reloaded.getOrigin()).toBe('https://self-hosted.example') + }) + + it('recovers from a corrupted settings file', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, '{not json') + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) + + it('falls back to the default origin when the stored origin is invalid', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, JSON.stringify({ origin: 'http://evil.example' })) + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) + + it('honors a valid SIM_DESKTOP_ORIGIN override without persisting it', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, { SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:4600' }) + expect(store.getOrigin()).toBe('http://127.0.0.1:4600') + store.set('zoomLevel', 1) + store.flush() + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe(DEFAULT_ORIGIN) + }) + + it('coalesces repeated writes and skips ones that change nothing', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + + // Reference equality can never hold for an array value, so this guard used + // to be dead for exactly the settings written most often — every browser + // navigation fell through to a synchronous whole-file write. + store.set('browserPinnedTabUrls', ['https://a.example/']) + store.flush() + const afterFirst = readFileSync(filePath, 'utf8') + + store.set('browserPinnedTabUrls', ['https://a.example/']) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(afterFirst) + + store.set('browserPinnedTabUrls', ['https://b.example/']) + store.flush() + expect(JSON.parse(readFileSync(filePath, 'utf8')).browserPinnedTabUrls).toEqual([ + 'https://b.example/', + ]) + }) + + it('writes a new origin immediately rather than debouncing it', () => { + // Changing the origin tears down and reloads, so a pending write could be + // lost on the way out — and losing it strands the app on the old server. + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + + store.setOrigin('https://sim.example.com') + + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://sim.example.com') + }) + + it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => { + const store = createConfigStore(tempSettingsPath(), { + SIM_DESKTOP_ORIGIN: 'http://evil.example', + }) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) +}) + +describe('channelForOrigin', () => { + it('maps each environment origin to its channel', () => { + expect(channelForOrigin('https://sim.ai')).toBe('prod') + expect(channelForOrigin('https://www.sim.ai')).toBe('prod') + expect(channelForOrigin('https://www.dev.sim.ai')).toBe('dev') + expect(channelForOrigin('https://dev.sim.ai')).toBe('dev') + expect(channelForOrigin('https://www.staging.sim.ai')).toBe('staging') + expect(channelForOrigin('http://localhost:3000')).toBe('local') + expect(channelForOrigin('http://127.0.0.1:3000')).toBe('local') + }) + + it('treats self-hosted and garbage origins as prod', () => { + expect(channelForOrigin('https://sim.mycompany.com')).toBe('prod') + expect(channelForOrigin('not a url')).toBe('prod') + }) + + it('gives every channel a distinct app identity, prod keeping the plain name', () => { + expect(APP_NAME_FOR_CHANNEL.prod).toBe('Sim') + const names = Object.values(APP_NAME_FOR_CHANNEL) + expect(new Set(names).size).toBe(names.length) + }) +}) diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 0000000000..7a0b0a5a8b --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -0,0 +1,305 @@ +import { readFileSync } from 'node:fs' +import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' +import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' + +/** settings.json is meant to be readable when a user opens it. */ +const SETTINGS_INDENT = 2 + +const logger = createLogger('DesktopConfig') + +/** + * The server origin fresh installs point at. `scripts/build.ts` can bake an + * override in via SIM_DESKTOP_DEFAULT_ORIGIN (per-environment builds: dev, + * staging, localhost); official builds default to production. The esbuild + * define replaces the env read at bundle time, so a packaged app never + * consults the runtime environment for this. http is accepted only for + * loopback origins (the localhost dev-server channel). + */ +function isValidBakedOrigin(origin: string | undefined): origin is string { + if (!origin) return false + try { + const url = new URL(origin) + if (url.protocol === 'https:') return true + return url.protocol === 'http:' && isLoopbackHostname(url.hostname) + } catch { + return false + } +} + +export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT_ORIGIN) + ? process.env.SIM_DESKTOP_DEFAULT_ORIGIN + : 'https://sim.ai' + +/** + * The environment a build is keyed to, derived from its baked default origin. + * Channel drives the app's identity — its name and therefore its userData + * directory and single-instance lock — so one developer can keep a prod, + * staging, dev, and localhost install side by side, each with its own + * settings, sessions, and update feed. + */ +export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' + +export function channelForOrigin(origin: string): DesktopChannel { + try { + const host = new URL(origin).hostname.toLowerCase() + if (isLoopbackHostname(host)) return 'local' + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return 'dev' + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return 'staging' + return 'prod' + } catch { + return 'prod' + } +} + +/** + * Per-channel app names. Prod keeps the plain name every existing install + * already has (its userData must not move); the others are distinct apps. + */ +export const APP_NAME_FOR_CHANNEL: Record = { + prod: 'Sim', + staging: 'Sim Staging', + dev: 'Sim Dev', + local: 'Sim Local', +} + +export interface WindowBounds { + x?: number + y?: number + width: number + height: number +} + +export interface BrowserKnownSiteSetting { + hostname: string + lastVisitedAt: string + signInCompletedAt?: string +} + +export interface DesktopSettings { + origin: string + windowBounds?: WindowBounds + zoomLevel?: number + lastRoute?: string + themeBackground?: 'dark' | 'light' + blockThirdPartyAnalytics?: boolean + trayEnabled?: boolean + notificationsEnabled?: boolean + notificationSounds?: boolean + notificationsOnlyWhenUnfocused?: boolean + launchAtLogin?: boolean + autoDownloadUpdates?: boolean + browserEnabled?: boolean + terminalEnabled?: boolean + /** + * Where the agent terminal last was. A shell that always reopened in the + * home directory would drop the user back at square one every session, and + * `$HOME` is the worst possible working directory for tools that ask what + * they are allowed to touch. Restored on the next launch when it still + * exists. + */ + terminalCwd?: string + /** + * Top-level sites visited in the dedicated agent-browser profile. This is + * local inference metadata only; no cookies, credentials, or account data + * are persisted here. + */ + browserKnownSites?: BrowserKnownSiteSetting[] + /** + * URLs of user-pinned agent-browser tabs, in pinned-strip order. Pinned + * pages are restored locally when the browser resource is opened again. + */ + browserPinnedTabUrls?: string[] +} + +export type OriginValidation = { ok: true; origin: string } | { ok: false; error: string } + +/** + * Validates a user-supplied server origin. HTTPS is required except for + * localhost, which may use HTTP for local development and self-host testing. + * Returns the normalized origin (scheme + host + port, no path). + */ +export function validateOriginInput(raw: string): OriginValidation { + const trimmed = raw.trim() + if (!trimmed) { + return { ok: false, error: 'Server URL is required' } + } + let url: URL + try { + url = new URL(trimmed) + } catch { + return { ok: false, error: 'Enter a full URL, like https://sim.example.com' } + } + if (url.username || url.password) { + return { ok: false, error: 'Server URL must not contain credentials' } + } + if (url.protocol === 'https:') { + return { ok: true, origin: url.origin } + } + if (url.protocol === 'http:' && isLoopbackHostname(url.hostname)) { + return { ok: true, origin: url.origin } + } + return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' } +} + +/** + * Maps a server origin to its cookie/storage partition. Each origin gets an + * isolated persistent partition so sessions never leak across instances. + */ +export function partitionForOrigin(origin: string): string { + if (origin === DEFAULT_ORIGIN) { + return 'persist:sim' + } + return `persist:sim-${encodeURIComponent(origin)}` +} + +/** + * Validates that a value is a same-origin absolute path suitable for reload + * targets and returnTo handoffs (single leading slash, no scheme or host). + */ +export function isSafeInternalPath(path: unknown): path is string { + if (typeof path !== 'string' || path.length === 0 || path.length > 2048) { + return false + } + if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) { + return false + } + try { + const url = new URL(path, 'https://internal.invalid') + return url.origin === 'https://internal.invalid' + } catch { + return false + } +} + +const DEFAULT_SETTINGS: DesktopSettings = { + origin: DEFAULT_ORIGIN, + blockThirdPartyAnalytics: true, + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: true, + terminalEnabled: true, +} + +export interface ConfigStore { + readonly filePath: string + getOrigin(): string + setOrigin(origin: string): OriginValidation + get(key: K): DesktopSettings[K] + set(key: K, value: DesktopSettings[K]): void + /** Writes any debounced change immediately. Called on quit. */ + flush(): void +} + +/** + * How long settings changes coalesce before hitting disk. Long enough to + * absorb a burst (a resize drag, a run of navigations), short enough that a + * crash loses nothing a user would notice. + */ +const SAVE_DEBOUNCE_MS = 400 + +/** + * Whether a settings write is a no-op. Identity first, then structurally, + * because the array- and object-valued settings are rebuilt + * on every write and would never be reference-equal. + */ +function isSameSetting(current: unknown, next: unknown): boolean { + if (current === next) return true + return JSON.stringify(current) === JSON.stringify(next) +} + +/** + * Creates the desktop settings store backed by a single JSON file. Writes are + * atomic (temp file + rename) so a crash mid-write never corrupts settings. + * The SIM_DESKTOP_ORIGIN environment variable overrides the stored origin, + * which the e2e harness uses to point the app at a fixture server. + */ +export function createConfigStore( + filePath: string, + env: NodeJS.ProcessEnv = process.env +): ConfigStore { + let settings: DesktopSettings = { ...DEFAULT_SETTINGS } + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial + settings = { ...DEFAULT_SETTINGS, ...parsed } + const validated = validateOriginInput(settings.origin) + settings.origin = validated.ok ? validated.origin : DEFAULT_ORIGIN + } catch { + settings = { ...DEFAULT_SETTINGS } + } + + const envOverride = env.SIM_DESKTOP_ORIGIN ? validateOriginInput(env.SIM_DESKTOP_ORIGIN) : null + if (env.SIM_DESKTOP_ORIGIN && !envOverride?.ok) { + logger.warn('Ignoring invalid SIM_DESKTOP_ORIGIN override', { value: env.SIM_DESKTOP_ORIGIN }) + } + + let saveTimer: ReturnType | null = null + + /** Writes the whole file now and cancels any pending debounced write. */ + const writeNow = () => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = null + try { + writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + } catch (error) { + logger.error('Failed to persist desktop settings', { error }) + } + } + + /** + * Coalesces writes. `save` is a synchronous mkdir + write + rename of the + * whole settings file on the main thread, and the callers are not rare: the + * known-sites list is rewritten on browser navigation and sign-in, pinned + * tabs on every pin change, window bounds on every resize step. Debouncing + * here rather than per-caller is what makes that safe by default — two + * callers had already hand-rolled their own timers, and the ones that had + * not were paying a full fsync per event. + */ + const save = () => { + if (saveTimer) return + saveTimer = setTimeout(writeNow, SAVE_DEBOUNCE_MS) + saveTimer.unref?.() + } + + return { + filePath, + getOrigin() { + if (envOverride?.ok) { + return envOverride.origin + } + return settings.origin + }, + setOrigin(raw: string) { + const validated = validateOriginInput(raw) + if (validated.ok) { + settings.origin = validated.origin + // Not debounced: changing the origin tears the session down and + // reloads, so a pending write could be lost on the way out — and this + // is the one setting whose loss strands the app on the wrong server. + writeNow() + } + return validated + }, + get(key) { + return settings[key] + }, + set(key, value) { + // Structural, not `===`. Reference equality can never hold for the array + // values this store carries (known sites, pinned tabs, window bounds are + // all freshly built), so the guard was dead for exactly the keys written + // most often — every one of them fell through to a write. + if (isSameSetting(settings[key], value)) { + return + } + settings[key] = value + save() + }, + flush() { + if (!saveTimer) return + writeNow() + }, + } +} diff --git a/apps/desktop/src/main/context-menu.test.ts b/apps/desktop/src/main/context-menu.test.ts new file mode 100644 index 0000000000..9931eddd90 --- /dev/null +++ b/apps/desktop/src/main/context-menu.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { buildContextMenuTemplate } from '@/main/context-menu' + +const handlers = { + replaceMisspelling: vi.fn(), + addToDictionary: vi.fn(), + openLink: vi.fn(), + copyLink: vi.fn(), + inspect: vi.fn(), +} + +const baseParams = { + misspelledWord: '', + dictionarySuggestions: [] as string[], + isEditable: false, + selectionText: '', + linkURL: '', + x: 0, + y: 0, +} + +describe('buildContextMenuTemplate', () => { + it('returns nothing for bare canvas areas so custom web menus stay in charge', () => { + expect(buildContextMenuTemplate(baseParams, { isDev: true }, handlers)).toEqual([]) + }) + + it('offers edit roles in editable fields', () => { + const template = buildContextMenuTemplate( + { ...baseParams, isEditable: true }, + { isDev: false }, + handlers + ) + const roles = template.map((item) => item.role) + expect(roles).toContain('cut') + expect(roles).toContain('copy') + expect(roles).toContain('paste') + expect(roles).toContain('selectAll') + }) + + it('offers copy for plain selections', () => { + const template = buildContextMenuTemplate( + { ...baseParams, selectionText: 'hello' }, + { isDev: false }, + handlers + ) + expect(template.map((item) => item.role)).toEqual(['copy']) + }) + + it('offers spellcheck suggestions and add-to-dictionary', () => { + const template = buildContextMenuTemplate( + { + ...baseParams, + isEditable: true, + misspelledWord: 'wrokflow', + dictionarySuggestions: ['workflow', 'workflows'], + }, + { isDev: false }, + handlers + ) + const labels = template.map((item) => item.label) + expect(labels).toContain('workflow') + expect(labels).toContain('Add to Dictionary') + }) + + it('offers link actions', () => { + const template = buildContextMenuTemplate( + { ...baseParams, linkURL: 'https://docs.sim.ai' }, + { isDev: false }, + handlers + ) + const labels = template.map((item) => item.label) + expect(labels).toContain('Open Link in Browser') + expect(labels).toContain('Copy Link') + }) + + it('adds Inspect Element only in dev and only when a menu is shown anyway', () => { + const dev = buildContextMenuTemplate( + { ...baseParams, selectionText: 'x' }, + { isDev: true }, + handlers + ) + expect(dev.map((item) => item.label)).toContain('Inspect Element') + const packaged = buildContextMenuTemplate( + { ...baseParams, selectionText: 'x' }, + { isDev: false }, + handlers + ) + expect(packaged.map((item) => item.label)).not.toContain('Inspect Element') + }) +}) diff --git a/apps/desktop/src/main/context-menu.ts b/apps/desktop/src/main/context-menu.ts new file mode 100644 index 0000000000..689da90437 --- /dev/null +++ b/apps/desktop/src/main/context-menu.ts @@ -0,0 +1,112 @@ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron' +import { clipboard, Menu } from 'electron' +import { openExternalSafe } from '@/main/navigation' + +const MAX_SUGGESTIONS = 5 + +export interface ContextMenuDeps { + isDev: boolean + allowHttpLocalhost: boolean +} + +interface TemplateHandlers { + replaceMisspelling(word: string): void + addToDictionary(word: string): void + openLink(url: string): void + copyLink(url: string): void + inspect(x: number, y: number): void +} + +/** + * Builds the native right-click menu for a given context. Returns an empty + * template when there is nothing editable, selected, or linked — which leaves + * canvas areas and custom React context menus alone. + */ +export function buildContextMenuTemplate( + params: Pick< + ContextMenuParams, + | 'misspelledWord' + | 'dictionarySuggestions' + | 'isEditable' + | 'selectionText' + | 'linkURL' + | 'x' + | 'y' + >, + deps: { isDev: boolean }, + handlers: TemplateHandlers +): MenuItemConstructorOptions[] { + const template: MenuItemConstructorOptions[] = [] + + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, MAX_SUGGESTIONS)) { + template.push({ label: suggestion, click: () => handlers.replaceMisspelling(suggestion) }) + } + if (params.dictionarySuggestions.length === 0) { + template.push({ label: 'No Guesses Found', enabled: false }) + } + template.push( + { label: 'Add to Dictionary', click: () => handlers.addToDictionary(params.misspelledWord) }, + { type: 'separator' } + ) + } + + if (params.isEditable) { + template.push( + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { type: 'separator' }, + { role: 'selectAll' } + ) + } else if (params.selectionText.trim()) { + template.push({ role: 'copy' }) + } + + if (params.linkURL) { + if (template.length > 0) { + template.push({ type: 'separator' }) + } + template.push( + { label: 'Open Link in Browser', click: () => handlers.openLink(params.linkURL) }, + { label: 'Copy Link', click: () => handlers.copyLink(params.linkURL) } + ) + } + + if (deps.isDev && template.length > 0) { + template.push( + { type: 'separator' }, + { + label: 'Inspect Element', + click: () => handlers.inspect(params.x, params.y), + } + ) + } + + return template +} + +/** + * Attaches the native context menu with spellcheck suggestions to a + * WebContents. Areas with no text/link context get no native menu so the web + * app's own menus (workflow canvas, tables) keep owning the right-click. + */ +export function attachContextMenu(contents: WebContents, deps: ContextMenuDeps): void { + contents.on('context-menu', (_event, params) => { + const template = buildContextMenuTemplate( + params, + { isDev: deps.isDev }, + { + replaceMisspelling: (word) => contents.replaceMisspelling(word), + addToDictionary: (word) => contents.session.addWordToSpellCheckerDictionary(word), + openLink: (url) => void openExternalSafe(url, deps.allowHttpLocalhost), + copyLink: (url) => clipboard.writeText(url), + inspect: (x, y) => contents.inspectElement(x, y), + } + ) + if (template.length === 0) { + return + } + Menu.buildFromTemplate(template).popup() + }) +} diff --git a/apps/desktop/src/main/csp.test.ts b/apps/desktop/src/main/csp.test.ts new file mode 100644 index 0000000000..1319ca314f --- /dev/null +++ b/apps/desktop/src/main/csp.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { attachCspFallback, DEFAULT_DESKTOP_CSP } from '@/main/csp' + +type HeadersReceivedHandler = ( + details: { + url: string + resourceType: string + responseHeaders?: Record + }, + callback: (response: { responseHeaders?: Record }) => void +) => void + +function fakeSession() { + let handler: HeadersReceivedHandler | undefined + const ses = { + webRequest: { + onHeadersReceived: vi.fn((h: HeadersReceivedHandler) => { + handler = h + }), + }, + } + return { ses, run: () => handler } +} + +const APP_ORIGIN = 'https://sim.ai' + +describe('attachCspFallback', () => { + let session: ReturnType + + beforeEach(() => { + session = fakeSession() + attachCspFallback( + session.ses as unknown as Parameters[0], + () => APP_ORIGIN + ) + }) + + it('injects the fallback CSP on an app-origin document lacking one', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/workspace`, resourceType: 'mainFrame', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({ + responseHeaders: { 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + }) + + it('never overrides a server-sent CSP', () => { + const cb = vi.fn() + session.run()?.( + { + url: `${APP_ORIGIN}/workspace`, + resourceType: 'mainFrame', + responseHeaders: { 'content-security-policy': ["default-src 'self'"] }, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves subresources untouched', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/app.js`, resourceType: 'script', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves non-app-origin documents untouched', () => { + const cb = vi.fn() + session.run()?.( + { + url: 'https://accounts.google.com/o/oauth2', + resourceType: 'mainFrame', + responseHeaders: {}, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) +}) diff --git a/apps/desktop/src/main/csp.ts b/apps/desktop/src/main/csp.ts new file mode 100644 index 0000000000..5d956f21e2 --- /dev/null +++ b/apps/desktop/src/main/csp.ts @@ -0,0 +1,47 @@ +import { createLogger } from '@sim/logger' +import type { Session } from 'electron' +import { isAppOrigin } from '@/main/navigation' + +const logger = createLogger('DesktopCsp') + +/** + * A minimal, non-drifting Content-Security-Policy applied ONLY to an app-origin + * top-level document whose response ships no CSP of its own. + * + * The hosted web app sends a full, env-aware policy on every response (see + * `apps/sim/lib/core/security/csp.ts`), which the shell can neither import + * (monorepo boundary) nor safely duplicate (it varies by env). This is a + * defense-in-depth backstop for the narrow case where that header is somehow + * absent — a deliberately small subset of the server's own base directives, so + * it can never be stricter than what the app already depends on and cannot + * break embeds or integrations. + */ +export const DEFAULT_DESKTOP_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + +function hasCspHeader(headers: Record): boolean { + return Object.keys(headers).some((key) => key.toLowerCase() === 'content-security-policy') +} + +/** + * Installs the CSP fallback on a session. Runs on `onHeadersReceived` (a + * distinct event from telemetry-policy's `onBeforeRequest`, so the two coexist) + * and leaves every response untouched except an app-origin main-frame document + * that carries no CSP, which gets {@link DEFAULT_DESKTOP_CSP}. + */ +export function attachCspFallback(ses: Session, appOrigin: () => string): void { + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = details.responseHeaders ?? {} + if ( + details.resourceType === 'mainFrame' && + isAppOrigin(details.url, appOrigin()) && + !hasCspHeader(headers) + ) { + logger.info('Injecting fallback CSP for app document without one') + callback({ + responseHeaders: { ...headers, 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + return + } + callback({}) + }) +} diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts new file mode 100644 index 0000000000..f94fb13219 --- /dev/null +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { app, BrowserWindow } from 'electron' +import { createConfigStore } from '@/main/config' +import { createDesktopSettingsService } from '@/main/desktop-settings' +import { Notification } from '@/test/electron-mock' + +function makeService() { + const config = createConfigStore( + join(mkdtempSync(join(tmpdir(), 'sim-desktop-settings-')), 'settings.json'), + {} + ) + const window = new BrowserWindow() + const openMainWindowAt = vi.fn() + const setAutoDownloadUpdates = vi.fn() + const setTrayEnabled = vi.fn() + const setBrowserEnabled = vi.fn() + const setTerminalEnabled = vi.fn() + const service = createDesktopSettingsService({ + config, + getMainWindow: () => window, + openMainWindowAt, + setAutoDownloadUpdates, + setTrayEnabled, + setBrowserEnabled, + setTerminalEnabled, + }) + return { + config, + window, + openMainWindowAt, + setAutoDownloadUpdates, + setTrayEnabled, + setBrowserEnabled, + setTerminalEnabled, + service, + } +} + +describe('desktop settings service', () => { + beforeEach(() => { + Notification.instances.length = 0 + Notification.isSupported.mockReturnValue(true) + vi.mocked(app.setLoginItemSettings).mockClear() + Object.defineProperty(app, 'isPackaged', { configurable: true, value: false }) + }) + + it('persists preferences and applies live updater changes', () => { + const { config, service, setAutoDownloadUpdates } = makeService() + expect(service.getPreferences()).toMatchObject({ + notificationsEnabled: true, + notificationsOnlyWhenUnfocused: true, + autoDownloadUpdates: true, + trayEnabled: true, + }) + + service.setPreference('autoDownloadUpdates', false) + expect(config.get('autoDownloadUpdates')).toBe(false) + expect(setAutoDownloadUpdates).toHaveBeenCalledWith(false) + }) + + it('persists tray visibility and applies it immediately', () => { + const { config, service, setTrayEnabled } = makeService() + service.setPreference('trayEnabled', false) + expect(config.get('trayEnabled')).toBe(false) + expect(setTrayEnabled).toHaveBeenCalledWith(false) + expect(service.getPreferences().trayEnabled).toBe(false) + }) + + it('tears down the browser and terminal when their surfaces are switched off', () => { + const { config, service, setBrowserEnabled, setTerminalEnabled } = makeService() + expect(service.getPreferences()).toMatchObject({ + browserEnabled: true, + terminalEnabled: true, + }) + + service.setPreference('browserEnabled', false) + service.setPreference('terminalEnabled', false) + expect(config.get('browserEnabled')).toBe(false) + expect(config.get('terminalEnabled')).toBe(false) + expect(setBrowserEnabled).toHaveBeenCalledWith(false) + expect(setTerminalEnabled).toHaveBeenCalledWith(false) + }) + + it('applies login-item changes only for packaged builds', () => { + const { service } = makeService() + service.setPreference('launchAtLogin', true) + expect(app.setLoginItemSettings).not.toHaveBeenCalled() + + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + service.setPreference('launchAtLogin', false) + expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false }) + }) + + it('shows notifications only when allowed and opens their route on click', () => { + const { window, openMainWindowAt, service } = makeService() + vi.mocked(window.isFocused).mockReturnValue(true) + expect(service.notify({ title: 'Done', body: 'Ready' })).toBe(false) + + vi.mocked(window.isFocused).mockReturnValue(false) + expect( + service.notify({ + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + ).toBe(true) + + const notification = Notification.instances[0] + expect(notification.options).toMatchObject({ silent: false }) + expect(notification.show).toHaveBeenCalled() + const click = notification.on.mock.calls.find(([event]) => event === 'click')?.[1] + expect(click).toBeTypeOf('function') + ;(click as () => void)() + expect(openMainWindowAt).toHaveBeenCalledWith('/workspace/ws1/chat/c1') + }) +}) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts new file mode 100644 index 0000000000..abc6327d29 --- /dev/null +++ b/apps/desktop/src/main/desktop-settings.ts @@ -0,0 +1,147 @@ +import type { + DesktopNotificationPayload, + DesktopPreferenceKey, + DesktopPreferences, +} from '@sim/desktop-bridge' +import type { BrowserWindow } from 'electron' +import { app, Notification } from 'electron' +import type { ConfigStore } from '@/main/config' +import { isSafeInternalPath } from '@/main/config' + +/** + * Every key the shell accepts over the settings IPC channel: the closed + * `setPreference` union plus preferences added after the first release, which + * ride their own optional bridge setters but share this channel. + */ +export type DesktopSettingKey = + | DesktopPreferenceKey + | 'trayEnabled' + | 'browserEnabled' + | 'terminalEnabled' + +const PREFERENCE_KEYS: ReadonlySet = new Set([ + 'notificationsEnabled', + 'notificationSounds', + 'notificationsOnlyWhenUnfocused', + 'launchAtLogin', + 'autoDownloadUpdates', + 'trayEnabled', + 'browserEnabled', + 'terminalEnabled', +]) + +export function isDesktopPreferenceKey(value: unknown): value is DesktopSettingKey { + return typeof value === 'string' && PREFERENCE_KEYS.has(value) +} + +export interface DesktopSettingsService { + getPreferences(): DesktopPreferences + setPreference(key: DesktopSettingKey, value: boolean): DesktopPreferences + notify(payload: DesktopNotificationPayload): boolean + applySystemPreferences(): void +} + +interface DesktopSettingsServiceDeps { + config: ConfigStore + getMainWindow: () => BrowserWindow | null + openMainWindowAt: (route?: string) => void + setAutoDownloadUpdates: (enabled: boolean) => void + /** Installs or tears down the menu-bar status item immediately. */ + setTrayEnabled: (enabled: boolean) => void + /** Ends the running agent-browser session when the surface is turned off. */ + setBrowserEnabled: (enabled: boolean) => void + /** Ends every open agent shell when the surface is turned off. */ + setTerminalEnabled: (enabled: boolean) => void +} + +function readPreferences(config: ConfigStore): DesktopPreferences { + return { + notificationsEnabled: config.get('notificationsEnabled') ?? true, + notificationSounds: config.get('notificationSounds') ?? true, + notificationsOnlyWhenUnfocused: config.get('notificationsOnlyWhenUnfocused') ?? true, + launchAtLogin: config.get('launchAtLogin') ?? false, + autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true, + trayEnabled: config.get('trayEnabled') ?? true, + browserEnabled: config.get('browserEnabled') ?? true, + terminalEnabled: config.get('terminalEnabled') ?? true, + } +} + +/** + * Owns device preferences and their native side effects. Renderer code can + * request a change, but only this main-process service touches login items, + * updater policy, window focus, or OS notifications. + */ +export function createDesktopSettingsService( + deps: DesktopSettingsServiceDeps +): DesktopSettingsService { + const applyLaunchAtLogin = (enabled: boolean) => { + // Registering an unpackaged Electron binary at login is surprising and + // points at the wrong executable. Persist the dev preference, then apply + // it when the packaged app starts. + if (app.isPackaged) { + app.setLoginItemSettings({ openAtLogin: enabled }) + } + } + + return { + getPreferences: () => readPreferences(deps.config), + setPreference(key, value) { + deps.config.set(key, value) + // Not debounced. Every branch below takes effect immediately, and + // `launchAtLogin` writes state the OS keeps after we exit — so a kill + // inside the debounce window would leave the login item registered + // while the switch that registered it reads off, and toggling it back + // on would be a no-op the store considers unchanged. + deps.config.flush() + switch (key) { + case 'launchAtLogin': + applyLaunchAtLogin(value) + break + case 'autoDownloadUpdates': + deps.setAutoDownloadUpdates(value) + break + case 'trayEnabled': + deps.setTrayEnabled(value) + break + case 'browserEnabled': + deps.setBrowserEnabled(value) + break + case 'terminalEnabled': + deps.setTerminalEnabled(value) + break + default: + break + } + return readPreferences(deps.config) + }, + notify(payload) { + const preferences = readPreferences(deps.config) + if (!preferences.notificationsEnabled || !Notification.isSupported()) { + return false + } + const window = deps.getMainWindow() + if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) { + return false + } + + const notification = new Notification({ + title: payload.title, + body: payload.body, + silent: !preferences.notificationSounds, + }) + notification.on('click', () => { + deps.openMainWindowAt( + payload.route && isSafeInternalPath(payload.route) ? payload.route : undefined + ) + }) + notification.show() + return true + }, + applySystemPreferences() { + const preferences = readPreferences(deps.config) + applyLaunchAtLogin(preferences.launchAtLogin) + deps.setAutoDownloadUpdates(preferences.autoDownloadUpdates) + }, + } +} diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebb670946e8dd396df881fbd16f3ae84181a4396 GIT binary patch literal 1390 zcmb7EOKaOe5boK3qI26dSdX|(3xSl-*QwA;pwQb`8rxg1cGcanl{DnPcO*qh#- z8_a5EzQ=qkQ`J5&+(AjP2xdhKpqrWsUVxF`!N5JNgRejx3{zw^n;lGA`C_LPS*Z)& zsRigcTz&x44b$o>YfH1?dD51v`Fu9(3j{5qGi?6QS7XUxC8%+~*+>(W!_f$Ql~rh5 zR{F+Sk7X1J16i$(2aR0rW*`c*a_lN283V^3hPN_Y zOp~UMq}OO8wWIIF#a>wCVCvYClpR*E8A|7A_ zRsC^$*s*=YE>MtpOX8l}@b<{JlQ^hS8A-_cJkLd1-`z|6MloJ*(R~kJe(%xhrBw8K zTi7QHKhDVnykD8~jV#Mi1D}yet7X1?m*nr0v-9h-<;9!);_Z2w=lM@j^ABrD^D5UB-O3Fq{8jd8eif&+#SjBU&k}J@&Et; literal 0 HcmV?d00001 diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts new file mode 100644 index 0000000000..c9cbad9ea0 --- /dev/null +++ b/apps/desktop/src/main/downloads.ts @@ -0,0 +1,78 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { Session } from 'electron' +import { app } from 'electron' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopDownloads') + +const MAX_FILENAME_LENGTH = 200 + +const MIME_EXTENSIONS: Record = { + 'text/csv': '.csv', + 'application/json': '.json', + 'application/pdf': '.pdf', + 'application/zip': '.zip', + 'text/plain': '.txt', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/svg+xml': '.svg', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', +} + +/** + * Strips path separators and control characters from a server- or + * blob-suggested filename so it can never escape the chosen directory. + */ +export function sanitizeFilename(name: string): string { + const cleaned = name + .replace(/[/\\]/g, '_') + .replace(/[\u0000-\u001f]/g, '') + .replace(/^\.+/, '') + .trim() + return cleaned.slice(0, MAX_FILENAME_LENGTH) +} + +/** + * Resolves the save-dialog default name. Blob downloads often arrive with no + * usable filename — fall back to a timestamped name with a mime-derived + * extension. + */ +export function suggestedFilename( + rawName: string, + mimeType: string, + now: Date = new Date() +): string { + const sanitized = sanitizeFilename(rawName) + if (sanitized && sanitized !== 'download') { + return sanitized + } + const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19) + const extension = MIME_EXTENSIONS[mimeType] ?? '' + return `download-${stamp}${extension}` +} + +/** + * Wires will-download so exports, blob URLs, and presigned-URL downloads all + * get a native save dialog with a sensible default name, and completed + * downloads bounce the Dock Downloads stack. + */ +export function attachDownloadHandling(session: Session, events: EventRecorder): void { + session.on('will-download', (_event, item) => { + const filename = suggestedFilename(item.getFilename(), item.getMimeType()) + item.setSaveDialogOptions({ + defaultPath: join(app.getPath('downloads'), filename), + }) + item.once('done', (_doneEvent, state) => { + if (state === 'completed') { + logger.info('Download completed', { filename }) + if (process.platform === 'darwin') { + app.dock?.downloadFinished(item.getSavePath()) + } + } else if (state === 'interrupted') { + logger.warn('Download interrupted', { filename }) + events.record('load_failure', { kind: 'download-interrupted' }) + } + }) + }) +} diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts new file mode 100644 index 0000000000..3682c8eeca --- /dev/null +++ b/apps/desktop/src/main/handoff.test.ts @@ -0,0 +1,272 @@ +import { get as httpGet } from 'node:http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + buildRedeemScript, + type ConnectHandoffCallback, + createHandoffManager, + type HandoffCallback, + type HandoffCallbacks, + type HandoffManagerDeps, +} from '@/main/handoff' +import type { EventRecorder } from '@/main/observability' + +const VALID_STATE = 'a'.repeat(32) +const VALID_TOKEN = 'tok_1234567890abcdef' + +function makeEvents(): EventRecorder { + return { filePath: '/tmp/none', record: vi.fn() } +} + +function makeDeps(overrides: Partial = {}): HandoffManagerDeps { + return { + origin: () => 'https://sim.ai', + openExternal: vi.fn(async () => true), + events: makeEvents(), + currentUserId: vi.fn(async () => 'user-1'), + ...overrides, + } +} + +function makeCallbacks(overrides: Partial = {}): HandoffCallbacks { + return { onLogin: () => {}, onConnect: () => {}, ...overrides } +} + +describe('buildRedeemScript', () => { + it('embeds the token JSON-escaped, targets the verify endpoint, and returns the status', () => { + const script = buildRedeemScript('abc"def') + expect(script).toContain('/api/auth/one-time-token/verify') + expect(script).toContain("credentials: 'include'") + expect(script).toContain(JSON.stringify(JSON.stringify({ token: 'abc"def' }))) + expect(script).toContain('return response.status') + }) +}) + +describe('createHandoffManager', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('begin opens the landing page with state and the loopback port', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + const opened = await manager.begin() + expect(opened).toBe(true) + + const openExternal = vi.mocked(deps.openExternal) + expect(openExternal).toHaveBeenCalledTimes(1) + const landing = new URL(openExternal.mock.calls[0][0]) + expect(landing.origin).toBe('https://sim.ai') + expect(landing.pathname).toBe('/desktop/auth') + expect(landing.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{32}$/) + expect(Number(landing.searchParams.get('port'))).toBeGreaterThan(0) + manager.clear() + }) + + it('consume is single-use, state-bound, and TTL-bound', async () => { + let nowValue = 1_000_000 + const deps = makeDeps({ now: () => nowValue }) + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.begin() + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + + expect(manager.consume('z'.repeat(32), 'login')).toBe(false) + expect(manager.consume(state, 'login')).toBe(true) + expect(manager.consume(state, 'login')).toBe(false) + + await manager.begin() + const secondState = new URL(vi.mocked(deps.openExternal).mock.calls[1][0]).searchParams.get( + 'state' + ) as string + nowValue += 31 * 60 * 1000 + expect(manager.consume(secondState, 'login')).toBe(false) + manager.clear() + }) + + it('loopback accepts one valid callback, rejects bad input, then closes', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const port = landing.searchParams.get('port') as string + const state = landing.searchParams.get('state') as string + const base = `http://127.0.0.1:${port}` + + expect((await fetch(`${base}/other`)).status).toBe(404) + + const badToken = await fetch(`${base}/auth/callback?token=bad token&state=${state}`) + expect(badToken.status).toBe(400) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { + redirect: 'manual', + }) + // Hands the browser back to a real app page rather than serving HTML from + // the main process, so the closing screen matches the rest of Sim. + expect(ok.status).toBe(302) + expect(ok.headers.get('location')).toBe('https://sim.ai/desktop/done?kind=auth') + expect(received).toEqual([{ token: VALID_TOKEN, state }]) + + await expect( + fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { redirect: 'manual' }) + ).rejects.toThrow() + }) + + it('rejects a wrong-state callback without killing the sign-in', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const base = `http://127.0.0.1:${landing.searchParams.get('port')}` + const state = landing.searchParams.get('state') as string + + // This port is reachable by any local process, and by any page the user + // has open via a no-CORS GET. Tearing the one-shot server down before + // checking the state let any of them cancel the sign-in. + const wrongState = await fetch( + `${base}/auth/callback?token=${VALID_TOKEN}&state=${'z'.repeat(32)}` + ) + expect(wrongState.status).toBe(403) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { + redirect: 'manual', + }) + expect(ok.status).toBe(302) + expect(received).toEqual([{ token: VALID_TOKEN, state }]) + }) + + it('refuses a request that does not address the loopback by name', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const state = landing.searchParams.get('state') as string + + // The DNS-rebinding shape: an attacker hostname resolving to 127.0.0.1. + const status = await new Promise((resolvePromise, rejectPromise) => { + const request = httpGet( + { + host: '127.0.0.1', + port: Number(landing.searchParams.get('port')), + path: `/auth/callback?token=${VALID_TOKEN}&state=${state}`, + headers: { Host: 'attacker.example' }, + }, + (response) => { + response.resume() + resolvePromise(response.statusCode ?? 0) + } + ) + request.on('error', rejectPromise) + }) + + expect(status).toBe(403) + expect(received).toHaveLength(0) + manager.clear() + }) + + it('cleans up the pending handoff when the browser cannot be opened', async () => { + const deps = makeDeps({ openExternal: vi.fn(async () => false) }) + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.begin() + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + expect(manager.consume(state, 'login')).toBe(false) + }) + it('beginConnect opens /desktop/connect with provider, state, and port', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + expect(await manager.beginConnect('not a provider!')).toBe(false) + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.pathname).toBe('/desktop/connect') + expect(landing.searchParams.get('provider')).toBe('google-email') + expect(landing.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{32}$/) + expect(Number(landing.searchParams.get('port'))).toBeGreaterThan(0) + manager.clear() + }) + + it('connect loopback forwards state and optional error, rejecting bad slugs', async () => { + const received: ConnectHandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onConnect: (callback) => received.push(callback) }) + ) + await manager.beginConnect('google-email') + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const base = `http://127.0.0.1:${landing.searchParams.get('port')}` + const state = landing.searchParams.get('state') as string + + const badError = await fetch(`${base}/connect/callback?state=${state}&error=${'x'.repeat(80)}`) + expect(badError.status).toBe(400) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/connect/callback?state=${state}&error=oauth_failed`, { + redirect: 'manual', + }) + expect(ok.status).toBe(302) + expect(ok.headers.get('location')).toBe('https://sim.ai/desktop/done?kind=connect') + expect(received).toEqual([{ state, error: 'oauth_failed' }]) + expect(manager.consume(state, 'connect')).toBe(true) + }) + + it('consume enforces the handoff kind', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.beginConnect('google-email') + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + expect(manager.consume(state, 'login')).toBe(false) + expect(manager.consume(state, 'connect')).toBe(true) + }) +}) + +describe('connect handoff account pinning', () => { + it('pins the connect flow to the account the app is signed in as', async () => { + // The OAuth flow runs in the browser under the BROWSER's session, which is + // a different row from the app's — without this the credential would attach + // to whichever account the browser happens to be signed into. + const deps = makeDeps({ currentUserId: vi.fn(async () => 'desktop-user') }) + const manager = createHandoffManager(deps, makeCallbacks()) + + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.pathname).toBe('/desktop/connect') + expect(landing.searchParams.get('user')).toBe('desktop-user') + manager.clear() + }) + + it('omits the pin when the app account cannot be read', async () => { + // Offline or signed out: fall back to the page's own login redirect rather + // than blocking a connect on a failed probe. + const deps = makeDeps({ currentUserId: vi.fn(async () => null) }) + const manager = createHandoffManager(deps, makeCallbacks()) + + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.searchParams.has('user')).toBe(false) + manager.clear() + }) +}) diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts new file mode 100644 index 0000000000..add823f9a9 --- /dev/null +++ b/apps/desktop/src/main/handoff.ts @@ -0,0 +1,469 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { generateShortId } from '@sim/utils/id' +import type { BrowserWindow } from 'electron' +import { app, dialog } from 'electron' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopHandoff') + +const TOKEN_PATTERN = /^[A-Za-z0-9_.-]{8,512}$/ +const STATE_PATTERN = /^[A-Za-z0-9_-]{16,256}$/ +const STATE_LENGTH = 32 +const REDEEM_PATH = '/api/auth/one-time-token/verify' +const CALLBACK_PATH = '/auth/callback' +const CONNECT_CALLBACK_PATH = '/connect/callback' +/** OAuth providerIds are kebab-case service slugs (e.g. "google-email"). */ +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/ +/** OAuth error codes forwarded by the connect complete page. */ +const ERROR_SLUG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/ +// Measured from begin() (when the browser opens) so it comfortably covers a +// full interactive login — email/OTP round-trips or OAuth consent — not just +// the redirect back. Bounds how long the loopback listener and the CSRF state +// stay valid. +const HANDOFF_TTL_MS = 30 * 60 * 1000 + +/** + * Where the browser lands once the loopback has taken the callback. Redirecting + * to a real app page — rather than serving HTML from here — keeps the closing + * screen on Sim's design system instead of a page hand-rolled in the main + * process. Purely informational: the handoff has already completed by then. + */ +const DONE_PATH = '/desktop/done' + +export type HandoffKind = 'login' | 'connect' + +export interface HandoffCallback { + token: string + state: string +} + +export interface ConnectHandoffCallback { + state: string + error?: string +} + +export interface HandoffCallbacks { + onLogin: (callback: HandoffCallback) => void + onConnect: (callback: ConnectHandoffCallback) => void +} + +export interface HandoffManagerDeps { + origin: () => string + openExternal: (url: string) => Promise + events: EventRecorder + /** + * The account the app is signed in as, used to pin an OAuth connect to it. + * See {@link HandoffManager.beginConnect}. + */ + currentUserId: () => Promise + now?: () => number +} + +/** Optional scope a chip-initiated connect carries into /desktop/connect. */ +export interface ConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface HandoffManager { + begin(): Promise + beginConnect(providerId: string, scope?: ConnectScope): Promise + consume(state: string, kind: HandoffKind): boolean + clear(): void +} + +/** + * Owns the system-browser handoffs — login and OAuth connect. The only + * callback channel is a one-shot 127.0.0.1 loopback server (RFC 8252 §7.3) — + * no OS scheme registration, works identically in dev and packaged builds. + * Because the app is always running when the browser redirects back (it + * started the loopback), the pending state lives in memory: single-flight, + * single-use, constant-time compared, TTL-bounded. Starting a new handoff of + * either kind supersedes the previous pending one. + */ +export function createHandoffManager( + deps: HandoffManagerDeps, + callbacks: HandoffCallbacks +): HandoffManager { + const now = deps.now ?? Date.now + let loopbackServer: Server | null = null + let loopbackTimer: NodeJS.Timeout | undefined + let pending: { state: string; createdAt: number; kind: HandoffKind } | null = null + + const stopLoopback = () => { + clearTimeout(loopbackTimer) + loopbackTimer = undefined + if (loopbackServer) { + loopbackServer.close() + loopbackServer = null + } + } + + /** + * The loopback route table: each hand-back kind declares its path, the + * "return to the app" page, and a parser that validates the query params + * and returns the callback dispatch (or null → 400). Adding a handoff kind + * is one new row. + */ + interface LoopbackRoute { + kind: HandoffKind + parse: (url: URL) => { state: string; dispatch: () => void } | null + } + const routes: Record = { + [CALLBACK_PATH]: { + kind: 'login', + parse: (url) => { + const token = url.searchParams.get('token') ?? '' + const state = url.searchParams.get('state') ?? '' + if (!TOKEN_PATTERN.test(token) || !STATE_PATTERN.test(state)) { + return null + } + return { state, dispatch: () => callbacks.onLogin({ token, state }) } + }, + }, + [CONNECT_CALLBACK_PATH]: { + kind: 'connect', + parse: (url) => { + const state = url.searchParams.get('state') ?? '' + const error = url.searchParams.get('error') + if (!STATE_PATTERN.test(state) || (error !== null && !ERROR_SLUG_PATTERN.test(error))) { + return null + } + return { + state, + dispatch: () => callbacks.onConnect({ state, ...(error !== null ? { error } : {}) }), + } + }, + }, + } + + /** + * Non-consuming state check, so a caller that does not know the state cannot + * shut the loopback down. The authoritative single-use consume still happens + * in the callback. + */ + const matchesPending = (state: string): boolean => + pending !== null && + now() - pending.createdAt <= HANDOFF_TTL_MS && + safeCompare(pending.state, state) + + /** + * The listener is reachable by any local process, and by any web page the + * user has open via a no-CORS GET. Requiring a loopback Host blocks the + * DNS-rebinding shape, where an attacker's hostname resolves to 127.0.0.1. + */ + const isLoopbackHost = (host: string | undefined): boolean => { + const hostname = (host ?? '').replace(/:\d+$/, '') + return hostname === '127.0.0.1' || hostname === 'localhost' + } + + const startLoopback = async (): Promise => { + stopLoopback() + const server = createServer((request, response) => { + if (!isLoopbackHost(request.headers.host)) { + response.writeHead(403, { 'Content-Type': 'text/plain' }).end('Forbidden') + return + } + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + const route = request.method === 'GET' ? routes[url.pathname] : undefined + if (!route) { + response.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found') + return + } + const callback = route.parse(url) + if (!callback) { + response.writeHead(400, { 'Content-Type': 'text/plain' }).end('Invalid request') + return + } + // Check the state BEFORE tearing anything down. Previously any request + // with a well-formed state killed this one-shot server, so a local + // process — or any page the user had open — could cancel a sign-in it + // could not otherwise touch. + if (!matchesPending(callback.state)) { + response.writeHead(403, { 'Content-Type': 'text/plain' }).end('Forbidden') + return + } + const done = new URL(DONE_PATH, deps.origin()) + done.searchParams.set('kind', route.kind === 'login' ? 'auth' : 'connect') + response.writeHead(302, { Location: done.toString() }).end() + stopLoopback() + callback.dispatch() + }) + loopbackServer = server + try { + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise) + server.listen(0, '127.0.0.1', () => resolvePromise()) + }) + } catch (error) { + logger.error('Could not start the loopback server', { error }) + loopbackServer = null + return undefined + } + loopbackTimer = setTimeout(stopLoopback, HANDOFF_TTL_MS) + const address = server.address() + return typeof address === 'object' && address ? address.port : undefined + } + + const clear = () => { + stopLoopback() + pending = null + } + + const beginFlow = async ( + kind: HandoffKind, + landingPath: string, + params: Record + ): Promise => { + const state = generateShortId(STATE_LENGTH) + // startLoopback() already tore down any prior server; if this bind fails, + // clear the now-orphaned pending so a superseded flow can't linger as a + // dangling entry pointing at a server that no longer exists. + const port = await startLoopback() + if (!port) { + clear() + return false + } + pending = { state, createdAt: now(), kind } + const landing = new URL(landingPath, deps.origin()) + for (const [key, value] of Object.entries(params)) { + landing.searchParams.set(key, value) + } + landing.searchParams.set('state', state) + landing.searchParams.set('port', String(port)) + deps.events.record(kind === 'login' ? 'handoff_started' : 'connect_handoff_started') + const opened = await deps.openExternal(landing.toString()) + if (!opened) { + clear() + } + return opened + } + + return { + begin() { + return beginFlow('login', '/desktop/auth', {}) + }, + async beginConnect(providerId: string, scope: ConnectScope = {}) { + if (!PROVIDER_ID_PATTERN.test(providerId)) { + logger.warn('Rejected connect handoff for invalid providerId') + return false + } + // The whole OAuth flow runs in the browser, under whatever account the + // browser is signed into — which is no longer guaranteed to be this app's + // account. Pin the flow to the app's user so a mismatch is refused instead + // of quietly attaching the credential to the wrong account. Omitted when + // unknown (offline, signed out): the page then falls back to its normal + // login redirect rather than blocking a connect on a failed probe. + const userId = await deps.currentUserId() + return beginFlow('connect', '/desktop/connect', { + provider: providerId, + ...(userId ? { user: userId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), + }) + }, + consume(state: string, kind: HandoffKind) { + if (!pending || pending.kind !== kind) { + return false + } + if (now() - pending.createdAt > HANDOFF_TTL_MS) { + clear() + return false + } + if (!safeCompare(pending.state, state)) { + return false + } + clear() + return true + }, + clear, + } +} + +/** Outcome of a token redeem. `status` is the verify endpoint's HTTP status, + * or 0 for a network/exec error, or -1 when the window was unavailable. */ +export const REDEEM_OK_STATUS = 200 +export const REDEEM_NETWORK_ERROR = 0 +export const REDEEM_WINDOW_UNAVAILABLE = -1 + +/** + * Builds the renderer-side script that redeems a one-time token. Running it in + * the app-origin renderer makes the request genuinely same-origin, so + * better-auth's trustedOrigins/CSRF checks pass and the Set-Cookie lands in the + * app partition. Resolves to the HTTP status (or 0 on a network error) so a + * failure surfaces the real cause — 403 = untrusted origin, 400 = bad/expired + * token, 0 = unreachable. + */ +export function buildRedeemScript(token: string): string { + const body = JSON.stringify(JSON.stringify({ token })) + return `(async () => { + try { + const response = await fetch('${REDEEM_PATH}', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: ${body}, + }) + return response.status + } catch { + return ${REDEEM_NETWORK_ERROR} + } +})()` +} + +/** + * Redeems a one-time token from the app-partition renderer and returns the + * verify endpoint's HTTP status (200 on success). If the window is currently + * off-origin (offline page, in-window IdP flow) it first loads the login page + * so the redeem fetch is same-origin. + */ +export async function redeemToken( + win: BrowserWindow, + origin: string, + token: string +): Promise { + if (win.isDestroyed()) { + return REDEEM_WINDOW_UNAVAILABLE + } + const contents = win.webContents + if (!contents.getURL().startsWith(`${origin}/`)) { + try { + await win.loadURL(`${origin}/login`) + } catch { + return REDEEM_WINDOW_UNAVAILABLE + } + } + try { + const status = await contents.executeJavaScript(buildRedeemScript(token), true) + return typeof status === 'number' ? status : REDEEM_NETWORK_ERROR + } catch (error) { + logger.error('Token redeem failed', { error }) + return REDEEM_NETWORK_ERROR + } +} + +export interface AuthFlowDeps { + handoff: HandoffManager + origin: () => string + events: EventRecorder + ensureMainWindow: () => Promise +} + +export interface AuthFlow { + beginLoginHandoff(): Promise + handleCallback(callback: HandoffCallback): Promise +} + +/** + * Orchestrates the login handoff: opening the system browser, consuming the + * loopback callback, redeeming the token, and navigating to the workspace. A + * failed or expired callback never leaves a partial session — the window lands + * back on /login. + */ +export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { + const failInWindow = async (win: BrowserWindow, reason: string, status?: number) => { + deps.events.record( + 'handoff_redeem_fail', + status === undefined ? { reason } : { reason, status } + ) + void dialog.showMessageBox(win, { + type: 'error', + message: 'Sign-in failed', + detail: 'The sign-in could not be completed. Try signing in again.', + }) + try { + await win.loadURL(`${deps.origin()}/login`) + } catch {} + } + + return { + async beginLoginHandoff() { + const opened = await deps.handoff.begin() + if (!opened) { + const win = await deps.ensureMainWindow() + void dialog.showMessageBox(win, { + type: 'error', + message: 'Couldn’t start sign-in', + detail: 'Sim could not open your browser to sign in. Try again.', + }) + } + }, + async handleCallback(callback: HandoffCallback) { + const win = await deps.ensureMainWindow() + if (!deps.handoff.consume(callback.state, 'login')) { + await failInWindow(win, 'state') + return + } + const origin = deps.origin() + const status = await redeemToken(win, origin, callback.token) + if (status !== REDEEM_OK_STATUS) { + await failInWindow(win, 'redeem', status) + return + } + deps.events.record('handoff_redeem_ok') + try { + await win.loadURL(`${origin}/workspace`) + } catch {} + win.show() + win.focus() + app.focus({ steal: true }) + }, + } +} + +/** Outcome pushed to the renderer when an OAuth connect handoff finishes. */ +export interface ConnectHandoffResult { + ok: boolean + error?: string +} + +export interface ConnectFlowDeps { + handoff: HandoffManager + events: EventRecorder + focusMainWindow: () => void + notifyRenderer: (result: ConnectHandoffResult) => void +} + +export interface ConnectFlow { + beginConnectHandoff(providerId: string, scope?: ConnectScope): Promise + handleCallback(callback: ConnectHandoffCallback): void +} + +/** + * Orchestrates the OAuth connect handoff: the whole OAuth flow — initiation, + * consent, callback — runs in the system browser (better-auth binds state to + * the initiating user agent's cookies, so the flow cannot be split between + * app and browser). The browser's /desktop/connect/complete page bounces to + * the loopback; this flow then refocuses the app and notifies the renderer, + * which refreshes its credential caches and shows the standard connected + * toast. + */ +export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow { + return { + async beginConnectHandoff(providerId: string, scope?: ConnectScope) { + const opened = await deps.handoff.beginConnect(providerId, scope) + if (!opened) { + deps.events.record('connect_handoff_open_fail') + } + return opened + }, + handleCallback(callback: ConnectHandoffCallback) { + if (!deps.handoff.consume(callback.state, 'connect')) { + deps.events.record('connect_handoff_state_fail') + return + } + if (callback.error === undefined) { + deps.events.record('connect_handoff_ok') + deps.focusMainWindow() + deps.notifyRenderer({ ok: true }) + return + } + deps.events.record('connect_handoff_error', { error: callback.error }) + deps.focusMainWindow() + deps.notifyRenderer({ ok: false, error: callback.error }) + }, + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 0000000000..1b22fd2377 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,545 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { Session, WebContents } from 'electron' +import { app, BrowserWindow, crashReporter, net, session } from 'electron' +import { newChatRoute, settingsRoute } from '@/main/app-routes' +import { + clearBrowserProfile as clearAgentBrowserProfile, + initDriver as initBrowserAgentDriver, +} from '@/main/browser-agent/driver' +import { + canReportPanelBounds, + setPanelBounds as setBrowserAgentPanelBounds, + setPanelOccluded as setBrowserAgentPanelOccluded, +} from '@/main/browser-agent/panel' +import { + closeSession as closeAgentBrowserSession, + closeFocusedTab as closeFocusedBrowserTab, + reopenFocusedTab as reopenClosedBrowserTab, + setPanelFocused as setBrowserAgentPanelFocused, +} from '@/main/browser-agent/session' +import { + APP_NAME_FOR_CHANNEL, + channelForOrigin, + createConfigStore, + DEFAULT_ORIGIN, + isSafeInternalPath, + partitionForOrigin, +} from '@/main/config' +import { attachContextMenu } from '@/main/context-menu' +import { attachCspFallback } from '@/main/csp' +import { createDesktopSettingsService } from '@/main/desktop-settings' +import { attachDownloadHandling } from '@/main/downloads' +import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff' +import { registerIpcHandlers } from '@/main/ipc' +import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' +import { LocalFilesystemService } from '@/main/local-filesystem' +import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' +import { installApplicationMenu } from '@/main/menu' +import { openExternalSafe } from '@/main/navigation' +import { createEventLog } from '@/main/observability' +import { installGlobalGuards } from '@/main/security-guards' +import { + createSessionLifecycleCoordinator, + decideStartRoute, + handleConnectIntercept, + readSessionUserId, + resolveStartRoute, +} from '@/main/session-lifecycle' +import { attachTelemetryPolicy } from '@/main/telemetry-policy' +import { TerminalService } from '@/main/terminal' +import { installTray, type TrayHandle } from '@/main/tray' +import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' +import { createMainWindow, setupPermissionHandlers } from '@/main/window' +import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' + +const logger = createLogger('DesktopMain') + +const OFFLINE_PAGE = 'static/offline.html' +const DOCK_ICON_FOR_CHANNEL = { + prod: 'dock-icon.png', + staging: 'dock-icon-staging.png', + dev: 'dock-icon-dev.png', + local: 'dock-icon-local.png', +} as const + +function main(): void { + app.enableSandbox() + + const config = createConfigStore(join(app.getPath('userData'), 'settings.json')) + const events = createEventLog(join(app.getPath('userData'), 'logs')) + const localFilesystem = new LocalFilesystemService({ + grantStore: createEncryptedLocalFilesystemGrantStore( + join(app.getPath('userData'), 'local-filesystem-grants.json') + ), + }) + const terminal = new TerminalService({ + loadCwd: () => config.get('terminalCwd'), + saveCwd: (cwd) => config.set('terminalCwd', cwd), + }) + const preloadPath = join(__dirname, 'preload.cjs') + + const windows = new Set() + const loadHealthByWindow = new Map() + let lastActiveWindow: BrowserWindow | null = null + let ensureWindowCreation: Promise | null = null + let appSession: Session | null = null + let sessionLifecycle: ReturnType | null = null + let tray: TrayHandle | null = null + let updater: UpdaterHandle | null = null + const configuredPartitions = new Set() + + const appOrigin = () => config.getOrigin() + const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://') + const getWindows = () => [...windows].filter((win) => !win.isDestroyed()) + const getMainWindow = () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused && windows.has(focused) && !focused.isDestroyed()) { + return focused + } + if (lastActiveWindow && windows.has(lastActiveWindow) && !lastActiveWindow.isDestroyed()) { + return lastActiveWindow + } + return getWindows().at(-1) ?? null + } + const windowForContents = (contents: WebContents) => { + const win = BrowserWindow.fromWebContents(contents) + return win && windows.has(win) && !win.isDestroyed() ? win : null + } + /** The focused window, but only when it is one of ours. */ + const focusedAppWindow = () => { + const focused = BrowserWindow.getFocusedWindow() + return focused && windows.has(focused) && !focused.isDestroyed() ? focused : null + } + const broadcast = (channel: string, ...args: unknown[]) => { + for (const win of getWindows()) { + win.webContents.send(channel, ...args) + } + } + + /** Restore/show/focus one full Sim window and activate the app. */ + function showMainWindow(target?: BrowserWindow | null): void { + const win = target ?? getMainWindow() + if (win) { + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + } + app.focus({ steal: true }) + } + + const handoff = createHandoffManager( + { + origin: appOrigin, + openExternal: (url) => openExternalSafe(url, allowHttpLocalhost()), + events, + currentUserId: () => readSessionUserId(ensureAppSession(), appOrigin()), + }, + { + onLogin: (callback) => void authFlow.handleCallback(callback), + onConnect: (callback) => connectFlow.handleCallback(callback), + } + ) + + const authFlow = createAuthFlow({ + handoff, + origin: appOrigin, + events, + ensureMainWindow: async () => { + let win = getMainWindow() + if (!win) { + win = await ensureMainWindow() + } + if (!win) { + throw new Error('Main window unavailable') + } + return win + }, + }) + + const connectFlow = createConnectFlow({ + handoff, + events, + focusMainWindow: showMainWindow, + notifyRenderer: (result) => { + broadcast('desktop:oauth-connect-complete', result) + }, + }) + + installGlobalGuards({ + appOrigin, + isPackaged: app.isPackaged, + allowHttpLocalhost, + isPopupContents, + onLoginHandoff: () => void authFlow.beginLoginHandoff(), + onConnectIntercept: (contents) => void handleConnectIntercept(contents, allowHttpLocalhost()), + }) + + function configureSessionForOrigin(origin: string) { + const partition = partitionForOrigin(origin) + const ses = session.fromPartition(partition) + if (configuredPartitions.has(partition)) { + return ses + } + configuredPartitions.add(partition) + setupPermissionHandlers(ses, appOrigin) + attachCspFallback(ses, appOrigin) + attachDownloadHandling(ses, events) + attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) + ses.setSpellCheckerLanguages(['en-US']) + return ses + } + + function ensureAppSession(): Session { + if (appSession && sessionLifecycle) return appSession + const ses = configureSessionForOrigin(appOrigin()) + appSession = ses + sessionLifecycle = createSessionLifecycleCoordinator({ + appSession: ses, + origin: appOrigin, + events, + getWindows, + clearHandoffState: async () => { + handoff.clear() + tray?.clearRecentChats() + await localFilesystem.forgetAll() + }, + clearBrowserProfile: clearAgentBrowserProfile, + }) + return ses + } + + async function ensureMainWindow(): Promise { + const existing = getMainWindow() + if (existing) return existing + if (ensureWindowCreation) return ensureWindowCreation + + const pending = createAndLoadAppWindow({ restorePosition: true }) + ensureWindowCreation = pending + try { + return await pending + } finally { + if (ensureWindowCreation === pending) { + ensureWindowCreation = null + } + } + } + + function routeFromAppUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl) + if (url.origin !== appOrigin()) return null + const route = `${url.pathname}${url.search}${url.hash}` + return isSafeInternalPath(route) ? route : null + } catch { + return null + } + } + + async function createAndLoadAppWindow({ + route: requestedRouteOverride, + restorePosition = false, + }: { + route?: string + restorePosition?: boolean + } = {}): Promise { + const origin = appOrigin() + const ses = ensureAppSession() + const requestedRoute = decideStartRoute(requestedRouteOverride ?? config.get('lastRoute')) + const route = await resolveStartRoute(ses, origin, requestedRoute) + if (route !== requestedRoute) { + config.set('lastRoute', route) + } + const win = createMainWindow({ + config, + events, + appOrigin, + partition: partitionForOrigin(origin), + preloadPath, + isPackaged: app.isPackaged, + restorePosition, + onFullScreenChange: (isFullScreen) => { + if (!win.isDestroyed()) { + win.webContents.send('desktop:window-state:changed', { isFullScreen }) + } + }, + onClosed: () => { + setBrowserAgentPanelBounds(null, win) + windows.delete(win) + loadHealthByWindow.delete(win) + if (lastActiveWindow === win) { + lastActiveWindow = getWindows().at(-1) ?? null + } + }, + }) + windows.add(win) + lastActiveWindow = win + win.on('focus', () => { + lastActiveWindow = win + }) + // A fresh document (reload, origin change, crash recovery) has no browser + // panel mounted yet — hide the embedded agent-browser view immediately + // rather than letting it linger over the loading page. + win.webContents.on('did-start-loading', () => { + setBrowserAgentPanelBounds(null, win) + }) + attachWindowOpenPolicy(win.webContents, { + appOrigin, + openAppWindow: (url) => { + const route = routeFromAppUrl(url) + if (route) { + void createAndLoadAppWindow({ route }) + } + }, + allowHttpLocalhost: allowHttpLocalhost(), + }) + attachContextMenu(win.webContents, { + isDev: !app.isPackaged, + allowHttpLocalhost: allowHttpLocalhost(), + }) + const loadHealth = attachLoadHealth(win, { + offlinePagePath: OFFLINE_PAGE, + getStartUrl: () => `${appOrigin()}${route}`, + isOnline: () => net.isOnline(), + events, + }) + loadHealthByWindow.set(win, loadHealth) + sessionLifecycle?.attachWindow(win) + loadHealth.startWatchdog() + // Fire-and-forget: the window and all its handlers are wired synchronously + // above, so callers get a usable window immediately and the app menu and + // updater never wait on the remote page's load (load-health surfaces any + // failure). + void win.loadURL(`${origin}${route}`).catch(() => {}) + return win + } + + /** Opens the Sim app's settings page in the active window. */ + function openSettings(): void { + void openMainWindowAt(settingsRoute(config.get('lastRoute'))) + } + + /** + * Brings the active window to front (creating one if needed), optionally + * navigating it to an in-app route first — the seam used by the tray menu. + */ + async function openMainWindowAt(route?: string): Promise { + let win = getMainWindow() + if (!win) { + win = await createAndLoadAppWindow({ route, restorePosition: true }) + showMainWindow(win) + return + } + if (route) { + void win.loadURL(`${appOrigin()}${route}`).catch(() => {}) + } + showMainWindow(win) + } + + /** Installs or removes the menu-bar status item; safe to call repeatedly. */ + function setTrayEnabled(enabled: boolean): void { + if (!enabled) { + tray?.destroy() + tray = null + return + } + if (!tray) { + tray = installTray({ + partition: () => partitionForOrigin(appOrigin()), + appOrigin, + lastRoute: () => config.get('lastRoute'), + openMainWindow: (route) => void openMainWindowAt(route), + }) + } + } + + const desktopSettings = createDesktopSettingsService({ + config, + getMainWindow, + openMainWindowAt: (route) => void openMainWindowAt(route), + setAutoDownloadUpdates: (enabled) => updater?.setAutoDownload(enabled), + setTrayEnabled, + // Switching a surface off ends what it is already running; the pages and + // shells would otherwise keep going in the background. The profile and the + // pinned strip survive, so switching back on resumes rather than restarts. + setBrowserEnabled: (enabled) => { + if (!enabled) closeAgentBrowserSession() + }, + setTerminalEnabled: (enabled) => { + if (!enabled) terminal.dispose() + }, + }) + + /** + * Routes through the coordinator rather than tearing down directly: the + * coordinator holds the in-progress guard, clears the same handoff and grant + * state, and reloads every window to /login. Doing it here instead meant the + * teardown's own cookie removal tripped the coordinator's cookie watcher into + * a second concurrent teardown. + */ + function signOutFromMenu(): void { + ensureAppSession() + sessionLifecycle?.signOut() + } + + app.on('second-instance', () => { + void app.whenReady().then(() => createAndLoadAppWindow()) + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) + + app.on('before-quit', () => { + // Stops the tray's background chat refresh alongside the OS handles. + tray?.destroy() + tray = null + localFilesystem.close() + terminal.dispose() + // Settings writes coalesce, so a change made in the last moments before + // quit is still pending here. + config.flush() + }) + + app.on('activate', () => { + if (app.isReady() && !getMainWindow()) { + void ensureMainWindow() + } + }) + + void app.whenReady().then(async () => { + // Use the same high-resolution source in packaged and unpackaged apps so + // macOS renders every environment marker consistently in the Dock. + if (process.platform === 'darwin') { + const channel = channelForOrigin(config.getOrigin()) + app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) + } + events.record('app_launch', { + version: app.getVersion(), + electron: process.versions.electron ?? '', + }) + initBrowserAgentDriver( + { + onPageState: (state) => { + broadcast('browser-agent:page-state', state) + }, + onTabsState: (state) => { + broadcast('browser-agent:tabs-state', state) + }, + onSessionStatus: (alive) => { + broadcast('browser-agent:session-status', alive) + }, + onFillAvailability: (available) => { + broadcast('browser-credentials:fill-availability', { available }) + }, + }, + getMainWindow, + config + ) + await localFilesystem.initialize() + terminal.setSink({ + data: (terminalId, data) => broadcast('terminal:data', terminalId, data), + tabs: (state) => broadcast('terminal:tabs', state), + command: (event) => broadcast('terminal:command', event), + }) + registerIpcHandlers({ + appOrigin, + allowHttpLocalhost, + retryLoad: (sender) => { + const win = windowForContents(sender) + if (win) loadHealthByWindow.get(win)?.retry() + }, + localFilesystem, + terminal, + settings: desktopSettings, + getWindowState: (sender) => ({ + isFullScreen: windowForContents(sender)?.isFullScreen() ?? false, + }), + getWindowForContents: (sender) => windowForContents(sender) ?? null, + browserPanel: { + setBounds: (sender, bounds, anchor) => { + const win = windowForContents(sender) + if (!win) return + if (bounds !== null && !canReportPanelBounds(win, focusedAppWindow())) { + return + } + setBrowserAgentPanelBounds(bounds, win, anchor) + }, + setFocused: (sender, focused) => { + const win = windowForContents(sender) + if (win) setBrowserAgentPanelFocused(focused, win) + }, + setOccluded: (sender, occluded) => { + const win = windowForContents(sender) + if (win) setBrowserAgentPanelOccluded(occluded, win) + }, + }, + beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope), + updates: { + getState: () => updater?.getState() ?? { status: 'idle' }, + check: () => updater?.check(), + install: () => updater?.install(), + }, + }) + await ensureMainWindow() + installApplicationMenu({ + config, + getMainWindow, + allowHttpLocalhost, + openSettings, + newWindow: () => void createAndLoadAppWindow(), + newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), + closeFocusedBrowserTab: (win) => closeFocusedBrowserTab(win), + reopenClosedBrowserTab: (win) => reopenClosedBrowserTab(win), + closeFocusedTerminal: (win) => terminal.closeFocusedTerminal(win), + reopenClosedTerminal: (win) => terminal.reopenClosedTerminal(win), + toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'), + signOut: signOutFromMenu, + checkForUpdates: () => + checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), + }) + setTrayEnabled(config.get('trayEnabled') ?? true) + updater = initUpdater({ + getWindow: getMainWindow, + events, + appOrigin, + autoDownload: () => config.get('autoDownloadUpdates') ?? true, + onStateChange: (state) => { + broadcast('desktop:updates:state', state) + }, + }) + desktopSettings.applySystemPreferences() + }) +} + +// Identity and userData must be set before the single-process lock, which +// writes its lock file into userData. Sim supports many full BrowserWindows +// inside that one process; a second OS launch is forwarded to the running +// process so it can create another window without two processes mutating the +// same Chromium profile. Setting identity here (not inside main) keeps the +// SIM_DESKTOP_ORIGIN/USER_DATA test overrides isolated per process. +// The name follows the build's channel ("Sim", "Sim Dev", …) so one developer +// can run one install per environment side by side — separate settings, +// sessions, locks, and update feeds. +app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +if (process.env.SIM_DESKTOP_USER_DATA) { + app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) +} + +// Capture native minidumps for main/renderer/GPU crashes. Local-only: there is +// no crash-ingest backend, so nothing is uploaded — the dumps land under +// userData/Crashpad and the event log records where. Must start before the app +// is ready so Crashpad initializes first. Set after userData so dumps follow +// any test/instance override. +crashReporter.start({ uploadToServer: false, compress: true }) + +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + app.quit() +} else { + main() +} diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts new file mode 100644 index 0000000000..26b4ee032c --- /dev/null +++ b/apps/desktop/src/main/ipc.test.ts @@ -0,0 +1,805 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +// Stubbed so the gating tests never read the developer's real Chrome profile +// or reach their Keychain — the importer's own behaviour is covered by +// src/main/browser-import. +vi.mock('@/main/browser-import', () => ({ + isChromeImportSupported: vi.fn(() => true), + listChromeImportProfiles: vi.fn(async () => [{ id: 'Default', label: 'Person 1' }]), + importChromeCookies: vi.fn(async () => ({ cookiesImported: 3, cookiesSkipped: 1 })), + importChromePasswords: vi.fn(async () => ({ + passwordsAdded: 2, + passwordsUpdated: 0, + passwordsSkipped: 1, + })), +})) + +const { mockCoordinator } = vi.hoisted(() => ({ + mockCoordinator: { + noteFormState: vi.fn(), + noteNavigation: vi.fn(), + forget: vi.fn(), + refreshAvailability: vi.fn(), + showChooser: vi.fn(async () => true), + }, +})) + +vi.mock('@/main/browser-credentials', () => ({ + revealCredential: vi.fn(async () => 'hunter2'), + copyCredential: vi.fn(async () => true), + credentialsAvailable: vi.fn(() => true), + listCredentials: vi.fn(async () => [ + { + id: 'c1', + origin: 'https://example.com', + username: 'ada', + createdAt: '', + updatedAt: '', + source: 'chrome', + }, + ]), + forgetCredential: vi.fn(async () => []), + forgetAllCredentials: vi.fn(async () => []), + clearCredentials: vi.fn(async () => {}), + initFillCoordinator: vi.fn(() => mockCoordinator), + fillCoordinator: vi.fn(() => mockCoordinator), +})) + +// A browser tab is identified by WebContents, not by URL — the pages it hosts +// are arbitrary websites. +vi.mock('@/main/browser-agent/registry', () => ({ + registerAgentWebContents: vi.fn(), + isAgentWebContents: vi.fn( + (contents: { isBrowserTab?: boolean } | null) => contents?.isBrowserTab === true + ), +})) + +import { ipcMain, shell } from 'electron' +import { + copyCredential, + credentialsAvailable, + forgetAllCredentials, + forgetCredential, + listCredentials, + revealCredential, +} from '@/main/browser-credentials' +import { + importChromeCookies, + importChromePasswords, + listChromeImportProfiles, +} from '@/main/browser-import' +import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' +import { LocalFilesystemService } from '@/main/local-filesystem' +import { TerminalService } from '@/main/terminal' + +const APP = 'https://sim.ai' + +type Handler = ( + event: { + senderFrame: { url: string; executeJavaScript?: (source: string) => Promise } | null + sender?: { + session?: { fetch: (url: string, init?: RequestInit) => Promise } + /** Marks a sender the mocked registry recognises as a browser tab. */ + isBrowserTab?: boolean + } + }, + ...args: unknown[] +) => unknown + +function collectHandlers() { + const invoke = new Map() + const on = new Map() + for (const [channel, handler] of vi.mocked(ipcMain.handle).mock.calls) { + invoke.set(channel as string, handler as Handler) + } + for (const [channel, handler] of vi.mocked(ipcMain.on).mock.calls) { + on.set(channel as string, handler as Handler) + } + return { invoke, on } +} + +const rejectedSender = () => ({ + session: { + fetch: vi.fn(async () => { + throw new Error('not authorized') + }), + }, +}) +const fileSender = rejectedSender() +const appSender = rejectedSender() +const evilSender = rejectedSender() +const fileEvent = { + senderFrame: { url: 'file:///app/static/offline.html' }, + sender: fileSender, +} +const appEvent = { senderFrame: { url: `${APP}/workspace/ws1` }, sender: appSender } +const activeAppEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => true), + }, +} +const inactiveAppEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => false), + }, +} +const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } +/** The chooser anchors a native menu, so it needs a sender with a window. */ +const FAKE_WINDOW = { id: 'main-window' } +const activeChooserEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => true), + }, + sender: appSender, +} + +describe('registerIpcHandlers', () => { + let deps: IpcDeps + + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + vi.mocked(shell.openExternal).mockClear() + vi.mocked(listChromeImportProfiles).mockClear() + vi.mocked(importChromeCookies).mockClear() + vi.mocked(importChromePasswords).mockClear() + vi.mocked(credentialsAvailable).mockClear() + vi.mocked(listCredentials).mockClear() + vi.mocked(forgetCredential).mockClear() + vi.mocked(forgetAllCredentials).mockClear() + vi.mocked(revealCredential).mockClear() + vi.mocked(copyCredential).mockClear() + mockCoordinator.noteFormState.mockClear() + mockCoordinator.showChooser.mockClear() + deps = { + appOrigin: () => APP, + allowHttpLocalhost: () => false, + retryLoad: vi.fn(), + beginOAuthConnect: vi.fn(async () => true), + localFilesystem: new LocalFilesystemService({ + chooseDirectory: vi.fn(async () => null), + }), + terminal: new TerminalService(), + settings: { + getPreferences: vi.fn(() => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + })), + setPreference: vi.fn(), + notify: vi.fn(() => true), + applySystemPreferences: vi.fn(), + }, + getWindowState: vi.fn(() => ({ isFullScreen: true })), + getWindowForContents: vi.fn(() => FAKE_WINDOW as never), + browserPanel: { + setBounds: vi.fn(), + setFocused: vi.fn(), + setOccluded: vi.fn(), + }, + updates: { + getState: vi.fn(() => ({ status: 'ready' as const, version: '1.2.3' })), + check: vi.fn(), + install: vi.fn(), + }, + } + registerIpcHandlers(deps) + }) + + it('validates open-external URLs regardless of sender', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true) + expect(await invoke.get('desktop:open-external')?.(appEvent, 'javascript:alert(1)')).toBe(false) + expect(await invoke.get('desktop:open-external')?.(appEvent, 42)).toBe(false) + expect(shell.openExternal).toHaveBeenCalledTimes(1) + }) + + it('restricts the OAuth connect handoff to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:oauth-connect') + expect(await handler?.(evilEvent, 'slack')).toBe(false) + expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(deps.beginOAuthConnect).not.toHaveBeenCalled() + expect(await handler?.(appEvent, 42)).toBe(false) + expect(await handler?.(appEvent, 'slack')).toBe(true) + expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {}) + + // Chip-initiated connects carry workspace/credential scope; malformed + // scopes (wrong types, unsafe ids) are rejected before the handoff. + expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1' })).toBe( + true + ) + expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', { + workspaceId: 'ws1', + credentialId: 'cred_1', + }) + expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) + }) + + it('restricts the updates surface to the app origin', async () => { + const { invoke, on } = collectHandlers() + const getState = invoke.get('desktop:updates:get-state') + expect(await getState?.(evilEvent)).toEqual({ status: 'idle' }) + expect(await getState?.(appEvent)).toEqual({ status: 'ready', version: '1.2.3' }) + + on.get('desktop:updates:check')?.(evilEvent) + on.get('desktop:updates:install')?.(evilEvent) + expect(deps.updates.check).not.toHaveBeenCalled() + expect(deps.updates.install).not.toHaveBeenCalled() + + on.get('desktop:updates:check')?.(appEvent) + on.get('desktop:updates:install')?.(appEvent) + expect(deps.updates.check).toHaveBeenCalledTimes(1) + expect(deps.updates.install).toHaveBeenCalledTimes(1) + }) + + it('restricts local filesystem access to the app origin', async () => { + const { invoke } = collectHandlers() + expect( + await invoke.get('desktop:local-filesystem')?.(evilEvent, { operation: 'list_mounts' }) + ).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + expect( + await invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' }) + ).toEqual({ ok: true, data: { mounts: [] } }) + }) + + it('requires an active user gesture for granting or revoking folder access', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:local-filesystem') + + expect(await handler?.(inactiveAppEvent, { operation: 'mount_directory' })).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('explicit user click'), + }) + expect(await handler?.(activeAppEvent, { operation: 'mount_directory' })).toMatchObject({ + ok: true, + data: { cancelled: true, mount: null }, + }) + expect( + await handler?.(inactiveAppEvent, { operation: 'reveal_mount', uri: 'localfs://mount-1/' }) + ).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('explicit user click'), + }) + }) + + it('requires server authorization for every privileged filesystem tool request', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:local-filesystem') + const handle = vi.spyOn(deps.localFilesystem, 'handle') + + expect( + await handler?.(appEvent, { + operation: 'read', + uri: 'localfs://mount-1/README.md', + requestId: 'tool-1', + }) + ).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + expect(handle).not.toHaveBeenCalled() + + const fetchAuthorization = vi.fn(async () => + Response.json({ + toolName: 'read', + args: { path: 'user-local/Project--mount-1/README.md' }, + }) + ) + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { session: { fetch: fetchAuthorization } }, + } + vi.spyOn(deps.localFilesystem, 'isAuthorizedClientToolRequest').mockReturnValueOnce(true) + handle.mockResolvedValueOnce({ ok: true, data: { forgotten: false } }) + + await expect( + handler?.(authorizedEvent, { + operation: 'read', + uri: 'localfs://mount-1/README.md', + requestId: 'tool-1', + }) + ).resolves.toEqual({ ok: true, data: { forgotten: false } }) + expect(fetchAuthorization).toHaveBeenCalledWith( + `${APP}/api/desktop/tool/authorize`, + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ toolCallId: 'tool-1' }), + }) + ) + }) + + it('restricts desktop settings to the app origin and validates mutations', async () => { + const { invoke } = collectHandlers() + const get = invoke.get('desktop:settings:get') + const set = invoke.get('desktop:settings:set') + const notify = invoke.get('desktop:settings:notify') + + expect(await get?.(evilEvent)).toBeNull() + expect(await get?.(appEvent)).toMatchObject({ notificationsEnabled: true }) + + await set?.(evilEvent, 'notificationsEnabled', false) + await set?.(appEvent, 'not-a-setting', false) + await set?.(appEvent, 'notificationsEnabled', 'no') + expect(deps.settings.setPreference).not.toHaveBeenCalled() + + await set?.(appEvent, 'notificationsEnabled', false) + expect(deps.settings.setPreference).toHaveBeenCalledWith('notificationsEnabled', false) + + expect(await notify?.(evilEvent, { title: 'Done', body: 'Ready' })).toBe(false) + expect(await notify?.(appEvent, { title: '', body: 'Ready' })).toBe(false) + expect( + await notify?.(appEvent, { title: 'Done', body: 'Ready', route: '//evil.example' }) + ).toBe(false) + expect(deps.settings.notify).not.toHaveBeenCalled() + + expect( + await notify?.(appEvent, { + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + ).toBe(true) + expect(deps.settings.notify).toHaveBeenCalledWith({ + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + }) + + it('reports native fullscreen state only to the app origin', async () => { + const { invoke } = collectHandlers() + const getWindowState = invoke.get('desktop:window-state:get') + + expect(await getWindowState?.(evilEvent)).toEqual({ isFullScreen: false }) + expect(await getWindowState?.(appEvent)).toEqual({ isFullScreen: true }) + expect(deps.getWindowState).toHaveBeenCalledWith(appSender) + }) + + it('restricts shell-control channels to bundled local pages', () => { + const { on } = collectHandlers() + + on.get('offline:retry')?.(appEvent) + expect(deps.retryLoad).not.toHaveBeenCalled() + on.get('offline:retry')?.(fileEvent) + expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) + }) + + it('registers every channel the preload bridge invokes or sends', () => { + // The two files 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, lints, and ships. + const { invoke, on } = collectHandlers() + const registered = new Set([...invoke.keys(), ...on.keys()]) + const preloadSource = readFileSync( + fileURLToPath(new URL('../preload/index.ts', import.meta.url)), + 'utf8' + ) + const used = [ + ...new Set( + [...preloadSource.matchAll(/ipcRenderer\.(?:invoke|send)\(\s*'([^']+)'/g)].map( + (match) => match[1] + ) + ), + ] + + expect(used.length).toBeGreaterThan(0) + expect(used.filter((channel) => !registered.has(channel))).toEqual([]) + }) + + it('compares the sender by parsed origin, not by prefix', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:settings:get') + + // A lookalike host is a prefix of the app origin, so a `startsWith` gate + // is one missing trailing slash away from admitting it. + const lookalike = { senderFrame: { url: `${APP}.evil.example/workspace/ws1` } } + expect(await handler?.(lookalike)).toBeNull() + + // Origin equality also normalizes the default port, which a prefix + // comparison rejects even though it is the same origin. + const explicitPort = { senderFrame: { url: 'https://sim.ai:443/workspace/ws1' } } + expect(await handler?.(explicitPort)).toMatchObject({ notificationsEnabled: true }) + }) + + it('handles a missing senderFrame safely', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('desktop:oauth-connect')?.({ senderFrame: null }, 'slack')).toBe(false) + expect(deps.beginOAuthConnect).not.toHaveBeenCalled() + }) + + it('restricts browser-agent tool execution to the app origin and known tools', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:execute-tool') + + expect( + await handler?.(evilEvent, 'tool-1', 'browser_navigate', { url: 'https://x.dev' }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('not allowed'), + }) + expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ + ok: false, + }) + expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {})).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + + const fetchAuthorization = vi.fn(async () => + Response.json({ toolName: 'browser_snapshot', args: {} }) + ) + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { session: { fetch: fetchAuthorization } }, + } + // The server-persisted name must match the renderer's requested name. + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_navigate', { + url: 'https://evil.example', + }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + // An authorized call reaches the driver with the server-persisted args + // (which reports its own tool-level failure because no session exists). + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', { + ignored: 'renderer cannot choose params', + }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('No page is open yet'), + }) + expect(fetchAuthorization).toHaveBeenCalledWith( + `${APP}/api/desktop/tool/authorize`, + expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-1' }) }) + ) + }) + + it('ignores browser-agent panel actions from outside the app origin', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:panel-action') + // Malformed and foreign-origin actions are dropped without throwing. + expect(() => handler?.(evilEvent, { action: 'reload' })).not.toThrow() + expect(() => handler?.(appEvent, 'not-an-object')).not.toThrow() + expect(() => handler?.(appEvent, { action: 'reload' })).not.toThrow() + }) + + it('restricts browser-tab pinning to typed app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-tab-pinned') + + expect(() => handler?.(evilEvent, '1', true)).not.toThrow() + expect(() => handler?.(appEvent, 1, true)).not.toThrow() + expect(() => handler?.(appEvent, '1', 'yes')).not.toThrow() + expect(() => handler?.(appEvent, '1', true)).not.toThrow() + }) + + it('restricts browser-tab reordering to typed app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:reorder-tab') + + expect(() => handler?.(evilEvent, '1', 0)).not.toThrow() + expect(() => handler?.(appEvent, 1, 0)).not.toThrow() + expect(() => handler?.(appEvent, '1', '0')).not.toThrow() + expect(() => handler?.(appEvent, '1', Number.NaN)).not.toThrow() + expect(() => handler?.(appEvent, '1', 0)).not.toThrow() + }) + + it('restricts browser-panel occlusion updates to boolean app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-occluded') + + expect(() => handler?.(evilEvent, true)).not.toThrow() + expect(() => handler?.(appEvent, 'yes')).not.toThrow() + expect(() => handler?.(appEvent, true)).not.toThrow() + expect(deps.browserPanel.setOccluded).toHaveBeenCalledWith(appSender, true) + }) + + it('restricts browser-panel focus updates to boolean app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-focused') + + expect(() => handler?.(evilEvent, true)).not.toThrow() + expect(() => handler?.(appEvent, 'yes')).not.toThrow() + expect(() => handler?.(appEvent, true)).not.toThrow() + expect(deps.browserPanel.setFocused).toHaveBeenCalledWith(appSender, true) + }) + + it('routes validated browser-panel bounds with the originating app window sender', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-bounds') + const bounds = { x: 100, y: 50, width: 800, height: 600 } + + handler?.(evilEvent, bounds) + handler?.(appEvent, { ...bounds, width: Number.NaN }) + expect(deps.browserPanel.setBounds).not.toHaveBeenCalled() + + handler?.(appEvent, bounds) + handler?.(appEvent, null) + expect(deps.browserPanel.setBounds).toHaveBeenNthCalledWith(1, appSender, bounds, undefined) + expect(deps.browserPanel.setBounds).toHaveBeenNthCalledWith(2, appSender, null, undefined) + }) + + it('forwards a well-formed panel anchor and drops a malformed one', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-bounds') + const bounds = { x: 100, y: 50, width: 800, height: 600 } + const anchor = { viewportWidth: 1600, viewportHeight: 900, widthRatio: 0.5 } + + handler?.(appEvent, bounds, anchor) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, anchor) + + // A bad anchor must not take the bounds down with it — the rect still + // applies, the shell just loses the resize optimization. + handler?.(appEvent, bounds, { ...anchor, widthRatio: Number.NaN }) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + handler?.(appEvent, bounds, { ...anchor, viewportWidth: 0 }) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + handler?.(appEvent, bounds, 'nonsense') + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + }) + + it('restricts browser theme updates to known app-origin preferences', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-theme') + + expect(() => handler?.(evilEvent, 'dark')).not.toThrow() + expect(() => handler?.(appEvent, 'sepia')).not.toThrow() + expect(() => handler?.(appEvent, 'system')).not.toThrow() + }) + + it('restricts Chrome profile discovery to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-import:list-profiles') + + expect(await handler?.(evilEvent)).toEqual([]) + expect(await handler?.(fileEvent)).toEqual([]) + expect(listChromeImportProfiles).not.toHaveBeenCalled() + + expect(await handler?.(appEvent)).toEqual([{ id: 'Default', label: 'Person 1' }]) + }) + + it('requires a live user gesture before importing Chrome cookies', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-import:cookies') + + // Reading someone's Chrome cookies is a user decision. Without an active + // gesture the call is refused before it can reach the Keychain, so a + // scripted or compromised renderer cannot start an import on its own. + expect(await handler?.(inactiveAppEvent, 'Default')).toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + + expect(await handler?.(activeAppEvent, 'Default')).toEqual({ + cookiesImported: 3, + cookiesSkipped: 1, + }) + expect(importChromeCookies).toHaveBeenCalledWith('Default') + }) + + it('never imports Chrome cookies for a foreign origin', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:cookies')?.(evilEvent, 'Default')).toMatchObject({ + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('refuses Chrome import while the browser surface is switched off', async () => { + deps.settings.getPreferences = vi.fn(() => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: false, + })) + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:list-profiles')?.(appEvent)).toEqual([]) + expect(await invoke.get('browser-import:cookies')?.(activeAppEvent, 'Default')).toMatchObject({ + error: 'unknown', + }) + expect(listChromeImportProfiles).not.toHaveBeenCalled() + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('refuses a malformed profile id rather than importing the default profile', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:cookies')?.(activeAppEvent, 42)).toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('imports the default profile when the page names none', async () => { + const { invoke } = collectHandlers() + + await invoke.get('browser-import:cookies')?.(activeAppEvent, undefined) + expect(importChromeCookies).toHaveBeenCalledWith(undefined) + }) + + it('exposes exactly one channel that can return a password', async () => { + // The structural guarantee behind the credential design. Reveal is the one + // deliberate exception, so the channel list is pinned here: a new way to + // get plaintext out of the main process has to break this test first. + const { invoke, on } = collectHandlers() + const credentialChannels = [...invoke.keys(), ...on.keys()].filter((channel) => + channel.startsWith('browser-credentials:') + ) + + expect(credentialChannels.sort()).toEqual([ + 'browser-credentials:available', + 'browser-credentials:copy', + 'browser-credentials:forget', + 'browser-credentials:forget-all', + 'browser-credentials:form-state', + 'browser-credentials:import', + 'browser-credentials:list', + 'browser-credentials:reveal', + 'browser-credentials:show-chooser', + ]) + + const listed = (await invoke.get('browser-credentials:list')?.(appEvent)) as Array< + Record + > + expect(listed.every((credential) => !('password' in credential))).toBe(true) + }) + + it('requires origin and a live gesture before revealing or copying a password', async () => { + const { invoke } = collectHandlers() + const revealHandler = invoke.get('browser-credentials:reveal') + const copyHandler = invoke.get('browser-credentials:copy') + + expect(await revealHandler?.(evilEvent, 'c1')).toBeNull() + expect(await revealHandler?.(inactiveAppEvent, 'c1')).toBeNull() + expect(await copyHandler?.(evilEvent, 'c1')).toBe(false) + expect(await copyHandler?.(inactiveAppEvent, 'c1')).toBe(false) + expect(revealCredential).not.toHaveBeenCalled() + expect(copyCredential).not.toHaveBeenCalled() + + expect(await revealHandler?.(activeAppEvent, 'c1')).toBe('hunter2') + expect(revealCredential).toHaveBeenCalledWith('c1') + }) + + it('requires a live user gesture before deleting every password', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:forget-all') + + expect(await handler?.(evilEvent)).toEqual([]) + expect(await handler?.(inactiveAppEvent)).toEqual([]) + expect(forgetAllCredentials).not.toHaveBeenCalled() + + await handler?.(activeAppEvent) + expect(forgetAllCredentials).toHaveBeenCalled() + }) + + it('refuses a reveal for anything that is not a credential id', async () => { + const { invoke } = collectHandlers() + + expect( + await invoke.get('browser-credentials:reveal')?.(activeAppEvent, { id: 'c1' }) + ).toBeNull() + expect(revealCredential).not.toHaveBeenCalled() + }) + + it('accepts login-form reports only from the built-in browseritself', async () => { + const { on } = collectHandlers() + const handler = on.get('browser-credentials:form-state') + const report = { origin: 'https://example.com', hasLoginForm: true } + const browserPageEvent = { + senderFrame: { url: 'https://example.com/login' }, + sender: { isBrowserTab: true }, + } + + // An arbitrary website, and even the Sim app itself, cannot claim a page + // has a login form — only the browser tab's own preload can. + handler?.(evilEvent, report) + handler?.(appEvent, report) + expect(mockCoordinator.noteFormState).not.toHaveBeenCalled() + + handler?.(browserPageEvent, report) + expect(mockCoordinator.noteFormState).toHaveBeenCalledWith(browserPageEvent.sender, report) + }) + + it('ignores a malformed login-form report', () => { + const { on } = collectHandlers() + const handler = on.get('browser-credentials:form-state') + const browserPageEvent = { + senderFrame: { url: 'https://x.test/' }, + sender: { isBrowserTab: true }, + } + + handler?.(browserPageEvent, 'nonsense') + handler?.(browserPageEvent, { origin: 42, hasLoginForm: true }) + handler?.(browserPageEvent, { origin: 'https://x.test', hasLoginForm: 'yes' }) + + expect(mockCoordinator.noteFormState).not.toHaveBeenCalled() + }) + + it('requires a live user gesture before opening the credential chooser', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:show-chooser') + const anchor = { x: 10, y: 20 } + + expect(await handler?.(evilEvent, anchor)).toBe(false) + expect(await handler?.(inactiveAppEvent, anchor)).toBe(false) + expect(mockCoordinator.showChooser).not.toHaveBeenCalled() + + expect(await handler?.(activeChooserEvent, anchor)).toBe(true) + expect(mockCoordinator.showChooser).toHaveBeenCalledWith(FAKE_WINDOW, anchor) + }) + + it('refuses a chooser anchor that is not a real point', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:show-chooser') + + expect(await handler?.(activeChooserEvent, { x: 'left', y: 2 })).toBe(false) + expect(await handler?.(activeChooserEvent, { x: Number.NaN, y: 2 })).toBe(false) + expect(await handler?.(activeChooserEvent, null)).toBe(false) + expect(mockCoordinator.showChooser).not.toHaveBeenCalled() + }) + + it('requires a live user gesture before importing or forgetting passwords', async () => { + const { invoke } = collectHandlers() + + expect( + await invoke.get('browser-credentials:import')?.(inactiveAppEvent, 'Default') + ).toMatchObject({ error: 'unknown' }) + await invoke.get('browser-credentials:forget')?.(inactiveAppEvent, 'c1') + expect(importChromePasswords).not.toHaveBeenCalled() + expect(forgetCredential).not.toHaveBeenCalled() + + await invoke.get('browser-credentials:import')?.(activeAppEvent, 'Default', 'replace') + await invoke.get('browser-credentials:forget')?.(activeAppEvent, 'c1') + expect(importChromePasswords).toHaveBeenCalledWith('Default', 'replace') + expect(forgetCredential).toHaveBeenCalledWith('c1') + }) + + it('defaults password conflicts to keeping what is already stored', async () => { + const { invoke } = collectHandlers() + + await invoke.get('browser-credentials:import')?.(activeAppEvent, undefined, 'nonsense') + expect(importChromePasswords).toHaveBeenCalledWith(undefined, 'keep-existing') + }) + + it('reports credential availability only to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:available') + + expect(await handler?.(evilEvent)).toBe(false) + expect(credentialsAvailable).not.toHaveBeenCalled() + expect(await handler?.(appEvent)).toBe(true) + }) + + it('never lists credentials to a foreign origin', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-credentials:list')?.(evilEvent)).toEqual([]) + expect(listCredentials).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 0000000000..a3532b5f3e --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -0,0 +1,1048 @@ +import { + type BrowserPanelAnchor, + type BrowserPanelBounds, + isBrowserDataKind, + isBrowserTheme, + isBrowserToolName, +} from '@sim/browser-protocol' +import type { + DesktopNotificationPayload, + DesktopUpdateState, + DesktopWindowState, +} from '@sim/desktop-bridge' +import { + isTerminalOperation, + isTerminalToolName, + type TerminalToolArgs, +} from '@sim/terminal-protocol' +import { isRecordLike } from '@sim/utils/object' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import { ipcMain } from 'electron' +import { + clearBrowsingData, + executeTool, + getKnownSessions, + handlePanelAction, +} from '@/main/browser-agent/driver' +import { isAgentWebContents } from '@/main/browser-agent/registry' +import { + findInActiveTab, + getTabsState, + reorderTab, + setBrowserTheme, + setTabPinned, + stopFindInActiveTab, +} from '@/main/browser-agent/session' +import { + copyCredential, + credentialsAvailable, + fillCoordinator, + forgetAllCredentials, + forgetCredential, + listCredentials, + revealCredential, +} from '@/main/browser-credentials' +import { + importChromeCookies, + importChromeData, + importChromePasswords, + listChromeImportProfiles, +} from '@/main/browser-import' +import { listSites } from '@/main/browser-sites' +import { isSafeInternalPath } from '@/main/config' +import type { DesktopSettingsService } from '@/main/desktop-settings' +import { isDesktopPreferenceKey } from '@/main/desktop-settings' +import type { LocalFilesystemService } from '@/main/local-filesystem' +import { isAppOrigin, openExternalSafe } from '@/main/navigation' +import type { TerminalService } from '@/main/terminal' + +/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ +const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +export interface OAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +/** + * Validates the optional connect-handoff scope: absent is fine, but a present + * scope must be an object whose ids are opaque tokens (they are embedded into + * the /desktop/connect URL). Returns undefined for malformed payloads. + */ +export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefined { + if (raw === undefined || raw === null) { + return {} + } + if (typeof raw !== 'object') { + return undefined + } + const { workspaceId, credentialId } = raw as { workspaceId?: unknown; credentialId?: unknown } + if ( + workspaceId !== undefined && + (typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId)) + ) { + return undefined + } + if ( + credentialId !== undefined && + (typeof credentialId !== 'string' || !ID_PATTERN.test(credentialId)) + ) { + return undefined + } + return { + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(credentialId !== undefined ? { credentialId } : {}), + } +} + +/** + * A renderer-supplied dimension worth acting on. `typeof NaN === 'number'` and + * `NaN <= 0` is false, so a bare typeof check lets an unfinite value through + * every downstream positivity guard untouched. + */ +export function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +/** + * A renderer-supplied dimension as a usable whole number of cells. + * + * Flooring after the positivity check is not enough on its own: 0.5 passes + * `> 0` and floors to 0, and a zero-column pty is either a broken shell or a + * spawn failure. The floor of one cell is applied here so every caller gets it. + */ +export function toCellCount(value: unknown, fallback: number): number { + return isPositiveFinite(value) ? Math.max(1, Math.floor(value)) : fallback +} + +/** Validates a renderer-reported panel rect (finite numbers or explicit null). */ +export function parsePanelBounds( + raw: unknown +): { x: number; y: number; width: number; height: number } | null | undefined { + if (raw === null) { + return null + } + if (typeof raw !== 'object') { + return undefined + } + const rect = raw as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } + if ( + typeof rect.x === 'number' && + typeof rect.y === 'number' && + typeof rect.width === 'number' && + typeof rect.height === 'number' && + [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) + ) { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + return undefined +} + +/** + * Validates the optional panel anchor. Absent or malformed yields undefined, so + * the panel falls back to the measured rect alone — an anchor is an + * optimization, never a requirement. + */ +export function parsePanelAnchor(raw: unknown): BrowserPanelAnchor | undefined { + if (!isRecordLike(raw)) { + return undefined + } + const { viewportWidth, viewportHeight, widthRatio } = raw as { + viewportWidth?: unknown + viewportHeight?: unknown + widthRatio?: unknown + } + if ( + typeof viewportWidth !== 'number' || + typeof viewportHeight !== 'number' || + typeof widthRatio !== 'number' || + ![viewportWidth, viewportHeight, widthRatio].every(Number.isFinite) || + viewportWidth <= 0 || + viewportHeight <= 0 || + widthRatio < 0 || + widthRatio > 1 + ) { + return undefined + } + return { viewportWidth, viewportHeight, widthRatio } +} + +export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificationPayload | null { + if (typeof raw !== 'object' || raw === null) { + return null + } + const { title, body, route } = raw as { + title?: unknown + body?: unknown + route?: unknown + } + if ( + typeof title !== 'string' || + title.length < 1 || + title.length > 120 || + typeof body !== 'string' || + body.length < 1 || + body.length > 500 + ) { + return null + } + if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) { + return null + } + return { title, body, ...(route !== undefined ? { route } : {}) } +} + +export interface IpcDeps { + appOrigin: () => string + allowHttpLocalhost: () => boolean + retryLoad: (sender: WebContents) => void + localFilesystem: LocalFilesystemService + terminal: TerminalService + settings: DesktopSettingsService + getWindowState: (sender: WebContents) => DesktopWindowState + /** The window owning a renderer, for anchoring native menus. */ + getWindowForContents: (sender: WebContents) => BrowserWindow | null + browserPanel: { + setBounds: ( + sender: WebContents, + bounds: BrowserPanelBounds | null, + anchor?: BrowserPanelAnchor + ) => void + setFocused: (sender: WebContents, focused: boolean) => void + setOccluded: (sender: WebContents, occluded: boolean) => void + } + beginOAuthConnect: (providerId: string, scope: OAuthConnectScope) => Promise + updates: { + getState: () => DesktopUpdateState + check: () => void + install: () => void + } +} + +/** + * Who may call a channel: + * - `app-origin`: only the remote app origin (main window pages). + * - `local-page`: only bundled `file:` pages (offline) — shell control. + * - `browser-page`: only the built-in browser's own tabs, identified by + * WebContents rather than by URL. These carry reports from the browser + * preload about untrusted pages, so they are the one inbound surface whose + * sender is not the app — the payload is treated as a claim to verify, never + * as an instruction. + * - `any`: sender-independent channels that validate their input instead. + */ +type ChannelGate = 'app-origin' | 'local-page' | 'browser-page' | 'any' + +/** + * A desktop surface the user can switch off. Channels that drive one are + * refused while it is off, so the gate holds even if renderer-side checks are + * stale or bypassed. Channels that only read or reset the surface's settings + * stay open — otherwise turning it back on would be impossible. + */ +type ChannelFeature = 'browser' | 'terminal' + +interface ChannelSpecBase { + gate: ChannelGate + passSender?: boolean + requires?: ChannelFeature + /** + * Why this channel's `gate` or `requires` deviates from the rest of its + * name family. Required by `check:desktop-ipc` for any channel that does, + * so a new channel cannot quietly opt out of its family's surface toggle. + * + * A field rather than a comment because the deviation is a property of this + * object: reordering the table moves it with its channel, where a positional + * comment would silently transfer to whichever channel took its place. + */ + deviationReason?: string +} + +type ChannelSpec = + | (ChannelSpecBase & { + kind: 'invoke' + /** Requires an in-progress user gesture in the calling page. */ + needsUserActivation?: boolean + /** Returned to the caller when a gate rejects the call. */ + denied: unknown + handler: (...args: unknown[]) => unknown + }) + | (ChannelSpecBase & { + kind: 'send' + handler: (...args: unknown[]) => void + }) + +function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + try { + return new URL(event.senderFrame?.url ?? '').protocol === 'file:' + } catch { + return false + } +} + +/** + * Compared by parsed origin, not `startsWith`. This is the renderer-to-main + * boundary, and prefix matching admits lookalike hosts — see the warning on + * {@link isAppOrigin}. + */ +function isAppOriginSender(event: IpcMainEvent | IpcMainInvokeEvent, appOrigin: string): boolean { + return isAppOrigin(event.senderFrame?.url ?? '', appOrigin) +} + +function localFilesystemRequestNeedsUserActivation(request: unknown): boolean { + if (typeof request !== 'object' || request === null) return false + const operation = (request as { operation?: unknown }).operation + return ( + operation === 'mount_directory' || operation === 'forget_mount' || operation === 'reveal_mount' + ) +} + +function localFilesystemRequestNeedsToolAuthorization(request: unknown): boolean { + if (typeof request !== 'object' || request === null) return false + const operation = (request as { operation?: unknown }).operation + return ( + operation === 'list' || + operation === 'glob' || + operation === 'read' || + operation === 'grep' || + operation === 'stat' + ) +} + +async function rendererHasActiveUserGesture(event: IpcMainInvokeEvent): Promise { + const frame = event.senderFrame + if (!frame || typeof frame.executeJavaScript !== 'function') return false + try { + return (await frame.executeJavaScript('navigator.userActivation?.isActive === true')) === true + } catch { + return false + } +} + +interface DesktopToolAuthorization { + toolName: string + args: Record +} + +async function fetchDesktopToolAuthorization( + event: IpcMainInvokeEvent, + deps: IpcDeps, + toolCallId: unknown +): Promise { + if (typeof toolCallId !== 'string' || toolCallId.length < 1 || toolCallId.length > 256) { + return null + } + try { + const response = await event.sender.session.fetch( + `${deps.appOrigin()}/api/desktop/tool/authorize`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolCallId }), + } + ) + if (!response.ok) return null + const authorization = (await response.json()) as { + toolName?: unknown + args?: unknown + } + if ( + typeof authorization.toolName !== 'string' || + typeof authorization.args !== 'object' || + authorization.args === null || + Array.isArray(authorization.args) + ) { + return null + } + return { + toolName: authorization.toolName, + args: authorization.args as Record, + } + } catch { + return null + } +} + +async function authorizeLocalFilesystemTool( + event: IpcMainInvokeEvent, + deps: IpcDeps, + request: unknown +): Promise { + if (typeof request !== 'object' || request === null) return false + const authorization = await fetchDesktopToolAuthorization( + event, + deps, + (request as { requestId?: unknown }).requestId + ) + return authorization + ? deps.localFilesystem.isAuthorizedClientToolRequest(request, authorization) + : false +} + +/** + * Registers the whitelisted IPC surface, table-driven so the whole + * renderer→main security posture is auditable in one place: every channel + * declares its sender gate up front, and handlers only ever see gated, + * unvalidated args they must parse themselves. + */ +export function registerIpcHandlers(deps: IpcDeps): void { + const channels: Record = { + 'desktop:open-external': { + kind: 'invoke', + gate: 'any', + deviationReason: + 'the offline and error pages are local-page senders, not app-origin, and handing a support link to the system browser is the one action that must work when the app cannot reach its origin at all', + denied: false, + handler: (url) => + typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false, + }, + // OAuth connect handoff: the whole flow runs in the system browser (state + // is cookie-bound to the initiating user agent), returning via loopback. + 'desktop:oauth-connect': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: (providerId, scope) => { + if (typeof providerId !== 'string') { + return false + } + const parsedScope = parseOAuthConnectScope(scope) + if (parsedScope === undefined) { + return false + } + return deps.beginOAuthConnect(providerId, parsedScope) + }, + }, + 'desktop:local-filesystem': { + kind: 'invoke', + gate: 'app-origin', + denied: { + ok: false, + code: 'ACCESS_DENIED', + error: 'Local filesystem access is not allowed from this page.', + }, + handler: (request) => deps.localFilesystem.handle(request), + }, + 'desktop:settings:get': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: () => deps.settings.getPreferences(), + }, + 'desktop:settings:set': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: (key, value) => + isDesktopPreferenceKey(key) && typeof value === 'boolean' + ? deps.settings.setPreference(key, value) + : deps.settings.getPreferences(), + }, + 'desktop:settings:notify': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: (raw) => { + const payload = parseDesktopNotificationPayload(raw) + return payload ? deps.settings.notify(payload) : false + }, + }, + 'desktop:window-state:get': { + kind: 'invoke', + gate: 'app-origin', + passSender: true, + denied: { isFullScreen: false }, + handler: (sender) => deps.getWindowState(sender as WebContents), + }, + 'desktop:updates:get-state': { + kind: 'invoke', + gate: 'app-origin', + denied: { status: 'idle' }, + handler: () => deps.updates.getState(), + }, + 'desktop:updates:check': { + kind: 'send', + gate: 'app-origin', + handler: () => deps.updates.check(), + }, + 'desktop:updates:install': { + kind: 'send', + gate: 'app-origin', + handler: () => deps.updates.install(), + }, + 'browser-agent:execute-tool': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: { ok: false, error: 'Browser automation is not allowed from this page.' }, + handler: (tool, params) => { + if (typeof tool !== 'string' || !isBrowserToolName(tool)) { + return { ok: false, error: `Unknown browser tool: ${String(tool)}` } + } + const toolParams = isRecordLike(params) ? params : {} + return executeTool(tool, toolParams) + }, + }, + 'browser-agent:get-tabs-state': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: { tabs: [], activeTabId: null }, + handler: () => getTabsState(), + }, + // Reads and wipes the stored browsing trail, so both stay available while + // the browser is switched off — that is exactly when someone clears it. + 'browser-agent:get-known-sessions': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + "read/reset of the surface's own data; gating it on the surface would strand the browsing trail with no way to inspect or erase it", + denied: { sessions: [] }, + handler: () => getKnownSessions(), + }, + 'browser-agent:clear-browsing-data': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'erasing browsing data has to work with the browser off, which is the state a user clearing it is most likely to be in', + needsUserActivation: true, + denied: { sessions: [] }, + handler: async (rawKinds) => { + // Saved passwords are intentionally untouched here; erasing the vault + // is a separate, explicit action. + const kinds = Array.isArray(rawKinds) ? rawKinds.filter(isBrowserDataKind) : undefined + await clearBrowsingData(kinds && kinds.length > 0 ? kinds : undefined) + return getKnownSessions() + }, + }, + 'browser-agent:panel-action': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (action) => { + if ( + typeof action !== 'object' || + action === null || + typeof (action as { action?: unknown }).action !== 'string' + ) { + return + } + void handlePanelAction(action as Parameters[0]).catch(() => {}) + }, + }, + 'browser-agent:set-tab-pinned': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (tabId, pinned) => { + if (typeof tabId !== 'string' || typeof pinned !== 'boolean') return + try { + setTabPinned(tabId, pinned) + } catch {} + }, + }, + 'browser-agent:reorder-tab': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (tabId, targetIndex) => { + if ( + typeof tabId !== 'string' || + typeof targetIndex !== 'number' || + !Number.isFinite(targetIndex) + ) { + return + } + try { + reorderTab(tabId, targetIndex) + } catch {} + }, + }, + 'browser-agent:set-panel-bounds': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, raw, rawAnchor) => { + const bounds = parsePanelBounds(raw) + if (bounds !== undefined) { + deps.browserPanel.setBounds(sender as WebContents, bounds, parsePanelAnchor(rawAnchor)) + } + }, + }, + 'browser-agent:set-panel-focused': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, focused) => { + if (typeof focused === 'boolean') { + deps.browserPanel.setFocused(sender as WebContents, focused) + } + }, + }, + 'browser-agent:set-panel-occluded': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, occluded) => { + if (typeof occluded === 'boolean') { + deps.browserPanel.setOccluded(sender as WebContents, occluded) + } + }, + }, + 'browser-agent:set-theme': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (theme) => { + if (isBrowserTheme(theme)) { + setBrowserTheme(theme) + } + }, + }, + 'browser-agent:find': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (raw) => { + if (typeof raw !== 'object' || raw === null) return + const { query, findNext, forward } = raw as Record + if ( + typeof query !== 'string' || + typeof findNext !== 'boolean' || + typeof forward !== 'boolean' + ) { + return + } + findInActiveTab({ query, findNext, forward }) + }, + }, + 'browser-agent:stop-find': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (focusPage) => { + stopFindInActiveTab(focusPage === true) + }, + }, + // Local Chrome import. This is a user-only surface: no browser tool maps + // to either channel, so the agent has no path to it, and the import itself + // additionally demands a live user gesture — a compromised or scripted + // renderer cannot start one on its own. Only counts come back. + 'browser-import:list-profiles': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: [], + handler: () => listChromeImportProfiles(), + }, + 'browser-import:cookies': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + needsUserActivation: true, + denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + handler: (profileId) => { + // An explicit profile must be honoured or refused, never quietly + // swapped for the default — that would import the wrong account. + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' } + } + return importChromeCookies(typeof profileId === 'string' ? profileId : undefined) + }, + }, + // Cookies and passwords together, so the user only has to authorize once. + 'browser-import:all': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + needsUserActivation: true, + denied: { + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + }, + handler: (profileId, policy) => { + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + } + } + return importChromeData( + typeof profileId === 'string' ? profileId : undefined, + policy === 'replace' ? 'replace' : 'keep-existing' + ) + }, + }, + // Reported by the browser preload for the page it is running in. The + // origin it names is a claim: the fill coordinator re-checks it against + // the live URL before any password is read. + 'browser-credentials:form-state': { + kind: 'send', + gate: 'browser-page', + deviationReason: + "the only sender in this family that is a browser PAGE rather than the Sim app, so browser-page is the correct gate and requires:'browser' follows — with the browser off no such page exists", + requires: 'browser', + passSender: true, + handler: (sender, report) => { + if (!isRecordLike(report)) return + const { origin, hasLoginForm } = report as { origin?: unknown; hasLoginForm?: unknown } + if (typeof origin !== 'string' || typeof hasLoginForm !== 'boolean') return + fillCoordinator()?.noteFormState(sender as WebContents, { origin, hasLoginForm }) + }, + }, + 'browser-credentials:available': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: () => credentialsAvailable(), + }, + 'browser-credentials:list': { + kind: 'invoke', + gate: 'app-origin', + denied: [], + handler: () => listCredentials(), + }, + // Hosts a previous import brought over, with the name and icon the source + // browser gave each one and an aggregate count of how much it was used + // there. No password material, and no browsing history in the sense that + // matters: no visit times, no URLs beyond the host, no ordering of one + // visit against another. + 'browser-import:sites': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser', + denied: [], + handler: () => listSites(), + }, + // The one channel in the whole surface that can return password + // plaintext. It is gated three ways: the Sim app origin, a live user + // gesture, and an OS prompt inside the handler on every single call. + 'browser-credentials:reveal': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: null, + handler: (id) => (typeof id === 'string' ? revealCredential(id) : null), + }, + 'browser-credentials:copy': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: false, + handler: (id) => (typeof id === 'string' ? copyCredential(id) : false), + }, + 'browser-credentials:forget': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: [], + handler: (id) => (typeof id === 'string' ? forgetCredential(id) : listCredentials()), + }, + 'browser-credentials:forget-all': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: [], + handler: () => forgetAllCredentials(), + }, + 'browser-credentials:import': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + "unlike its siblings this WRITES new credentials by driving the embedded browser's import path, so it needs the surface the rest of the family deliberately does without", + requires: 'browser', + needsUserActivation: true, + denied: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + handler: (profileId, policy) => { + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0, error: 'unknown' } + } + return importChromePasswords( + typeof profileId === 'string' ? profileId : undefined, + policy === 'replace' ? 'replace' : 'keep-existing' + ) + }, + }, + // Opens the native account chooser. The renderer only says "the user + // clicked the key icon, here"; it never learns which accounts exist, never + // names one, and never receives a password. The shell performs the fill. + 'browser-credentials:show-chooser': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'it fills into a live browser page, so unlike the read-only management channels beside it there is nothing to act on when the browser is off', + requires: 'browser', + needsUserActivation: true, + passSender: true, + denied: false, + handler: (sender, anchor) => { + const window = deps.getWindowForContents(sender as WebContents) + if (!window || !isRecordLike(anchor)) return false + const { x, y } = anchor as { x?: unknown; y?: unknown } + if ( + typeof x !== 'number' || + typeof y !== 'number' || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return false + } + return fillCoordinator()?.showChooser(window, { x, y }) ?? false + }, + }, + 'terminal:start': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { ok: false, code: 'ACCESS_DENIED', error: 'Not allowed from this page.' }, + handler: (raw) => { + const options = isRecordLike(raw) ? raw : {} + const cols = Number(options.cols) + const rows = Number(options.rows) + try { + return { + ok: true, + tabs: deps.terminal.start({ + cols: toCellCount(cols, 80), + rows: toCellCount(rows, 24), + }), + } + } catch (error) { + const failure = error as { code?: string; message?: string } + return { + ok: false, + code: failure.code ?? 'SPAWN_FAILED', + error: failure.message ?? 'Could not open a terminal.', + } + } + }, + }, + 'terminal:execute-tool': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { ok: false, error: 'Terminal access is not allowed from this page.' }, + handler: (toolCallId, tool, params) => { + if ( + typeof toolCallId !== 'string' || + typeof tool !== 'string' || + !isTerminalToolName(tool) + ) { + return { ok: false, error: `Unknown terminal tool: ${String(tool)}` } + } + const call = isRecordLike(params) ? params : {} + if (!isTerminalOperation(call.operation)) { + return { ok: false, error: `Unknown terminal operation: ${String(call.operation)}` } + } + const args = isRecordLike(call.args) ? (call.args as TerminalToolArgs) : {} + return deps.terminal.executeTool(toolCallId, call.operation, args) + }, + }, + 'terminal:handoff-done': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId) => { + if (typeof terminalId === 'string') deps.terminal.finishHandoff(terminalId) + }, + }, + 'terminal:focused': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + passSender: true, + handler: (sender, focused) => + deps.terminal.setPanelFocused(focused === true, sender as WebContents), + }, + 'terminal:scrollback': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: '', + handler: (terminalId) => + typeof terminalId === 'string' ? deps.terminal.getScrollback(terminalId) : '', + }, + 'terminal:get-tabs': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: () => deps.terminal.getTabs(), + }, + 'terminal:open': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (cwd) => deps.terminal.openTerminal(typeof cwd === 'string' ? cwd : undefined), + }, + 'terminal:switch': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (terminalId) => + typeof terminalId === 'string' + ? deps.terminal.switchTerminal(terminalId) + : deps.terminal.getTabs(), + }, + 'terminal:close': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (terminalId) => + typeof terminalId === 'string' + ? deps.terminal.closeTerminal(terminalId) + : deps.terminal.getTabs(), + }, + 'terminal:write': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId, data) => { + if (typeof terminalId === 'string' && typeof data === 'string') { + deps.terminal.write(terminalId, data) + } + }, + }, + 'terminal:resize': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId, cols, rows) => { + // `typeof NaN === 'number'`, and the downstream `cols <= 0` guard is + // false for NaN, so an unfinite value reached pty.resize() intact. + // Matches the clamping terminal:start already applies to these fields. + if (typeof terminalId !== 'string') return + if (!isPositiveFinite(cols) || !isPositiveFinite(rows)) return + deps.terminal.resize(terminalId, toCellCount(cols, 1), toCellCount(rows, 1)) + }, + }, + 'terminal:dispose': { + kind: 'send', + gate: 'app-origin', + deviationReason: + 'tearing the surface down must survive the surface being off, or a terminal left running when the feature was disabled could never be reaped', + handler: () => deps.terminal.dispose(), + }, + 'offline:retry': { + kind: 'send', + gate: 'local-page', + passSender: true, + handler: (sender) => deps.retryLoad(sender as WebContents), + }, + } + + const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => { + if (gate === 'any') return true + if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) + if (gate === 'browser-page') return isAgentWebContents(event.sender) + return isLocalPageSender(event) + } + + const featureAllowed = (feature: ChannelFeature | undefined): boolean => { + if (!feature) return true + const preferences = deps.settings.getPreferences() + // Absent means on: the surfaces predate the preference. + return feature === 'browser' + ? preferences.browserEnabled !== false + : preferences.terminalEnabled !== false + } + + for (const [channel, spec] of Object.entries(channels)) { + if (spec.kind === 'invoke') { + ipcMain.handle(channel, async (event, ...args) => { + if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return spec.denied + if (spec.needsUserActivation && !(await rendererHasActiveUserGesture(event))) { + return spec.denied + } + let handlerArgs = args + if (channel === 'browser-agent:execute-tool') { + const requestedTool = args[1] + const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) + if ( + !authorization || + typeof requestedTool !== 'string' || + authorization.toolName !== requestedTool || + !isBrowserToolName(authorization.toolName) + ) { + return { + ok: false, + error: 'This browser action is not an authorized pending Copilot tool call.', + } + } + handlerArgs = [authorization.toolName, authorization.args] + } + if (channel === 'terminal:execute-tool') { + const requestedTool = args[1] + const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) + if ( + !authorization || + typeof requestedTool !== 'string' || + authorization.toolName !== requestedTool || + !isTerminalToolName(authorization.toolName) + ) { + return { + ok: false, + error: 'This terminal action is not an authorized pending Copilot tool call.', + } + } + // The command executed is the one the server has on file for this + // tool call, never the one the renderer passed in. + handlerArgs = [args[0], authorization.toolName, authorization.args] + } + if ( + channel === 'desktop:local-filesystem' && + localFilesystemRequestNeedsUserActivation(args[0]) && + !(await rendererHasActiveUserGesture(event)) + ) { + return { + ok: false, + code: 'ACCESS_DENIED', + error: 'This local filesystem action requires an explicit user click.', + } + } + if ( + channel === 'desktop:local-filesystem' && + localFilesystemRequestNeedsToolAuthorization(args[0]) && + !(await authorizeLocalFilesystemTool(event, deps, args[0])) + ) { + return { + ok: false, + code: 'ACCESS_DENIED', + error: 'This local filesystem request is not an authorized pending Copilot tool call.', + } + } + if (spec.passSender) { + handlerArgs = [event.sender, ...handlerArgs] + } + return spec.handler(...handlerArgs) + }) + } else { + ipcMain.on(channel, (event, ...args) => { + if (senderAllowed(event, spec.gate) && featureAllowed(spec.requires)) { + spec.handler(...(spec.passSender ? [event.sender, ...args] : args)) + } + }) + } + } +} diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts new file mode 100644 index 0000000000..e313d0a653 --- /dev/null +++ b/apps/desktop/src/main/load-health.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { classifyLoadError } from '@/main/load-health' + +describe('classifyLoadError', () => { + it('ignores aborted navigations (OAuth redirects abort constantly)', () => { + expect(classifyLoadError(-3)).toBe('ignored') + expect(classifyLoadError(0)).toBe('ignored') + }) + + it('does NOT ignore ERR_FAILED (-2) or ERR_IO_PENDING (-1)', () => { + expect(classifyLoadError(-2)).toBe('unreachable') + expect(classifyLoadError(-1)).toBe('unreachable') + }) + + it('classifies connectivity failures', () => { + expect(classifyLoadError(-106)).toBe('offline') + expect(classifyLoadError(-105)).toBe('dns') + expect(classifyLoadError(-137)).toBe('dns') + expect(classifyLoadError(-7)).toBe('timeout') + expect(classifyLoadError(-118)).toBe('timeout') + }) + + it('classifies TLS failures', () => { + expect(classifyLoadError(-200)).toBe('tls') + expect(classifyLoadError(-201)).toBe('tls') + expect(classifyLoadError(-213)).toBe('tls') + }) + + it('falls back to unreachable for other network errors', () => { + expect(classifyLoadError(-102)).toBe('unreachable') + expect(classifyLoadError(-21)).toBe('unreachable') + expect(classifyLoadError(-324)).toBe('unreachable') + }) +}) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts new file mode 100644 index 0000000000..ab8b9d4c14 --- /dev/null +++ b/apps/desktop/src/main/load-health.ts @@ -0,0 +1,154 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow } from 'electron' +import { type EventRecorder, scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopLoadHealth') + +const AUTO_RETRY_INTERVAL_MS = 5000 +const LOAD_WATCHDOG_MS = 30_000 + +export type LoadErrorKind = 'offline' | 'dns' | 'tls' | 'timeout' | 'unreachable' | 'ignored' + +/** + * Maps Chromium net error codes to recovery copy. -3 (ERR_ABORTED) is emitted + * constantly by OAuth redirect chains and in-app aborts and must be ignored. + */ +export function classifyLoadError(errorCode: number): LoadErrorKind { + // Only ERR_ABORTED (-3) and success (0) are ignored. ERR_FAILED (-2) and + // ERR_IO_PENDING (-1) are real failures that must surface the offline page. + if (errorCode === 0 || errorCode === -3) { + return 'ignored' + } + if (errorCode === -106) { + return 'offline' + } + if (errorCode === -105 || errorCode === -137) { + return 'dns' + } + if (errorCode === -7 || errorCode === -118) { + return 'timeout' + } + if (errorCode <= -200 && errorCode >= -213) { + return 'tls' + } + return 'unreachable' +} + +export interface LoadHealthDeps { + offlinePagePath: string + getStartUrl: () => string + isOnline: () => boolean + events: EventRecorder +} + +export interface LoadHealthHandle { + retry(): void + startWatchdog(): void +} + +/** + * Branded recovery for a fully remote renderer: on main-frame load failures + * the window swaps to the bundled offline page (a local file, never wrapping + * the origin), auto-retries when the network returns, and a first-paint + * watchdog catches servers that accept connections but never respond. + */ +export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): LoadHealthHandle { + let intendedUrl: string | null = null + let showingOffline = false + let retryTimer: NodeJS.Timeout | undefined + let watchdogTimer: NodeJS.Timeout | undefined + + const stopAutoRetry = () => { + clearInterval(retryTimer) + retryTimer = undefined + } + + const startWatchdog = () => { + clearTimeout(watchdogTimer) + watchdogTimer = setTimeout(() => { + if (!win.isDestroyed() && win.webContents.isLoading()) { + showOffline('timeout', 'The app took too long to load') + } + }, LOAD_WATCHDOG_MS) + } + + const retry = () => { + if (win.isDestroyed()) { + return + } + const target = intendedUrl ?? deps.getStartUrl() + logger.info('Retrying load', { url: scrubUrl(target) }) + // Re-arm before loading. The caller has already stopped auto-retry, so if + // this load hangs — the exact case the watchdog exists for — no load event + // ever fires and without this no timer is left to recover the window. + startWatchdog() + void win.loadURL(target) + } + + const startAutoRetry = () => { + if (retryTimer) { + return + } + retryTimer = setInterval(() => { + if (!showingOffline || win.isDestroyed()) { + stopAutoRetry() + return + } + if (deps.isOnline()) { + stopAutoRetry() + retry() + } + }, AUTO_RETRY_INTERVAL_MS) + } + + const showOffline = (kind: LoadErrorKind, detail: string) => { + if (win.isDestroyed()) { + return + } + showingOffline = true + deps.events.record('load_failure', { kind, detail }) + void win.loadFile(deps.offlinePagePath, { query: { kind, detail } }) + startAutoRetry() + } + + win.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return + } + clearTimeout(watchdogTimer) + const kind = classifyLoadError(errorCode) + if (kind === 'ignored') { + return + } + if (validatedURL?.startsWith('http')) { + intendedUrl = validatedURL + } + logger.warn('Main-frame load failed', { + kind, + errorCode, + errorDescription, + url: scrubUrl(validatedURL ?? ''), + }) + showOffline(kind, `${errorDescription} (${errorCode})`) + } + ) + + win.webContents.on('did-finish-load', () => { + clearTimeout(watchdogTimer) + const url = win.webContents.getURL() + if (url.startsWith('http')) { + showingOffline = false + intendedUrl = null + stopAutoRetry() + } + }) + + win.on('closed', () => { + stopAutoRetry() + clearTimeout(watchdogTimer) + }) + + return { retry, startWatchdog } +} diff --git a/apps/desktop/src/main/local-filesystem-grant-store.test.ts b/apps/desktop/src/main/local-filesystem-grant-store.test.ts new file mode 100644 index 0000000000..043a3577cb --- /dev/null +++ b/apps/desktop/src/main/local-filesystem-grant-store.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' + +function testEncryption(available = true) { + return { + isEncryptionAvailable: vi.fn(() => available), + encryptString: vi.fn((value: string) => Buffer.from(`protected:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^protected:/, '')), + } +} + +describe('createEncryptedLocalFilesystemGrantStore', () => { + it('encrypts grants at rest and restores them', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const encryption = testEncryption() + const store = createEncryptedLocalFilesystemGrantStore(filePath, encryption) + const grants = [ + { + id: 'grant-1', + name: 'project', + rootPath: '/Users/example/private-project', + bookmark: 'security-scoped-bookmark', + }, + ] + + await expect(store.save(grants)).resolves.toBe(true) + + const raw = await readFile(filePath, 'utf8') + expect(raw).not.toContain(grants[0].rootPath) + expect(raw).not.toContain(grants[0].bookmark) + expect(encryption.encryptString).toHaveBeenCalledOnce() + await expect(store.load()).resolves.toEqual(grants) + + await store.clear() + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('does not write a plaintext fallback when OS encryption is unavailable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption(false)) + + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/apps/desktop/src/main/local-filesystem-grant-store.ts b/apps/desktop/src/main/local-filesystem-grant-store.ts new file mode 100644 index 0000000000..2a086e1125 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem-grant-store.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' + +const STORE_VERSION = 1 + +export interface PersistedLocalFilesystemGrant { + id: string + name: string + rootPath: string + bookmark?: string +} + +export interface LocalFilesystemGrantStore { + load(): Promise + save(grants: PersistedLocalFilesystemGrant[]): Promise + clear(): Promise +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +interface EncryptedGrantEnvelope { + version: typeof STORE_VERSION + ciphertext: string +} + +function isPersistedGrant(value: unknown): value is PersistedLocalFilesystemGrant { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const grant = value as Record + return ( + typeof grant.id === 'string' && + typeof grant.name === 'string' && + typeof grant.rootPath === 'string' && + (grant.bookmark === undefined || typeof grant.bookmark === 'string') + ) +} + +/** + * `safeStorage.isEncryptionAvailable()` throws rather than returning false on a + * Linux box with no keyring, and an unguarded call propagated out of grant + * persistence. Grants stay session-only when encryption is unavailable. + */ +function encryptionAvailable(encryption: EncryptionProvider): boolean { + try { + return encryption.isEncryptionAvailable() + } catch { + return false + } +} + +/** + * Stores host paths and optional macOS security-scoped bookmarks encrypted + * with Electron safeStorage (Keychain on macOS, DPAPI on Windows, and the + * desktop keyring on supported Linux environments). No plaintext fallback is + * used: when OS-backed encryption is unavailable, grants remain session-only. + */ +export function createEncryptedLocalFilesystemGrantStore( + filePath: string, + encryption: EncryptionProvider = safeStorage +): LocalFilesystemGrantStore { + return { + async load() { + if (!encryptionAvailable(encryption)) return [] + try { + const raw = JSON.parse(await readFile(filePath, 'utf8')) as Partial + if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') return [] + const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + const parsed = JSON.parse(decrypted) as unknown + return Array.isArray(parsed) ? parsed.filter(isPersistedGrant) : [] + } catch { + return [] + } + }, + + async save(grants) { + if (!encryptionAvailable(encryption)) return false + const encrypted = encryption.encryptString(JSON.stringify(grants)) + const envelope: EncryptedGrantEnvelope = { + version: STORE_VERSION, + ciphertext: encrypted.toString('base64'), + } + await writeJsonFileAtomically(filePath, envelope) + return true + }, + + async clear() { + await removeFileIfPresent(filePath) + }, + } +} diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts new file mode 100644 index 0000000000..e54418f11a --- /dev/null +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -0,0 +1,536 @@ +import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { LocalFilesystemMount, LocalFilesystemResponse } from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { shell } from 'electron' +import { LocalFilesystemService } from '@/main/local-filesystem' +import type { + LocalFilesystemGrantStore, + PersistedLocalFilesystemGrant, +} from '@/main/local-filesystem-grant-store' + +class MemoryGrantStore implements LocalFilesystemGrantStore { + grants: PersistedLocalFilesystemGrant[] = [] + + async load(): Promise { + return structuredClone(this.grants) + } + + async save(grants: PersistedLocalFilesystemGrant[]): Promise { + this.grants = structuredClone(grants) + return true + } + + async clear(): Promise { + this.grants = [] + } +} + +function dataOf(response: LocalFilesystemResponse) { + expect(response.ok).toBe(true) + if (!response.ok) throw new Error(response.error) + return response.data +} + +async function mount(service: LocalFilesystemService): Promise { + const data = dataOf(await service.handle({ operation: 'mount_directory' })) + if (!('mount' in data) || !data.mount) throw new Error('Expected a mounted directory') + return data.mount +} + +describe('LocalFilesystemService', () => { + let root: string + let service: LocalFilesystemService + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'sim-localfs-')) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'README.md'), 'hello world\nsecond line\n') + await writeFile(join(root, 'src', 'index.ts'), 'export const answer = 42\n') + service = new LocalFilesystemService({ + chooseDirectory: async () => root, + }) + }) + + it('returns opaque mount metadata without exposing the host path', async () => { + const granted = await mount(service) + expect(granted.uri).toMatch(/^localfs:\/\/[^/]+\/$/) + expect(granted).not.toHaveProperty('path') + + const listData = dataOf(await service.handle({ operation: 'list_mounts' })) + expect(listData).toEqual({ mounts: [granted] }) + }) + + it('refuses glob patterns that would be ruinously expensive to evaluate', async () => { + const granted = await mount(service) + // Micromatch backtracking is exponential in wildcard count. Measured + // against a single 46-character path, the 10-wildcard pattern below took + // 2.7s and a 12-wildcard one 43s — per scanned entry, in one synchronous + // call that no abort check can interrupt, on the main process. + const pathological = ['**/*a*a*a*a*a*b', '**/*a*a*a*a*a*a*a*b', '*'.repeat(40)] + + for (const pattern of pathological) { + const response = await service.handle({ operation: 'glob', uri: granted.uri, pattern }) + expect(response.ok).toBe(false) + } + }) + + it('reports an invalid grep regex instead of claiming there are no matches', async () => { + const granted = await mount(service) + + const response = await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: '([unclosed', + }) + + // Returning an empty match set would tell the model the string appears + // nowhere in the user's files, which it would then act on as fact. + expect(response.ok).toBe(false) + }) + + it('still accepts the glob patterns people actually write', async () => { + const granted = await mount(service) + + for (const pattern of ['**/*.ts', 'src/**/*.tsx', '**/node_modules/**', '**/*spec*']) { + const response = await service.handle({ operation: 'glob', uri: granted.uri, pattern }) + expect(response.ok).toBe(true) + } + }) + + it('lists, reads, globs, greps, and stats inside the selected directory', async () => { + const granted = await mount(service) + + const listData = dataOf(await service.handle({ operation: 'list', uri: granted.uri })) + expect('entries' in listData && listData.entries.map((entry) => entry.name)).toEqual([ + 'README.md', + 'src', + ]) + + const readData = dataOf( + await service.handle({ + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 2, + lineCount: 1, + }) + ) + expect(readData).toMatchObject({ content: 'second line', startLine: 2, endLine: 2 }) + + const globData = dataOf( + await service.handle({ operation: 'glob', uri: granted.uri, pattern: '**/*.ts' }) + ) + expect( + 'entries' in globData && globData.entries.map((entry) => entry.uri.replace(granted.uri, '')) + ).toEqual(['src/index.ts']) + + const grepData = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + query: 'ANSWER', + include: '**/*.ts', + }) + ) + expect(grepData).toMatchObject({ + matches: [{ line: 1, text: 'export const answer = 42' }], + }) + + const statData = dataOf( + await service.handle({ operation: 'stat', uri: `${granted.uri}src/index.ts` }) + ) + expect(statData).toMatchObject({ name: 'index.ts', kind: 'file' }) + }) + + it('supports the normal VFS grep regex and output modes', async () => { + const granted = await mount(service) + + const content = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'hello|answer\\s*=\\s*42', + outputMode: 'content', + caseSensitive: true, + maxResults: 10, + }) + ) + expect(content).toMatchObject({ + matches: [ + { uri: `${granted.uri}README.md`, line: 1, text: 'hello world' }, + { uri: `${granted.uri}src/index.ts`, line: 1, text: 'export const answer = 42' }, + ], + }) + + const files = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'second line', + outputMode: 'files_with_matches', + }) + ) + expect(files).toEqual({ files: [`${granted.uri}README.md`], truncated: false }) + + const counts = dataOf( + await service.handle({ + operation: 'grep', + uri: `${granted.uri}README.md`, + pattern: 'line', + outputMode: 'count', + }) + ) + expect(counts).toEqual({ + counts: [{ uri: `${granted.uri}README.md`, count: 1 }], + truncated: false, + }) + }) + + it('rejects grep regexes with catastrophic-backtracking risk', async () => { + const granted = await mount(service) + await expect( + service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: '(a+)+$', + }) + ).resolves.toMatchObject({ + ok: false, + code: 'INVALID_REQUEST', + error: expect.stringContaining('catastrophic backtracking'), + }) + }) + + it('cancels a native scan by request id', async () => { + const granted = await mount(service) + for (let index = 0; index < 200; index++) { + await writeFile(join(root, `file-${index}.txt`), `line ${index}\n`) + } + + const pending = service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'never-matches', + requestId: 'tool-abort', + }) + const cancelled = dataOf(await service.handle({ operation: 'cancel', requestId: 'tool-abort' })) + expect(cancelled).toEqual({ cancelled: true }) + await expect(pending).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + }) + + it('does not expose a raw-byte read operation', async () => { + const granted = await mount(service) + await expect( + service.handle({ operation: 'read_file_bytes', uri: `${granted.uri}README.md` }) + ).resolves.toMatchObject({ ok: false, code: 'INVALID_REQUEST' }) + }) + + it('authorizes a request whose omitted args resolved to the shared defaults', async () => { + // The failure mode this guards is silent: with an arg omitted there is no + // value on the wire to disagree about, only two defaulting tables — the + // renderer's, resolving what to send, and the authorizer's, resolving what + // to expect. If they drift, a legitimate tool call is DENIED rather than + // erroring. Both now read these from @sim/desktop-bridge; this pins the + // authorizer half to them. + const granted = await mount(service) + const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 1, + lineCount: DEFAULT_READ_LINES, + requestId: 'read-defaults', + }, + { toolName: 'read', args: { path: `${vfsRoot}/README.md` } } + ) + ).toBe(true) + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: true, + outputMode: 'content', + lineNumbers: true, + context: DEFAULT_GREP_CONTEXT, + maxResults: DEFAULT_GREP_RESULTS, + requestId: 'grep-defaults', + }, + { toolName: 'grep', args: { path: 'user-local', pattern: 'TODO' } } + ) + ).toBe(true) + }) + + it('binds privileged client reads and searches to server-persisted tool args', async () => { + const granted = await mount(service) + const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 3, + lineCount: 25, + requestId: 'read-tool', + }, + { + toolName: 'read', + args: { path: `${vfsRoot}/README.md`, offset: 2, limit: 25 }, + } + ) + ).toBe(true) + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}src/index.ts`, + startLine: 3, + lineCount: 25, + requestId: 'read-tool', + }, + { + toolName: 'read', + args: { path: `${vfsRoot}/README.md`, offset: 2, limit: 25 }, + } + ) + ).toBe(false) + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'glob', + uri: granted.uri, + pattern: `${vfsRoot}/**/*.ts`, + pathPrefix: vfsRoot, + requestId: 'glob-tool', + }, + { toolName: 'glob', args: { pattern: `${vfsRoot}/**/*.ts` } } + ) + ).toBe(true) + + const grepAuthorization = { + toolName: 'grep', + args: { + path: 'user-local', + pattern: 'TODO', + ignoreCase: true, + output_mode: 'files_with_matches', + maxResults: 20, + }, + } + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + }, + grepAuthorization + ) + ).toBe(true) + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'PASSWORD', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + }, + grepAuthorization + ) + ).toBe(false) + + const authorizedGrepRequest = { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + } + + // A tool call whose args carry no pattern once made the comparison + // `undefined !== undefined`, so the guard passed and grep fell back to + // searching the renderer's own `query`. + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, pattern: undefined, query: 'PASSWORD' }, + { toolName: 'grep', args: { ...grepAuthorization.args, pattern: undefined } } + ) + ).toBe(false) + + // `query` and `include` are read by grep() but never sent by the authorized + // path, so smuggling either widens or silently narrows the search. + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, query: 'PASSWORD' }, + grepAuthorization + ) + ).toBe(false) + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, include: '**/nothing-here/**' }, + grepAuthorization + ) + ).toBe(false) + }) + + it('rejects unknown mounts and symlinks that escape the selected directory', async () => { + const granted = await mount(service) + const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) + await writeFile(join(outside, 'secret.txt'), 'secret') + await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) + + const missingMount = await service.handle({ + operation: 'read', + uri: 'localfs://not-granted/file.txt', + }) + expect(missingMount).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) + + const escaped = await service.handle({ + operation: 'read', + uri: `${granted.uri}secret-link.txt`, + }) + expect(escaped).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + }) + + it('rejects lexical traversal before URL normalization can reinterpret it', async () => { + const granted = await mount(service) + const traversal = await service.handle({ + operation: 'read', + uri: `${granted.uri}../README.md`, + }) + + expect(traversal).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + }) + + it('reads a child whose name merely starts with dots', async () => { + // The containment check compares path SEGMENTS. Testing the two leading + // characters instead denies real files: `..config` is an ordinary name, + // not a walk out of the root. + await writeFile(join(root, '..config'), 'kept\n') + const granted = await mount(service) + + const response = await service.handle({ + operation: 'read', + uri: `${granted.uri}..config`, + }) + + expect(response.ok).toBe(true) + }) + + it('clears all grants without touching files on disk', async () => { + const granted = await mount(service) + service.close() + + const response = await service.handle({ operation: 'stat', uri: granted.uri }) + expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) + }) + + it('restores an encrypted grant with the same opaque URI after restart', async () => { + const grantStore = new MemoryGrantStore() + const firstStopAccessing = vi.fn() + const firstService = new LocalFilesystemService({ + chooseDirectory: async () => ({ path: root, bookmark: 'bookmark' }), + grantStore, + startAccessingBookmark: () => firstStopAccessing, + }) + + const granted = await mount(firstService) + const canonicalRoot = await realpath(root) + expect(granted).toMatchObject({ remembered: true }) + expect(grantStore.grants).toMatchObject([ + { id: granted.id, rootPath: canonicalRoot, bookmark: 'bookmark' }, + ]) + + firstService.close() + expect(firstStopAccessing).toHaveBeenCalledOnce() + + const restoredStopAccessing = vi.fn() + const restoredService = new LocalFilesystemService({ + grantStore, + startAccessingBookmark: () => restoredStopAccessing, + }) + await restoredService.initialize() + + const listData = dataOf(await restoredService.handle({ operation: 'list_mounts' })) + expect(listData).toEqual({ mounts: [granted] }) + const statData = dataOf( + await restoredService.handle({ + operation: 'stat', + uri: `${granted.uri}README.md`, + }) + ) + expect(statData).toMatchObject({ name: 'README.md', kind: 'file' }) + + const forgotten = dataOf( + await restoredService.handle({ operation: 'forget_mount', uri: granted.uri }) + ) + expect(forgotten).toEqual({ forgotten: true }) + expect(restoredStopAccessing).toHaveBeenCalledOnce() + expect(grantStore.grants).toEqual([]) + + const nextLaunch = new LocalFilesystemService({ grantStore }) + await nextLaunch.initialize() + expect(dataOf(await nextLaunch.handle({ operation: 'list_mounts' }))).toEqual({ mounts: [] }) + }) + + it('keeps a grant session-only when secure persistence is unavailable', async () => { + const grantStore: LocalFilesystemGrantStore = { + load: async () => [], + save: async () => false, + clear: async () => {}, + } + const sessionService = new LocalFilesystemService({ + chooseDirectory: async () => root, + grantStore, + }) + + expect(await mount(sessionService)).toMatchObject({ remembered: false }) + }) + + it('shows a granted folder in the file manager, and only a granted one', async () => { + const granted = await mount(service) + + expect(dataOf(await service.handle({ operation: 'reveal_mount', uri: granted.uri }))).toEqual({ + revealed: true, + }) + expect(shell.showItemInFolder).toHaveBeenCalledWith(await realpath(root)) + + const unknown = await service.handle({ + operation: 'reveal_mount', + uri: 'localfs://not-a-mount/', + }) + expect(unknown.ok).toBe(false) + expect(shell.showItemInFolder).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts new file mode 100644 index 0000000000..0b115133a0 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem.ts @@ -0,0 +1,1140 @@ +import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { basename, isAbsolute, relative, resolve, sep } from 'node:path' +import type { + LocalFilesystemData, + LocalFilesystemEntry, + LocalFilesystemEntryKind, + LocalFilesystemGrepMatch, + LocalFilesystemMount, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, + MAX_GREP_CONTEXT, + MAX_GREP_RESULTS, + MAX_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { app, dialog, shell } from 'electron' +import micromatch from 'micromatch' +import safeRegex from 'safe-regex2' +import type { + LocalFilesystemGrantStore, + PersistedLocalFilesystemGrant, +} from '@/main/local-filesystem-grant-store' + +const MAX_URI_LENGTH = 4096 +const MAX_LIST_ENTRIES = 500 +const MAX_SCAN_ENTRIES = 10_000 +const MAX_SCAN_DEPTH = 50 +const MAX_GLOB_RESULTS = 500 +const MAX_GLOB_LENGTH = 128 +/** + * Measured cost of one match against a single 46-character path: six wildcards + * 1.8ms, eight 96ms, ten 2.7s, twelve 43s. Six keeps the worst case around 2ms + * per scanned entry while leaving headroom over real patterns, which top out + * around four (`**\/node_modules\/**`, `**\/*spec*`). + */ +const MAX_GLOB_WILDCARDS = 6 +/** + * Backstop only. Deliberately far above the ~0.1ms real patterns cost, because + * elapsed time varies with JIT warmth and machine load — a tight budget here + * rejects legitimate patterns on a busy machine and accepts bad ones on an idle + * one. {@link MAX_GLOB_WILDCARDS} is the deterministic bound. + */ +const GLOB_PROBE_BUDGET_MS = 100 +/** Repeated-literal path that provokes backtracking in a pathological glob. */ +const GLOB_PROBE_PATH = `${'a'.repeat(32)}/${'a'.repeat(32)}.txt` +const MAX_TEXT_FILE_BYTES = 5 * 1024 * 1024 +const MAX_GREP_SCAN_BYTES = 100 * 1024 * 1024 +const MAX_GREP_LINE_LENGTH = 500 +const REQUEST_ID_PATTERN = /^[^\x00-\x1f\x7f]{1,256}$/ + +type LocalFilesystemErrorCode = Extract['code'] + +class LocalFilesystemError extends Error { + constructor( + public readonly code: LocalFilesystemErrorCode, + message: string + ) { + super(message) + this.name = 'LocalFilesystemError' + } +} + +interface GrantedMount extends LocalFilesystemMount { + rootPath: string + bookmark?: string + stopAccessing?: () => void +} + +interface ResolvedLocalPath { + mount: GrantedMount + relativePath: string + lexicalPath: string + realPath: string +} + +interface LocalFilesystemServiceOptions { + chooseDirectory?: () => Promise + grantStore?: LocalFilesystemGrantStore + startAccessingBookmark?: (bookmark: string) => (() => void) | undefined +} + +interface SelectedDirectory { + path: string + bookmark?: string +} + +export interface LocalFilesystemToolAuthorization { + toolName: string + args: Record +} + +/** + * Whether a resolved path is the granted root or sits beneath it. + * + * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel + * that form needs to reject `/granted-evil` against `/granted` becomes `//` when + * the root IS a separator — and the picker lets the user grant a volume root — + * so every path under a granted `/` was denied while the bare root passed. + * `relative()` has no such edge case: it returns `''` for the root itself and a + * leading `..` segment for anything outside, on both separators. + */ +function isWithinRoot(rootPath: string, candidatePath: string): boolean { + const rel = relative(rootPath, candidatePath) + if (rel === '') return true + // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary + // child name, and matching it as an escape would deny a real dotfile. + if (rel === '..' || rel.startsWith(`..${sep}`)) return false + // A different Windows volume relativizes to an absolute path rather than a + // `..` walk, so it has to be rejected separately. + return !isAbsolute(rel) +} + +function entryKind(entry: { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean +}): LocalFilesystemEntryKind { + if (entry.isFile()) return 'file' + if (entry.isDirectory()) return 'directory' + if (entry.isSymbolicLink()) return 'symlink' + return 'other' +} + +function encodeUriPath(relativePath: string): string { + return relativePath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') +} + +function localUri(mountId: string, relativePath = ''): string { + const encodedPath = encodeUriPath(relativePath) + return `localfs://${mountId}/${encodedPath}` +} + +function normalizeVfsDisplaySegment(segment: string): string { + return segment + .normalize('NFC') + .trim() + .replace(/[\x00-\x1f\x7f]/g, '') + .replace(/\s+/g, ' ') +} + +function mountVfsRoot(mount: GrantedMount): string { + return `user-local/${encodeURIComponent(normalizeVfsDisplaySegment(mount.name))}--${mount.id}` +} + +function parsePositiveInteger(value: unknown, name: string, fallback: number, max: number): number { + if (value === undefined) return fallback + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > max) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `${name} must be an integer between 1 and ${max}.` + ) + } + return value as number +} + +/** + * Compiles a glob, refusing patterns that would be ruinously expensive to + * evaluate. + * + * Micromatch produces a backtracking regex whose cost grows exponentially with + * the number of wildcards separated by literals. Measured against a single + * 46-character path with the options below, `**\/*a*a*a*a*a*a*a*b` takes 2.7s + * and two more wildcards take 43s. The matcher runs once per scanned entry, up + * to {@link MAX_SCAN_ENTRIES}, inside one synchronous call — so the abort + * checks around it never get a turn and an unbounded pattern freezes the whole + * main process, taking every window, the menu bar and the tray with it. + * `safeRegex` does not catch this: it reports the generated source as safe. + * + * The wildcard cap is the real bound and is deterministic. The probe is a + * backstop for a shape the cap does not anticipate, with a budget loose enough + * that timing variance cannot make it fire on a legitimate pattern. + */ +function compileGlob(pattern: string): (path: string) => boolean { + if ( + !pattern || + pattern.length > MAX_GLOB_LENGTH || + pattern.includes('\0') || + pattern.includes('\\') + ) { + throw new LocalFilesystemError('INVALID_REQUEST', 'Glob pattern is invalid.') + } + if (isAbsolute(pattern) || pattern.split('/').some((segment) => segment === '..')) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Glob patterns must stay within the selected directory.' + ) + } + const wildcards = (pattern.match(/[*?]/g) ?? []).length + if (wildcards > MAX_GLOB_WILDCARDS) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `Glob patterns may use at most ${MAX_GLOB_WILDCARDS} wildcards.` + ) + } + + const matcher = micromatch.matcher(pattern, { + bash: false, + dot: false, + windows: false, + nobrace: true, + noext: true, + }) + + const startedAt = performance.now() + matcher(GLOB_PROBE_PATH) + if (performance.now() - startedAt > GLOB_PROBE_BUDGET_MS) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Glob pattern is too expensive to evaluate. Use fewer wildcards.' + ) + } + return matcher +} + +function isBinary(buffer: Uint8Array): boolean { + const sampleLength = Math.min(buffer.length, 8192) + for (let index = 0; index < sampleLength; index++) { + if (buffer[index] === 0) return true + } + return false +} + +function safeError(error: unknown): LocalFilesystemError { + if (error instanceof LocalFilesystemError) return error + if (error instanceof DOMException && error.name === 'AbortError') { + return new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } + if (error instanceof Error && error.name === 'AbortError') { + return new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : '' + if (code === 'ENOENT') { + return new LocalFilesystemError('NOT_FOUND', 'The local file or directory was not found.') + } + if (code === 'EACCES' || code === 'EPERM') { + return new LocalFilesystemError('ACCESS_DENIED', 'The operating system denied access.') + } + return new LocalFilesystemError('IO_ERROR', 'The local filesystem operation failed.') +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } +} + +export class LocalFilesystemService { + private readonly mounts = new Map() + private readonly activeRequests = new Map() + private readonly chooseDirectory: () => Promise + private readonly grantStore?: LocalFilesystemGrantStore + private readonly startAccessingBookmark: (bookmark: string) => (() => void) | undefined + private initializePromise?: Promise + + constructor(options: LocalFilesystemServiceOptions = {}) { + this.grantStore = options.grantStore + this.startAccessingBookmark = + options.startAccessingBookmark ?? + ((bookmark) => { + try { + return app.startAccessingSecurityScopedResource(bookmark) as () => void + } catch { + return undefined + } + }) + this.chooseDirectory = + options.chooseDirectory ?? + (async () => { + const result = await dialog.showOpenDialog({ + title: 'Allow Sim to read a folder', + buttonLabel: 'Allow', + properties: ['openDirectory'], + ...(process.platform === 'darwin' ? { securityScopedBookmarks: true } : {}), + }) + if (result.canceled || !result.filePaths[0]) return null + return { + path: result.filePaths[0], + ...(result.bookmarks?.[0] ? { bookmark: result.bookmarks[0] } : {}), + } + }) + } + + /** + * Restore encrypted grants after Electron is ready. Invalid, moved, or + * OS-revoked directories are skipped without exposing their host paths. + */ + initialize(): Promise { + return (this.initializePromise ??= this.restoreRememberedMounts()) + } + + /** Release active OS handles while keeping encrypted grants for next launch. */ + close(): void { + for (const controller of this.activeRequests.values()) { + controller.abort() + } + this.activeRequests.clear() + for (const mount of this.mounts.values()) { + mount.stopAccessing?.() + } + this.mounts.clear() + } + + /** Revoke every remembered grant, used on sign-out and origin changes. */ + async forgetAll(): Promise { + this.close() + await this.grantStore?.clear() + } + + async handle(request: unknown): Promise { + try { + if (!isRecordLike(request) || typeof request.operation !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'Local filesystem request is invalid.') + } + + if (request.operation === 'cancel') { + const requestId = this.requiredRequestId(request) + const controller = this.activeRequests.get(requestId) + controller?.abort() + return { ok: true, data: { cancelled: controller !== undefined } } + } + + const requestId = + request.requestId === undefined ? undefined : this.requiredRequestId(request) + if (requestId && this.activeRequests.has(requestId)) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'A local filesystem operation with that request id is already running.' + ) + } + const controller = requestId ? new AbortController() : undefined + if (requestId && controller) { + this.activeRequests.set(requestId, controller) + } + + let data: LocalFilesystemData + try { + switch (request.operation) { + case 'mount_directory': + data = await this.mountDirectory() + break + case 'list_mounts': + data = this.listMounts() + break + case 'forget_mount': + data = await this.forgetMount(this.requiredUri(request)) + break + case 'reveal_mount': + data = this.revealMount(this.requiredUri(request)) + break + case 'list': + data = await this.listDirectory(this.requiredUri(request)) + break + case 'glob': + data = await this.glob( + this.requiredUri(request), + this.requiredString(request, 'pattern'), + request.pathPrefix, + controller?.signal + ) + break + case 'read': + data = await this.readText( + this.requiredUri(request), + request.startLine, + request.lineCount, + controller?.signal + ) + break + case 'grep': + data = await this.grep(this.requiredUri(request), request, controller?.signal) + break + case 'stat': + data = await this.statPath(this.requiredUri(request)) + break + default: + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Local filesystem operation is not supported.' + ) + } + } finally { + if (requestId) { + this.activeRequests.delete(requestId) + } + } + return { ok: true, data } + } catch (error) { + const safe = safeError(error) + return { ok: false, code: safe.code, error: safe.message } + } + } + + /** + * Bind a privileged read/search request to the canonical args persisted for + * one authenticated pending client tool call. Renderer code chooses neither + * a different operation nor a different granted path. + */ + isAuthorizedClientToolRequest( + request: unknown, + authorization: LocalFilesystemToolAuthorization + ): boolean { + if (!isRecordLike(request) || typeof request.operation !== 'string') return false + if ( + typeof request.requestId !== 'string' || + request.requestId.length === 0 || + !REQUEST_ID_PATTERN.test(request.requestId) + ) { + return false + } + const args = authorization.args + + const expectedUriForPath = (path: unknown): string | null => { + if (typeof path !== 'string') return null + for (const mount of this.mounts.values()) { + const root = mountVfsRoot(mount) + if (path === root) return mount.uri + if (path.startsWith(`${root}/`)) { + return `${mount.uri}${path.slice(root.length + 1)}` + } + } + return null + } + + switch (authorization.toolName) { + case 'read': { + if (request.operation !== 'read') return false + const expectedUri = expectedUriForPath(args.path) + const offset = + typeof args.offset === 'number' && Number.isFinite(args.offset) + ? Math.max(0, Math.trunc(args.offset)) + : 0 + const limit = + typeof args.limit === 'number' && Number.isFinite(args.limit) + ? Math.min(MAX_READ_LINES, Math.max(1, Math.trunc(args.limit))) + : DEFAULT_READ_LINES + return ( + expectedUri !== null && + request.uri === expectedUri && + request.startLine === offset + 1 && + request.lineCount === limit + ) + } + case 'grep': { + // `typeof` first, mirroring the glob case below: without it, a tool + // call whose args carry no pattern makes this `undefined !== undefined` + // and the guard passes. + if ( + request.operation !== 'grep' || + typeof args.pattern !== 'string' || + request.pattern !== args.pattern + ) { + return false + } + // `grep()` falls back to `query` when `pattern` is absent, and narrows + // the file set by `include`. The authorized path sends neither, so a + // request carrying them is the renderer searching for something the + // model did not ask for, or hiding results it believes are complete. + if (request.query !== undefined || request.include !== undefined) return false + const rawPath = typeof args.path === 'string' ? args.path.replace(/\/+$/, '') : '' + const uriAllowed = + rawPath === 'user-local' + ? [...this.mounts.values()].some((mount) => request.uri === mount.uri) + : request.uri === expectedUriForPath(rawPath) + const outputMode = + args.output_mode === 'files_with_matches' || args.output_mode === 'count' + ? args.output_mode + : 'content' + const maxResults = + typeof args.maxResults === 'number' && Number.isFinite(args.maxResults) + ? Math.min(MAX_GREP_RESULTS, Math.max(1, Math.trunc(args.maxResults))) + : DEFAULT_GREP_RESULTS + const context = + typeof args.context === 'number' && Number.isFinite(args.context) + ? Math.min(MAX_GREP_CONTEXT, Math.max(0, Math.trunc(args.context))) + : DEFAULT_GREP_CONTEXT + return ( + uriAllowed && + request.caseSensitive === (args.ignoreCase !== true) && + request.maxResults === maxResults && + request.outputMode === outputMode && + request.lineNumbers === (args.lineNumbers !== false) && + request.context === context + ) + } + case 'glob': { + if ( + request.operation !== 'glob' || + typeof args.pattern !== 'string' || + request.pattern !== args.pattern + ) { + return false + } + for (const mount of this.mounts.values()) { + if (request.uri !== mount.uri) continue + return request.pathPrefix === mountVfsRoot(mount) + } + return false + } + default: + return false + } + } + + private requiredRequestId(request: Record): string { + const value = request.requestId + if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) { + throw new LocalFilesystemError('INVALID_REQUEST', 'requestId is invalid.') + } + return value + } + + private requiredUri(request: Record): string { + return this.requiredString(request, 'uri', MAX_URI_LENGTH) + } + + private requiredString(request: Record, key: string, maxLength = 1000): string { + const value = request[key] + if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) { + throw new LocalFilesystemError('INVALID_REQUEST', `${key} is required.`) + } + return value + } + + private async mountDirectory(): Promise { + const selection = await this.chooseDirectory() + if (!selection) return { mount: null, cancelled: true } + + const selected = typeof selection === 'string' ? { path: selection } : selection + const stopAccessing = selected.bookmark + ? this.startAccessingBookmark(selected.bookmark) + : undefined + + try { + const rootPath = await realpath(selected.path) + const rootStat = await stat(rootPath) + if (!rootStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') + } + + const existing = [...this.mounts.values()].find((mount) => mount.rootPath === rootPath) + const id = existing?.id ?? generateId() + const bookmark = selected.bookmark ?? existing?.bookmark + const nextStopAccessing = selected.bookmark + ? stopAccessing + : (existing?.stopAccessing ?? + (bookmark ? this.startAccessingBookmark(bookmark) : undefined)) + if (selected.bookmark) { + existing?.stopAccessing?.() + } + const mount: GrantedMount = { + id, + name: basename(rootPath) || 'Local files', + uri: localUri(id), + rootPath, + remembered: existing?.remembered ?? false, + ...(bookmark ? { bookmark } : {}), + ...(nextStopAccessing ? { stopAccessing: nextStopAccessing } : {}), + } + this.mounts.set(id, mount) + mount.remembered = await this.persistMounts() + return { mount: this.publicMount(mount), cancelled: false } + } catch (error) { + stopAccessing?.() + throw error + } + } + + private listMounts(): LocalFilesystemData { + return { mounts: [...this.mounts.values()].map((mount) => this.publicMount(mount)) } + } + + private publicMount(mount: GrantedMount): LocalFilesystemMount { + return { + id: mount.id, + name: mount.name, + uri: mount.uri, + remembered: mount.remembered, + } + } + + private async restoreRememberedMounts(): Promise { + if (!this.grantStore) return + const grants = await this.grantStore.load() + let skipped = false + + for (const grant of grants) { + if (!/^[a-zA-Z0-9-]{1,128}$/.test(grant.id) || this.mounts.has(grant.id)) { + skipped = true + continue + } + const stopAccessing = grant.bookmark ? this.startAccessingBookmark(grant.bookmark) : undefined + try { + const rootPath = await realpath(grant.rootPath) + const rootStat = await stat(rootPath) + if (!rootStat.isDirectory()) { + stopAccessing?.() + skipped = true + continue + } + this.mounts.set(grant.id, { + id: grant.id, + name: basename(rootPath) || grant.name || 'Local files', + uri: localUri(grant.id), + rootPath, + remembered: true, + ...(grant.bookmark ? { bookmark: grant.bookmark } : {}), + ...(stopAccessing ? { stopAccessing } : {}), + }) + } catch { + stopAccessing?.() + skipped = true + } + } + + if (skipped) { + await this.persistMounts() + } + } + + private persistedMounts(): PersistedLocalFilesystemGrant[] { + return [...this.mounts.values()].map((mount) => ({ + id: mount.id, + name: mount.name, + rootPath: mount.rootPath, + ...(mount.bookmark ? { bookmark: mount.bookmark } : {}), + })) + } + + private async persistMounts(): Promise { + if (!this.grantStore) return false + try { + if (this.mounts.size === 0) { + await this.grantStore.clear() + return true + } + const remembered = await this.grantStore.save(this.persistedMounts()) + if (remembered) { + for (const mount of this.mounts.values()) { + mount.remembered = true + } + } + return remembered + } catch { + return false + } + } + + private async forgetMount(uri: string): Promise { + const { mount } = this.parseUri(uri) + mount.stopAccessing?.() + this.mounts.delete(mount.id) + + const persisted = await this.persistMounts() + if (!persisted && this.grantStore) { + // Fail closed: if an updated encrypted grant set cannot be written, + // remove the store so a revoked mount cannot return after restart. + await this.grantStore.clear() + for (const remaining of this.mounts.values()) { + remaining.remembered = false + } + } + return { forgotten: true } + } + + /** + * Opens the grant's root in the OS file manager. Only a URI that resolves to + * a live grant can be revealed, so this exposes nothing the renderer could not + * already read. + */ + private revealMount(uri: string): LocalFilesystemData { + const { mount } = this.parseUri(uri) + shell.showItemInFolder(mount.rootPath) + return { revealed: true } + } + + private parseUri(uri: string): { mount: GrantedMount; relativePath: string } { + if (!uri.startsWith('localfs://')) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + const rawPathSegments = uri.slice('localfs://'.length).split('/').slice(1) + for (const rawSegment of rawPathSegments) { + let decodedSegment: string + try { + decodedSegment = decodeURIComponent(rawSegment) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if (decodedSegment === '.' || decodedSegment === '..') { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + } + + let parsed: URL + try { + parsed = new URL(uri) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if ( + parsed.protocol !== 'localfs:' || + !parsed.hostname || + parsed.username || + parsed.password || + parsed.port || + parsed.search || + parsed.hash + ) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + + const mount = this.mounts.get(parsed.hostname) + if (!mount) { + throw new LocalFilesystemError( + 'MOUNT_NOT_FOUND', + 'That local folder is no longer available. Select it again.' + ) + } + + const encodedSegments = parsed.pathname.split('/').filter(Boolean) + const segments = encodedSegments.map((segment) => { + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if ( + !decoded || + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('\0') + ) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + return decoded + }) + return { mount, relativePath: segments.join('/') } + } + + private async resolveUri(uri: string): Promise { + const { mount, relativePath } = this.parseUri(uri) + const lexicalPath = resolve(mount.rootPath, ...relativePath.split('/').filter(Boolean)) + if (!isWithinRoot(mount.rootPath, lexicalPath)) { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + const realPath = await realpath(lexicalPath) + if (!isWithinRoot(mount.rootPath, realPath)) { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + return { mount, relativePath, lexicalPath, realPath } + } + + private async listDirectory(uri: string): Promise { + const resolvedPath = await this.resolveUri(uri) + const directoryStat = await stat(resolvedPath.realPath) + if (!directoryStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') + } + + const directoryEntries = await readdir(resolvedPath.realPath, { withFileTypes: true }) + directoryEntries.sort((a, b) => a.name.localeCompare(b.name)) + const truncated = directoryEntries.length > MAX_LIST_ENTRIES + // `allSettled`, so one entry disappearing mid-read does not fail the whole + // listing. Build output, downloads and caches churn constantly, and a + // single ENOENT should drop that row rather than the directory. + const settled = await Promise.allSettled( + directoryEntries.slice(0, MAX_LIST_ENTRIES).map(async (directoryEntry) => { + const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] + .filter(Boolean) + .join('/') + const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) + const item: LocalFilesystemEntry = { + name: directoryEntry.name, + uri: localUri(resolvedPath.mount.id, childRelativePath), + kind: entryKind(directoryEntry), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + return item + }) + ) + const entries = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ) + return { entries, truncated } + } + + private async glob( + uri: string, + pattern: string, + rawPathPrefix?: unknown, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + if (rawPathPrefix !== undefined && typeof rawPathPrefix !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'pathPrefix must be a string.') + } + const pathPrefix = typeof rawPathPrefix === 'string' ? rawPathPrefix.replace(/\/+$/, '') : '' + const matcher = compileGlob(pattern) + const resolvedPath = await this.resolveUri(uri) + const baseStat = await stat(resolvedPath.realPath) + if (!baseStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') + } + + const entries: LocalFilesystemEntry[] = [] + let scanned = 0 + let truncated = false + const stack = [{ path: resolvedPath.realPath, relativeFromBase: '', depth: 0 }] + + while (stack.length > 0 && !truncated) { + throwIfAborted(signal) + const current = stack.pop() + if (!current) break + const children = await readdir(current.path, { withFileTypes: true }) + children.sort((a, b) => b.name.localeCompare(a.name)) + + for (const child of children) { + throwIfAborted(signal) + scanned++ + if (scanned > MAX_SCAN_ENTRIES) { + truncated = true + break + } + const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') + const childPath = resolve(current.path, child.name) + const mountRelativePath = [resolvedPath.relativePath, relativeFromBase] + .filter(Boolean) + .join('/') + + const candidatePath = pathPrefix ? `${pathPrefix}/${relativeFromBase}` : relativeFromBase + if (matcher(candidatePath)) { + const metadata = await lstat(childPath) + entries.push({ + name: child.name, + uri: localUri(resolvedPath.mount.id, mountRelativePath), + kind: entryKind(child), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + }) + if (entries.length >= MAX_GLOB_RESULTS) { + truncated = true + break + } + } + + if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { + stack.push({ + path: childPath, + relativeFromBase, + depth: current.depth + 1, + }) + } + } + } + + entries.sort((a, b) => a.uri.localeCompare(b.uri)) + return { entries, truncated } + } + + private async readText( + uri: string, + rawStartLine: unknown, + rawLineCount: unknown, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + const startLine = parsePositiveInteger(rawStartLine, 'startLine', 1, Number.MAX_SAFE_INTEGER) + const lineCount = parsePositiveInteger( + rawLineCount, + 'lineCount', + DEFAULT_READ_LINES, + MAX_READ_LINES + ) + const resolvedPath = await this.resolveUri(uri) + const fileStat = await stat(resolvedPath.realPath) + if (!fileStat.isFile()) { + throw new LocalFilesystemError('NOT_A_FILE', 'The localfs URI is not a file.') + } + if (fileStat.size > MAX_TEXT_FILE_BYTES) { + throw new LocalFilesystemError( + 'FILE_TOO_LARGE', + 'The file is too large to read through user-local/.' + ) + } + + const buffer = await readFile(resolvedPath.realPath, { signal }) + throwIfAborted(signal) + if (isBinary(buffer)) { + throw new LocalFilesystemError( + 'BINARY_FILE', + 'The file is binary and cannot be read through user-local/.' + ) + } + const content = new TextDecoder().decode(buffer) + const lines = content.length === 0 ? [] : content.split(/\r?\n/) + const selectedLines = lines.slice(startLine - 1, startLine - 1 + lineCount) + const endLine = selectedLines.length === 0 ? 0 : startLine + selectedLines.length - 1 + return { + uri, + content: selectedLines.join('\n'), + startLine, + endLine, + totalLines: lines.length, + } + } + + private async grep( + uri: string, + request: Record, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + const rawPattern = request.pattern + const rawQuery = request.query + if (rawPattern !== undefined && typeof rawPattern !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'pattern must be a string.') + } + if (rawQuery !== undefined && typeof rawQuery !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'query must be a string.') + } + const expression = rawPattern ?? rawQuery + if (typeof expression !== 'string' || expression.length < 1 || expression.length > 1000) { + throw new LocalFilesystemError('INVALID_REQUEST', 'grep pattern is invalid.') + } + if (request.include !== undefined && typeof request.include !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'include must be a glob string.') + } + if (request.caseSensitive !== undefined && typeof request.caseSensitive !== 'boolean') { + throw new LocalFilesystemError('INVALID_REQUEST', 'caseSensitive must be a boolean.') + } + const outputMode = request.outputMode ?? 'content' + if (!['content', 'files_with_matches', 'count'].includes(String(outputMode))) { + throw new LocalFilesystemError('INVALID_REQUEST', 'outputMode is invalid.') + } + if (request.lineNumbers !== undefined && typeof request.lineNumbers !== 'boolean') { + throw new LocalFilesystemError('INVALID_REQUEST', 'lineNumbers must be a boolean.') + } + const rawContext = request.context ?? 0 + if ( + !Number.isInteger(rawContext) || + (rawContext as number) < 0 || + (rawContext as number) > MAX_GREP_CONTEXT + ) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `context must be an integer from 0 to ${MAX_GREP_CONTEXT}.` + ) + } + const contextLines = rawContext as number + const maxResults = parsePositiveInteger( + request.maxResults, + 'maxResults', + DEFAULT_GREP_RESULTS, + MAX_GREP_RESULTS + ) + + const include = typeof request.include === 'string' ? request.include : '**/*' + const matcher = compileGlob(include) + const ignoreCase = request.caseSensitive !== true + if (rawPattern !== undefined && !safeRegex(expression)) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'grep pattern was rejected because it may cause catastrophic backtracking.' + ) + } + let regex: RegExp + try { + regex = + rawPattern !== undefined + ? new RegExp(expression, ignoreCase ? 'i' : '') + : new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '') + } catch { + // An empty result set would tell the model the string appears nowhere in + // the user's files — a factual claim it will act on, when in truth the + // search never ran. + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'grep pattern is not a valid regular expression.' + ) + } + const resolvedPath = await this.resolveUri(uri) + const baseStat = await stat(resolvedPath.realPath) + if (!baseStat.isDirectory() && !baseStat.isFile()) { + throw new LocalFilesystemError('NOT_A_FILE', 'The localfs URI is not searchable.') + } + + const matches: LocalFilesystemGrepMatch[] = [] + const files: string[] = [] + const counts: Array<{ uri: string; count: number }> = [] + let scanned = 0 + let scannedBytes = 0 + let truncated = false + const stack = baseStat.isDirectory() + ? [{ path: resolvedPath.realPath, relativeFromBase: '', depth: 0 }] + : [] + + const inspectFile = async (childPath: string, relativeFromBase: string): Promise => { + throwIfAborted(signal) + if (baseStat.isDirectory() && !matcher(relativeFromBase)) return + const fileStat = await stat(childPath) + if (fileStat.size > MAX_TEXT_FILE_BYTES) return + if (scannedBytes + fileStat.size > MAX_GREP_SCAN_BYTES) { + truncated = true + return + } + scannedBytes += fileStat.size + const buffer = await readFile(childPath, { signal }) + if (isBinary(buffer)) return + const content = new TextDecoder().decode(buffer) + const mountRelativePath = baseStat.isFile() + ? resolvedPath.relativePath + : [resolvedPath.relativePath, relativeFromBase].filter(Boolean).join('/') + const resultUri = localUri(resolvedPath.mount.id, mountRelativePath) + + if (outputMode === 'files_with_matches') { + regex.lastIndex = 0 + if (regex.test(content)) files.push(resultUri) + return + } + + const lines = content.split(/\r?\n/) + let count = 0 + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + throwIfAborted(signal) + regex.lastIndex = 0 + if (!regex.test(lines[lineIndex])) continue + count++ + if (outputMode !== 'content') continue + + const contextStart = Math.max(0, lineIndex - contextLines) + const contextEnd = Math.min(lines.length - 1, lineIndex + contextLines) + for (let contextIndex = contextStart; contextIndex <= contextEnd; contextIndex++) { + const line = lines[contextIndex] + matches.push({ + uri: resultUri, + line: request.lineNumbers === false ? 0 : contextIndex + 1, + text: + line.length > MAX_GREP_LINE_LENGTH ? `${line.slice(0, MAX_GREP_LINE_LENGTH)}…` : line, + }) + if (matches.length >= maxResults) { + truncated = true + return + } + } + } + if (outputMode === 'count' && count > 0) { + counts.push({ uri: resultUri, count }) + } + } + + if (baseStat.isFile()) { + await inspectFile(resolvedPath.realPath, basename(resolvedPath.realPath)) + } + + while (stack.length > 0 && !truncated) { + throwIfAborted(signal) + const current = stack.pop() + if (!current) break + const children = await readdir(current.path, { withFileTypes: true }) + for (const child of children) { + throwIfAborted(signal) + scanned++ + if (scanned > MAX_SCAN_ENTRIES) { + truncated = true + break + } + const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') + const childPath = resolve(current.path, child.name) + if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { + stack.push({ + path: childPath, + relativeFromBase, + depth: current.depth + 1, + }) + continue + } + if (!child.isFile()) continue + await inspectFile(childPath, relativeFromBase) + if (truncated) break + const resultCount = + outputMode === 'files_with_matches' + ? files.length + : outputMode === 'count' + ? counts.length + : matches.length + if (resultCount >= maxResults) { + truncated = true + break + } + } + } + + if (outputMode === 'files_with_matches') { + files.sort() + return { files: files.slice(0, maxResults), truncated } + } + if (outputMode === 'count') { + counts.sort((a, b) => a.uri.localeCompare(b.uri)) + return { counts: counts.slice(0, maxResults), truncated } + } + matches.sort((a, b) => a.uri.localeCompare(b.uri) || a.line - b.line) + return { matches, truncated } + } + + private async statPath(uri: string): Promise { + const resolvedPath = await this.resolveUri(uri) + const metadata = await lstat(resolvedPath.lexicalPath) + return { + name: basename(resolvedPath.lexicalPath) || resolvedPath.mount.name, + uri, + kind: entryKind(metadata), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + } +} diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts new file mode 100644 index 0000000000..1fe85ab43d --- /dev/null +++ b/apps/desktop/src/main/menu.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow, type MenuItemConstructorOptions } from 'electron' +import type { ConfigStore } from '@/main/config' +import { buildMenuTemplate, type MenuDeps } from '@/main/menu' + +function makeDeps(): MenuDeps { + return { + config: { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => 'https://sim.ai'), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore, + getMainWindow: vi.fn(() => null), + allowHttpLocalhost: vi.fn(() => false), + openSettings: vi.fn(), + newWindow: vi.fn(), + newChat: vi.fn(), + closeFocusedTerminal: vi.fn(() => false), + reopenClosedTerminal: vi.fn(() => false), + closeFocusedBrowserTab: vi.fn(() => false), + reopenClosedBrowserTab: vi.fn(() => false), + toggleSidebar: vi.fn(), + signOut: vi.fn(), + checkForUpdates: vi.fn(), + } +} + +function submenu( + template: MenuItemConstructorOptions[], + label: string +): MenuItemConstructorOptions[] { + return (template.find((item) => item.label === label || item.role === label.toLowerCase()) + ?.submenu ?? []) as MenuItemConstructorOptions[] +} + +describe('buildMenuTemplate', () => { + it('uses the requested native menu structure', () => { + const template = buildMenuTemplate(makeDeps()) + expect(template.map((item) => item.label ?? item.role)).toEqual([ + 'Sim', + 'File', + 'editMenu', + 'View', + 'windowMenu', + 'help', + ]) + + expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'about', + 'Settings…', + 'Check for Updates…', + 'Sign Out', + 'separator', + 'services', + 'separator', + 'hide', + 'hideOthers', + 'unhide', + 'separator', + 'quit', + ]) + expect(submenu(template, 'File').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'New Window', + 'New Chat', + 'separator', + 'Reopen Closed Tab', + 'Close Window', + ]) + expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'Toggle Sidebar', + 'separator', + 'Reload', + 'separator', + 'Actual Size', + 'Zoom In', + 'Zoom Out', + 'separator', + 'togglefullscreen', + ]) + }) + + it('keeps Help limited to documentation and system status', () => { + const help = submenu(buildMenuTemplate(makeDeps()), 'Help') + expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'System Status']) + }) + + it('never exposes developer tools in the application menu', () => { + const view = submenu(buildMenuTemplate(makeDeps()), 'View') + expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) + }) + + it('routes the close accelerator through the focused browser tab before closing a window', () => { + const closeFocusedBrowserTab = vi.fn((_win: BrowserWindow | null) => true) + const deps = Object.assign(makeDeps(), { closeFocusedBrowserTab }) + const closeItem = submenu(buildMenuTemplate(deps), 'File').find( + (item) => item.accelerator === 'CmdOrCtrl+W' + ) + const focusedWindow = new BrowserWindow() + + expect(closeItem).toMatchObject({ label: 'Close Window', accelerator: 'CmdOrCtrl+W' }) + expect(closeItem?.role).toBeUndefined() + + const click = closeItem?.click as unknown as ( + menuItem: unknown, + browserWindow: BrowserWindow + ) => void + click({}, focusedWindow) + + expect(closeFocusedBrowserTab).toHaveBeenCalledWith(focusedWindow) + expect(focusedWindow.close).not.toHaveBeenCalled() + + closeFocusedBrowserTab.mockReturnValue(false) + click({}, focusedWindow) + expect(focusedWindow.close).toHaveBeenCalledOnce() + }) + + it('routes the reopen accelerator through the focused browser session', () => { + const reopenClosedBrowserTab = vi.fn((_win: BrowserWindow | null) => true) + const template = buildMenuTemplate( + Object.assign(makeDeps(), { + reopenClosedBrowserTab, + }) + ) + const reopenItem = submenu(template, 'File').find( + (item) => item.accelerator === 'CmdOrCtrl+Shift+T' + ) + + expect(reopenItem).toMatchObject({ + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + }) + const focusedWindow = new BrowserWindow() + ;(reopenItem?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( + {}, + focusedWindow + ) + expect(reopenClosedBrowserTab).toHaveBeenCalledWith(focusedWindow) + }) + + it('offers the standard new-window command', () => { + const deps = makeDeps() + const item = submenu(buildMenuTemplate(deps), 'File').find( + (entry) => entry.accelerator === 'CmdOrCtrl+Shift+N' + ) + + expect(item).toMatchObject({ label: 'New Window' }) + ;(item?.click as unknown as () => void)() + expect(deps.newWindow).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts new file mode 100644 index 0000000000..715a5e44be --- /dev/null +++ b/apps/desktop/src/main/menu.ts @@ -0,0 +1,156 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { app, BrowserWindow, Menu } from 'electron' +import type { ConfigStore } from '@/main/config' +import { openExternalSafe } from '@/main/navigation' + +const DOCS_URL = 'https://docs.sim.ai' +const STATUS_URL = 'https://status.sim.ai' +const ZOOM_STEP = 0.5 + +export interface MenuDeps { + config: ConfigStore + getMainWindow: () => BrowserWindow | null + allowHttpLocalhost: () => boolean + openSettings: () => void + newWindow: () => void + newChat: () => void + closeFocusedBrowserTab: (win: BrowserWindow | null) => boolean + reopenClosedBrowserTab: (win: BrowserWindow | null) => boolean + /** + * Terminal counterparts. Menu accelerators are global, so Cmd-W and + * Cmd-Shift-T reach here whatever the user is looking at; each panel gets + * asked whether the keystroke was meant for it before the window acts. + */ + closeFocusedTerminal: (win: BrowserWindow | null) => boolean + reopenClosedTerminal: (win: BrowserWindow | null) => boolean + toggleSidebar: () => void + signOut: () => void + checkForUpdates: () => void +} + +/** + * Builds the role-based macOS menu. Edit roles are load-bearing — without + * them copy/paste/undo silently fail in web inputs. Zoom items are custom so + * the zoom level persists across launches. + */ +export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { + const withWindow = (fn: (win: BrowserWindow) => void) => () => { + const win = deps.getMainWindow() + if (win && !win.isDestroyed()) { + fn(win) + } + } + + const setZoom = (resolve: (current: number) => number) => + withWindow((win) => { + const level = resolve(win.webContents.getZoomLevel()) + win.webContents.setZoomLevel(level) + deps.config.set('zoomLevel', level) + }) + + const viewSubmenu: MenuItemConstructorOptions[] = [ + { + label: 'Toggle Sidebar', + accelerator: 'CmdOrCtrl+B', + click: deps.toggleSidebar, + }, + { type: 'separator' }, + { + label: 'Reload', + accelerator: 'CmdOrCtrl+R', + click: withWindow((win) => win.webContents.reload()), + }, + { type: 'separator' }, + { label: 'Actual Size', accelerator: 'CmdOrCtrl+0', click: setZoom(() => 0) }, + { + label: 'Zoom In', + accelerator: 'CmdOrCtrl+Plus', + click: setZoom((current) => current + ZOOM_STEP), + }, + { + label: 'Zoom Out', + accelerator: 'CmdOrCtrl+-', + click: setZoom((current) => current - ZOOM_STEP), + }, + { type: 'separator' }, + ] + viewSubmenu.push({ role: 'togglefullscreen' }) + + return [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, + { label: 'Check for Updates…', click: deps.checkForUpdates }, + { label: 'Sign Out', click: deps.signOut }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { + label: 'New Window', + accelerator: 'CmdOrCtrl+Shift+N', + click: deps.newWindow, + }, + { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, + { type: 'separator' }, + { + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + click: (_item, focusedWindow) => { + const win = + focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + if (deps.reopenClosedTerminal(win)) return + deps.reopenClosedBrowserTab(win) + }, + }, + { + label: 'Close Window', + accelerator: 'CmdOrCtrl+W', + click: (_item, focusedWindow) => { + const win = + focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + // Both panels are asked window-scoped, so a claim made in one window + // cannot answer an accelerator fired in another. + if (deps.closeFocusedTerminal(win)) return + if (deps.closeFocusedBrowserTab(win)) return + if (win && !win.isDestroyed()) win.close() + }, + }, + ], + }, + { role: 'editMenu' }, + { label: 'View', submenu: viewSubmenu }, + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Sim Documentation', + click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), + }, + { + label: 'System Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ], + }, + ] +} + +/** + * Installs the application menu. + */ +export function installApplicationMenu(deps: MenuDeps): void { + Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenuTemplate(deps))) +} diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts new file mode 100644 index 0000000000..6edfe06592 --- /dev/null +++ b/apps/desktop/src/main/navigation.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { shell } from 'electron' +import { + classifyBlankChildNavigation, + classifyNavigation, + classifyWindowOpen, + isAuthSurfacePath, + isSafeExternalUrl, + matchesHostList, + openExternalSafe, +} from '@/main/navigation' + +const APP = 'https://sim.ai' + +describe('classifyNavigation', () => { + it('keeps same-origin navigation in-app', () => { + expect( + classifyNavigation(`${APP}/workspace/ws1/w/wf1`, { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1`, + }) + ).toBe('in-app') + }) + + it('allows about:blank', () => { + expect(classifyNavigation('about:blank', { appOrigin: APP })).toBe('in-app') + }) + + it('routes Google from the login surface to the system-browser handoff', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?x=1', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-system-login') + }) + + it('routes Microsoft from the signup surface to the system-browser handoff', () => { + expect( + classifyNavigation('https://login.microsoftonline.com/common/oauth2/v2.0/authorize', { + appOrigin: APP, + currentUrl: `${APP}/signup`, + }) + ).toBe('idp-system-login') + }) + + it('routes Google from a workspace page to the connect-in-browser intercept', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?scope=drive', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1/integrations/google-drive`, + }) + ).toBe('idp-system-connect') + }) + + it('matches system IdP subdomains', () => { + expect( + classifyNavigation('https://device.login.microsoftonline.com/', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1`, + }) + ).toBe('idp-system-connect') + }) + + it('keeps verified-lenient IdPs in-window from the login surface', () => { + expect( + classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-in-window') + }) + + it('sends unknown hosts from an auth surface to the system browser (SSO safe default)', () => { + expect( + classifyNavigation('https://company.okta.com/sso/saml', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-system-login') + }) + + it('keeps unknown hosts from workspace pages in-window (integration OAuth is a same-window redirect)', () => { + expect( + classifyNavigation('https://api.notion.com/v1/oauth/authorize?x=1', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1/integrations/notion`, + }) + ).toBe('idp-in-window') + }) + + it('allows continuation navigation while already on an IdP host', () => { + expect( + classifyNavigation('https://github.com/sessions/two-factor', { + appOrigin: APP, + currentUrl: 'https://github.com/login', + }) + ).toBe('idp-in-window') + }) + + it('allows any https navigation inside popups', () => { + expect( + classifyNavigation('https://third-party-mcp.example/authorize', { + appOrigin: APP, + currentUrl: 'https://other.example/start', + isPopup: true, + }) + ).toBe('in-app') + }) + + it('denies non-web schemes everywhere', () => { + for (const url of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'data:text/html,x', + 'sim://auth', + ]) { + expect(classifyNavigation(url, { appOrigin: APP, currentUrl: `${APP}/login` })).toBe('deny') + expect(classifyNavigation(url, { appOrigin: APP, isPopup: true })).toBe('deny') + } + }) +}) + +describe('classifyWindowOpen', () => { + it('classifies blank children (Stripe blank-then-assign)', () => { + expect(classifyWindowOpen('', '', APP)).toBe('popup-blank') + expect(classifyWindowOpen('about:blank', '', APP)).toBe('popup-blank') + }) + + it('classifies the MCP OAuth popup by frame name for any https URL', () => { + expect(classifyWindowOpen(`${APP}/api/mcp/oauth/start`, 'mcp-oauth-srv1', APP)).toBe( + 'popup-mcp' + ) + expect(classifyWindowOpen('https://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( + 'popup-mcp' + ) + }) + + it('does not let a cross-origin http URL ride the mcp-oauth frame name in-app', () => { + expect(classifyWindowOpen('http://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( + 'external' + ) + }) + + it('collapses internal new-tab opens into the main window', () => { + expect(classifyWindowOpen(`${APP}/workspace/ws1/w/wf1`, '', APP)).toBe('popup-internal') + }) + + it('routes external opens to the system browser', () => { + expect(classifyWindowOpen('https://docs.sim.ai/blocks', '', APP)).toBe('external') + }) + + it('denies non-web schemes', () => { + expect(classifyWindowOpen('javascript:alert(1)', '', APP)).toBe('deny') + expect(classifyWindowOpen('file:///tmp/x', 'mcp-oauth-x', APP)).toBe('deny') + }) +}) + +describe('classifyBlankChildNavigation', () => { + it('ignores staying blank', () => { + expect(classifyBlankChildNavigation('about:blank', APP)).toBe('ignore') + }) + + it('routes same-origin assignment into the main window', () => { + expect(classifyBlankChildNavigation(`${APP}/chat/deployed`, APP)).toBe('internal') + }) + + it('routes external assignment (Stripe portal) to the system browser', () => { + expect(classifyBlankChildNavigation('https://billing.stripe.com/p/session/x', APP)).toBe( + 'external' + ) + }) + + it('denies non-web schemes', () => { + expect(classifyBlankChildNavigation('javascript:alert(1)', APP)).toBe('deny') + }) +}) + +describe('isSafeExternalUrl', () => { + it('allows https', () => { + expect(isSafeExternalUrl('https://docs.sim.ai')).toBe(true) + }) + + it('rejects credentials in the URL', () => { + expect(isSafeExternalUrl('https://user@evil.example')).toBe(false) + expect(isSafeExternalUrl('https://user:pass@evil.example')).toBe(false) + }) + + it('allows http only for loopback hosts and only when enabled', () => { + expect(isSafeExternalUrl('http://localhost:3000', true)).toBe(true) + expect(isSafeExternalUrl('http://127.0.0.1:3000', true)).toBe(true) + expect(isSafeExternalUrl('http://localhost:3000', false)).toBe(false) + expect(isSafeExternalUrl('http://evil.example', true)).toBe(false) + }) + + it('rejects non-web schemes', () => { + for (const url of [ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'data:text/html,x', + 'steam://run/1', + 'blob:https://sim.ai/x', + ]) { + expect(isSafeExternalUrl(url, true)).toBe(false) + } + }) + + it('rejects garbage', () => { + expect(isSafeExternalUrl('not a url')).toBe(false) + expect(isSafeExternalUrl('')).toBe(false) + }) +}) + +describe('openExternalSafe', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + it('opens validated URLs', async () => { + await expect(openExternalSafe('https://docs.sim.ai')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.sim.ai') + }) + + it('never passes unsafe URLs to the shell', async () => { + await expect(openExternalSafe('javascript:alert(1)')).resolves.toBe(false) + await expect(openExternalSafe('file:///etc/passwd', true)).resolves.toBe(false) + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) + +describe('helpers', () => { + it('matchesHostList covers exact hosts and subdomains', () => { + expect(matchesHostList('accounts.google.com', ['accounts.google.com'])).toBe(true) + expect(matchesHostList('sub.accounts.google.com', ['accounts.google.com'])).toBe(true) + expect(matchesHostList('evilaccounts.google.com.attacker.io', ['accounts.google.com'])).toBe( + false + ) + expect(matchesHostList('notaccounts.google.com', ['accounts.google.com'])).toBe(false) + }) + + it('isAuthSurfacePath matches auth routes and their children only', () => { + expect(isAuthSurfacePath('/login')).toBe(true) + expect(isAuthSurfacePath('/sso/acme')).toBe(true) + expect(isAuthSurfacePath('/desktop/auth')).toBe(true) + expect(isAuthSurfacePath('/workspace/ws1')).toBe(false) + expect(isAuthSurfacePath('/loginish')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts new file mode 100644 index 0000000000..a89e37f50b --- /dev/null +++ b/apps/desktop/src/main/navigation.ts @@ -0,0 +1,232 @@ +import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' +import { shell } from 'electron' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopNavigation') + +export type MainNavigationAction = + | 'in-app' + | 'idp-in-window' + | 'idp-system-login' + | 'idp-system-connect' + | 'external' + | 'deny' + +export type WindowOpenAction = 'popup-mcp' | 'popup-blank' | 'popup-internal' | 'external' | 'deny' + +export type BlankChildAction = 'internal' | 'external' | 'ignore' | 'deny' + +export interface NavigationContext { + appOrigin: string + currentUrl?: string + isPopup?: boolean +} + +/** + * IdP hosts that hard-block OAuth inside embedded user agents. Navigation to + * these hosts is cancelled and rerouted: from an auth surface the app starts + * the system-browser login handoff; from anywhere else it offers to finish the + * integration connect in the browser (tokens land server-side either way). + */ +export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [ + 'accounts.google.com', + 'accounts.youtube.com', + 'login.microsoftonline.com', + 'login.live.com', + 'login.windows.net', + 'sts.windows.net', +] + +/** + * IdP hosts verified lenient toward embedded user agents (the U5 provider + * matrix). Only consulted for navigations leaving an auth surface — everywhere + * else unknown hosts already stay in-window because same-window departures + * from workspace pages are OAuth connect flows in this app. + */ +export const IN_WINDOW_IDP_HOSTS: readonly string[] = ['github.com'] + +const AUTH_SURFACE_PREFIXES: readonly string[] = [ + '/login', + '/signup', + '/sso', + '/reset-password', + '/verify', + '/desktop/auth', +] + +const MCP_POPUP_NAME_PREFIX = 'mcp-oauth-' + +export function parseHttpUrl(raw: string): URL | null { + try { + const url = new URL(raw) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + return url + } catch { + return null + } +} + +/** + * Matches a hostname against a list of registrable domains, including their + * subdomains ('login.live.com' matches 'live.com'-style entries and itself). + */ +export function matchesHostList(hostname: string, hosts: readonly string[]): boolean { + return hosts.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) +} + +/** + * True when a URL is on the app origin, compared by parsed origin equality. + * Never use `url.startsWith(origin)` for this — that prefix-matches lookalike + * hosts (`https://sim.ai.evil.com` starts with `https://sim.ai`). + */ +export function isAppOrigin(rawUrl: string, appOrigin: string): boolean { + const url = parseHttpUrl(rawUrl) + return url !== null && url.origin === appOrigin +} + +/** + * Auth surfaces are routes where a non-origin departure means an identity + * flow (social login, SSO) rather than an integration connect. + */ +export function isAuthSurfacePath(pathname: string): boolean { + return AUTH_SURFACE_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ) +} + +/** + * Classifies a top-level navigation (will-navigate / will-redirect). + * + * Same-window departures to non-origin hosts are OAuth flows in this app — + * regular external links always go through window.open — so unknown hosts stay + * in-window (lenient IdP assumption) except for the known embedded-blocking + * hosts, which are rerouted through the system browser. Departures from auth + * surfaces default to the system browser because SSO IdPs need real-browser + * device claims. + */ +export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction { + if (rawUrl === 'about:blank') { + return 'in-app' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === ctx.appOrigin) { + return 'in-app' + } + if (ctx.isPopup) { + return 'in-app' + } + const current = ctx.currentUrl ? parseHttpUrl(ctx.currentUrl) : null + const fromAuthSurface = current + ? current.origin === ctx.appOrigin && isAuthSurfacePath(current.pathname) + : false + if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { + return fromAuthSurface ? 'idp-system-login' : 'idp-system-connect' + } + if (matchesHostList(url.hostname, IN_WINDOW_IDP_HOSTS)) { + return 'idp-in-window' + } + if (current && current.origin !== ctx.appOrigin) { + return 'idp-in-window' + } + return fromAuthSurface ? 'idp-system-login' : 'idp-in-window' +} + +/** + * Classifies a window.open request (setWindowOpenHandler). Internal requests + * become full Sim application windows; the MCP OAuth popup and the + * blank-then-assign pattern (Stripe portal, deployed-chat tabs) are allowed as + * guarded children in the same partition. + */ +export function classifyWindowOpen( + rawUrl: string, + frameName: string, + appOrigin: string +): WindowOpenAction { + if (rawUrl === '' || rawUrl === 'about:blank') { + return 'popup-blank' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === appOrigin) { + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX)) { + return 'popup-mcp' + } + return 'popup-internal' + } + // The MCP OAuth popup opens the provider's cross-origin authorization URL + // (always https). The frame name is renderer-controlled, so gate the in-app + // popup on https too — an http(s-less) page can never ride the mcp-oauth name + // into an in-app window; it goes to the system browser like any external URL. + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX) && url.protocol === 'https:') { + return 'popup-mcp' + } + return 'external' +} + +/** + * Classifies the first real navigation of an about:blank child window created + * by the blank-then-assign pattern. + */ +export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string): BlankChildAction { + if (rawUrl === '' || rawUrl === 'about:blank') { + return 'ignore' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === appOrigin) { + return 'internal' + } + return 'external' +} + +/** + * Validates a URL for handing to the system browser: https always, http only + * for loopback hosts when explicitly allowed, never credentials in the URL, + * never non-web schemes. + */ +export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): boolean { + let url: URL + try { + url = new URL(raw) + } catch { + return false + } + if (url.username || url.password) { + return false + } + if (url.protocol === 'https:') { + return true + } + if (url.protocol === 'http:') { + return allowHttpLocalhost && isLoopbackHostname(url.hostname) + } + return false +} + +/** + * Opens a URL in the system browser after validation. Every openExternal in + * the app goes through here — menu items, IPC, and navigation policy. + */ +export async function openExternalSafe(raw: string, allowHttpLocalhost = false): Promise { + if (!isSafeExternalUrl(raw, allowHttpLocalhost)) { + logger.warn('Blocked unsafe external URL', { url: scrubUrl(raw) }) + return false + } + try { + await shell.openExternal(raw) + return true + } catch (error) { + logger.error('Failed to open external URL', { error }) + return false + } +} diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts new file mode 100644 index 0000000000..cd3e3cf214 --- /dev/null +++ b/apps/desktop/src/main/observability.test.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createEventLog, scrubUrl } from '@/main/observability' + +describe('scrubUrl', () => { + it('drops query strings and fragments so tokens never reach the log', () => { + expect(scrubUrl('https://sim.ai/desktop/auth?state=SECRET&token=SECRET#frag')).toBe( + 'https://sim.ai/desktop/auth' + ) + }) + + it('returns empty for unparseable input', () => { + expect(scrubUrl('not a url')).toBe('') + }) +}) + +describe('createEventLog', () => { + it('appends JSONL entries', () => { + const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const events = createEventLog(dir) + events.record('app_launch', { version: '1.0.0' }) + events.record('load_failure', { kind: 'dns' }) + + const lines = readFileSync(events.filePath, 'utf8').trim().split('\n') + expect(lines).toHaveLength(2) + const first = JSON.parse(lines[0]) + expect(first.name).toBe('app_launch') + expect(first.data).toEqual({ version: '1.0.0' }) + expect(typeof first.at).toBe('string') + }) + + it('rotates once past the size cap', () => { + const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const events = createEventLog(dir, 64) + events.record('app_launch', { version: '1.0.0' }) + events.record('app_launch', { version: '1.0.0' }) + events.record('app_launch', { version: '1.0.0' }) + expect(existsSync(`${events.filePath}.1`)).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts new file mode 100644 index 0000000000..6290c271d3 --- /dev/null +++ b/apps/desktop/src/main/observability.ts @@ -0,0 +1,83 @@ +import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { createLogger } from '@sim/logger' + +const logger = createLogger('DesktopEvents') + +const DEFAULT_MAX_BYTES = 1_000_000 + +export type DesktopEventName = + | 'app_launch' + | 'update_check' + | 'update_feed' + | 'update_downloaded' + | 'update_error' + | 'update_blocked_version' + | 'update_manual_mode' + | 'update_manual_download' + | 'handoff_redeem_ok' + | 'handoff_redeem_fail' + | 'load_failure' + | 'renderer_gone' + | 'renderer_unresponsive' + | 'sign_out' + | 'origin_changed' + | 'handoff_started' + | 'connect_handoff_started' + | 'connect_handoff_open_fail' + | 'connect_handoff_state_fail' + | 'connect_handoff_ok' + | 'connect_handoff_error' + +export interface EventRecorder { + readonly filePath: string + record(name: DesktopEventName, data?: Record): void +} + +/** + * Reduces a URL to origin + path for logging. Query strings and fragments are + * dropped so tokens, states, and signed parameters never reach the event log. + */ +export function scrubUrl(raw: string): string { + try { + const url = new URL(raw) + return `${url.origin}${url.pathname}` + } catch { + return '' + } +} + +/** + * Structured JSONL event log for the main process, answering "is this release + * crashing?" and "did auto-update fail?" from a user machine. Rotates once at + * maxBytes (current file becomes .1). Callers must pass pre-scrubbed data — + * use scrubUrl for anything URL-shaped and never log tokens or cookies. + */ +export function createEventLog(dir: string, maxBytes: number = DEFAULT_MAX_BYTES): EventRecorder { + const filePath = join(dir, 'desktop-events.log') + try { + mkdirSync(dir, { recursive: true }) + } catch {} + + const rotateIfNeeded = () => { + try { + if (statSync(filePath).size > maxBytes) { + renameSync(filePath, `${filePath}.1`) + } + } catch {} + } + + return { + filePath, + record(name, data) { + logger.info(`desktop event: ${name}`, data) + try { + rotateIfNeeded() + const entry = { at: new Date().toISOString(), name, ...(data ? { data } : {}) } + appendFileSync(filePath, `${JSON.stringify(entry)}\n`) + } catch (error) { + logger.warn('Failed to append desktop event', { error }) + } + }, + } +} diff --git a/apps/desktop/src/main/security-guards.test.ts b/apps/desktop/src/main/security-guards.test.ts new file mode 100644 index 0000000000..6e0b3830ef --- /dev/null +++ b/apps/desktop/src/main/security-guards.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { WebContents } from 'electron' +import { app, shell } from 'electron' +import { attachNavigationGuards, type GuardDeps, installGlobalGuards } from '@/main/security-guards' + +const APP = 'https://sim.ai' + +interface FakeContents { + handlers: Map void }, url: string) => void> + on: ReturnType + getURL: ReturnType + setWindowOpenHandler: ReturnType + closeDevTools: ReturnType +} + +function makeContents(currentUrl: string): FakeContents { + const handlers = new Map() + return { + handlers, + on: vi.fn((event: string, handler: never) => { + handlers.set(event, handler) + }), + getURL: vi.fn(() => currentUrl), + setWindowOpenHandler: vi.fn(), + closeDevTools: vi.fn(), + } +} + +function makeDeps(overrides: Partial = {}): GuardDeps { + return { + appOrigin: () => APP, + isPackaged: true, + allowHttpLocalhost: () => false, + isPopupContents: () => false, + onLoginHandoff: vi.fn(), + onConnectIntercept: vi.fn(), + ...overrides, + } +} + +function fire(contents: FakeContents, event: string, url: string) { + const preventDefault = vi.fn() + contents.handlers.get(event)?.({ preventDefault }, url) + return preventDefault +} + +describe('attachNavigationGuards', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + it('guards both will-navigate and will-redirect', () => { + const contents = makeContents(`${APP}/login`) + attachNavigationGuards(contents as unknown as WebContents, makeDeps()) + expect(contents.handlers.has('will-navigate')).toBe(true) + expect(contents.handlers.has('will-redirect')).toBe(true) + }) + + it('lets same-origin navigation through untouched', () => { + const contents = makeContents(`${APP}/workspace/ws1`) + attachNavigationGuards(contents as unknown as WebContents, makeDeps()) + const preventDefault = fire(contents, 'will-navigate', `${APP}/workspace/ws1/w/wf1`) + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('cancels blocked-IdP login navigation and starts the browser handoff', () => { + const deps = makeDeps() + const contents = makeContents(`${APP}/login`) + attachNavigationGuards(contents as unknown as WebContents, deps) + const preventDefault = fire( + contents, + 'will-navigate', + 'https://accounts.google.com/o/oauth2/v2/auth' + ) + expect(preventDefault).toHaveBeenCalled() + expect(deps.onLoginHandoff).toHaveBeenCalled() + }) + + it('cancels blocked-IdP connect navigation via will-redirect and intercepts', () => { + const deps = makeDeps() + const contents = makeContents(`${APP}/workspace/ws1/integrations/gmail`) + attachNavigationGuards(contents as unknown as WebContents, deps) + const preventDefault = fire( + contents, + 'will-redirect', + 'https://accounts.google.com/o/oauth2/v2/auth' + ) + expect(preventDefault).toHaveBeenCalled() + expect(deps.onConnectIntercept).toHaveBeenCalled() + }) + + it('denies non-web schemes', () => { + const contents = makeContents(`${APP}/workspace/ws1`) + attachNavigationGuards(contents as unknown as WebContents, makeDeps()) + const preventDefault = fire(contents, 'will-navigate', 'file:///etc/passwd') + expect(preventDefault).toHaveBeenCalled() + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) + +describe('installGlobalGuards', () => { + it('hardens every created WebContents and rejects TLS errors', () => { + const appOn = vi.mocked(app.on) + appOn.mockClear() + installGlobalGuards(makeDeps()) + + const registrations = appOn.mock.calls as unknown as Array< + [string, (...args: unknown[]) => void] + > + const created = registrations.find(([event]) => event === 'web-contents-created') + expect(created).toBeDefined() + const contents = makeContents(`${APP}/workspace`) + ;(created?.[1] as (event: unknown, contents: unknown) => void)(undefined, contents) + + expect(contents.setWindowOpenHandler).toHaveBeenCalled() + const defaultHandler = contents.setWindowOpenHandler.mock.calls[0][0] as () => { + action: string + } + expect(defaultHandler()).toEqual({ action: 'deny' }) + + const webviewGuard = contents.handlers.get('will-attach-webview') + const preventDefault = vi.fn() + ;(webviewGuard as unknown as (event: { preventDefault: () => void }) => void)?.({ + preventDefault, + }) + expect(preventDefault).toHaveBeenCalled() + + expect(contents.handlers.has('devtools-opened')).toBe(true) + + const certHandler = registrations.find(([event]) => event === 'certificate-error') + expect(certHandler).toBeDefined() + const certPreventDefault = vi.fn() + const callback = vi.fn() + ;(certHandler?.[1] as (...args: unknown[]) => void)( + { preventDefault: certPreventDefault }, + contents, + 'https://bad-cert.example', + 'ERR_CERT_AUTHORITY_INVALID', + {}, + callback + ) + expect(certPreventDefault).toHaveBeenCalled() + expect(callback).toHaveBeenCalledWith(false) + }) +}) diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts new file mode 100644 index 0000000000..ac6e8b2882 --- /dev/null +++ b/apps/desktop/src/main/security-guards.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import type { WebContents } from 'electron' +import { app } from 'electron' +import { isAgentWebContents } from '@/main/browser-agent/registry' +import { classifyNavigation, openExternalSafe } from '@/main/navigation' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopSecurityGuards') + +export interface GuardDeps { + appOrigin: () => string + isPackaged: boolean + allowHttpLocalhost: () => boolean + isPopupContents: (contents: WebContents) => boolean + onLoginHandoff: () => void + onConnectIntercept: (contents: WebContents) => void +} + +/** + * Applies the top-level navigation policy to both will-navigate and + * will-redirect — OAuth chains can be redirect-injected, so redirects get the + * same classifier as direct navigations. + */ +export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): void { + const handle = (event: { preventDefault(): void }, url: string) => { + // The agent browser's tabs are general-purpose browsing surfaces: any + // http(s) navigation is their job (they run isolated on their own + // partition with no preload). Everything else stays denied. + if (isAgentWebContents(contents)) { + if (!/^https?:/i.test(url)) { + event.preventDefault() + logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) }) + } + return + } + const action = classifyNavigation(url, { + appOrigin: deps.appOrigin(), + currentUrl: contents.getURL(), + isPopup: deps.isPopupContents(contents), + }) + switch (action) { + case 'in-app': + case 'idp-in-window': + return + case 'external': + event.preventDefault() + void openExternalSafe(url, deps.allowHttpLocalhost()) + return + case 'idp-system-login': + event.preventDefault() + deps.onLoginHandoff() + return + case 'idp-system-connect': + event.preventDefault() + deps.onConnectIntercept(contents) + return + default: + event.preventDefault() + logger.warn('Denied navigation', { url: scrubUrl(url) }) + } + } + contents.on('will-navigate', handle) + contents.on('will-redirect', handle) +} + +/** + * Global defense-in-depth: every WebContents ever created — main window, MCP + * popups, blank children, settings, agent-browser tabs — gets webview + * blocking, packaged DevTools lockdown, a default-deny window.open handler + * (specific policies overwrite it), and the navigation classifier. + * Agent-browser tabs swap the classifier for a free-http(s) policy (their job + * is browsing arbitrary sites, isolated on their own partition). TLS errors + * are always fatal when packaged; self-host private CAs must be + * system-trusted rather than bypassed in-app. + */ +export function installGlobalGuards(deps: GuardDeps): void { + app.on('web-contents-created', (_event, contents) => { + contents.on('will-attach-webview', (event) => { + event.preventDefault() + logger.warn('Blocked webview attach') + }) + if (deps.isPackaged) { + contents.on('devtools-opened', () => { + contents.closeDevTools() + }) + } + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + attachNavigationGuards(contents, deps) + }) + + app.on('certificate-error', (event, _webContents, url, error, _certificate, callback) => { + event.preventDefault() + callback(false) + logger.warn('Rejected TLS certificate', { url: scrubUrl(url), error }) + }) +} diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts new file mode 100644 index 0000000000..64f98f1f09 --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow, type Session } from 'electron' +import { + createSessionLifecycleCoordinator, + decideStartRoute, + isLogoutNavigation, + isSessionCookieName, + probeSession, + resolveStartRoute, + revokeAppSession, + tearDownSession, +} from '@/main/session-lifecycle' + +const APP = 'https://sim.ai' + +describe('isSessionCookieName', () => { + it('matches the better-auth session cookie on secure and non-secure hosts', () => { + expect(isSessionCookieName('better-auth.session_token')).toBe(true) + expect(isSessionCookieName('__Secure-better-auth.session_token')).toBe(true) + }) + + it('ignores non-session cookies', () => { + expect(isSessionCookieName('better-auth.session_data')).toBe(false) + expect(isSessionCookieName('__Host-csrf')).toBe(false) + expect(isSessionCookieName('theme')).toBe(false) + }) +}) + +function sessionWithResponse(status: number, body: unknown): Session { + return { + fetch: vi.fn(async () => new Response(JSON.stringify(body), { status })), + } as unknown as Session +} + +describe('isLogoutNavigation', () => { + it('detects the web sign-out navigation', () => { + expect(isLogoutNavigation(`${APP}/login?fromLogout=true`, APP)).toBe(true) + }) + + it('ignores plain login loads, other origins, and garbage', () => { + expect(isLogoutNavigation(`${APP}/login`, APP)).toBe(false) + expect(isLogoutNavigation(`${APP}/login?fromLogout=false`, APP)).toBe(false) + expect(isLogoutNavigation('https://evil.example/login?fromLogout=true', APP)).toBe(false) + expect(isLogoutNavigation('not a url', APP)).toBe(false) + }) +}) + +describe('decideStartRoute', () => { + it('restores the last route when plausible', () => { + expect(decideStartRoute('/workspace/ws1?tab=logs')).toBe('/workspace/ws1?tab=logs') + expect(decideStartRoute('/workspace/ws1')).toBe('/workspace/ws1') + }) + + it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => { + expect(decideStartRoute(undefined)).toBe('/workspace') + expect(decideStartRoute('//evil.example')).toBe('/workspace') + expect(decideStartRoute('/login')).toBe('/workspace') + }) +}) + +describe('resolveStartRoute', () => { + it('restores an accessible saved workspace route', async () => { + const session = sessionWithResponse(200, { workspace: { id: 'ws1' } }) + + await expect(resolveStartRoute(session, APP, '/workspace/ws1/home')).resolves.toBe( + '/workspace/ws1/home' + ) + expect(vi.mocked(session.fetch)).toHaveBeenCalledWith( + `${APP}/api/workspaces/ws1/host-context`, + expect.objectContaining({ cache: 'no-store' }) + ) + }) + + it('falls back to the workspace picker after confirmed access denial', async () => { + const session = sessionWithResponse(403, { error: 'Workspace access denied' }) + + await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe( + '/workspace' + ) + }) + + it('does not probe routes without a workspace id', async () => { + const session = sessionWithResponse(200, {}) + + await expect(resolveStartRoute(session, APP, '/workspace')).resolves.toBe('/workspace') + expect(session.fetch).not.toHaveBeenCalled() + }) + + it('preserves the saved route on auth, server, and network failures', async () => { + await expect( + resolveStartRoute(sessionWithResponse(401, {}), APP, '/workspace/ws1/home') + ).resolves.toBe('/workspace/ws1/home') + await expect( + resolveStartRoute(sessionWithResponse(500, {}), APP, '/workspace/ws1/home') + ).resolves.toBe('/workspace/ws1/home') + + const failing = { + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as unknown as Session + await expect(resolveStartRoute(failing, APP, '/workspace/ws1/home')).resolves.toBe( + '/workspace/ws1/home' + ) + }) +}) + +describe('probeSession', () => { + it('reports valid when a session or user is present', async () => { + await expect(probeSession(sessionWithResponse(200, { user: { id: 'u1' } }), APP)).resolves.toBe( + 'valid' + ) + await expect( + probeSession(sessionWithResponse(200, { session: { id: 's1' } }), APP) + ).resolves.toBe('valid') + }) + + it('reports invalid for a null session body', async () => { + await expect(probeSession(sessionWithResponse(200, null), APP)).resolves.toBe('invalid') + }) + + it('reports unknown for server errors and network failures', async () => { + await expect(probeSession(sessionWithResponse(500, {}), APP)).resolves.toBe('unknown') + const failing = { + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as unknown as Session + await expect(probeSession(failing, APP)).resolves.toBe('unknown') + }) + + it('asks the get-session endpoint with the partition cookies', async () => { + const ses = sessionWithResponse(200, null) + await probeSession(ses, APP) + expect(vi.mocked(ses.fetch).mock.calls[0][0]).toBe(`${APP}/api/auth/get-session`) + }) +}) + +describe('tearDownSession', () => { + it('revokes server-side first, then local secrets and the browser profile, then the web session', async () => { + // Order is load-bearing: the server-side revoke needs the partition's + // session cookie, which clearStorageData destroys. + const order: string[] = [] + const session = { + clearStorageData: vi.fn(async () => { + order.push('session') + }), + } as unknown as Session + + await tearDownSession( + session, + async () => { + await Promise.resolve() + order.push('local') + }, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => { + await Promise.resolve() + order.push('browser') + }, + async () => { + await Promise.resolve() + order.push('revoke') + } + ) + + expect(order).toEqual(['revoke', 'local', 'browser', 'session']) + }) + + it('still clears the web session when the browser profile cannot be cleared', async () => { + // Sign-out must complete even if the embedded browser is in a bad state; + // failing to clear its cookies is bad, failing to sign out is worse. + const clearStorageData = vi.fn(async () => {}) + const session = { clearStorageData } as unknown as Session + + await expect( + tearDownSession( + session, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => { + throw new Error('browser view already destroyed') + }, + async () => {} + ) + ).resolves.toBeUndefined() + + expect(clearStorageData).toHaveBeenCalled() + }) + + it('still clears local state when the server-side revoke fails', async () => { + // Offline sign-out must not strand the user signed in locally. + const clearStorageData = vi.fn(async () => {}) + const session = { clearStorageData } as unknown as Session + + await expect( + tearDownSession( + session, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => {}, + async () => { + throw new Error('offline') + } + ) + ).resolves.toBeUndefined() + + expect(clearStorageData).toHaveBeenCalled() + }) +}) + +describe('revokeAppSession', () => { + const origin = 'https://sim.ai' + + function windowAt(url: string, destroyed = false) { + const executeJavaScript = vi.fn(async (_script: string) => 200) + return { + win: { + isDestroyed: () => destroyed, + webContents: { getURL: () => url, executeJavaScript }, + } as unknown as BrowserWindow, + executeJavaScript, + } + } + + it('POSTs sign-out from the app-origin renderer so the session row is deleted', async () => { + const { win, executeJavaScript } = windowAt(`${origin}/workspace`) + + await revokeAppSession(win, origin) + + expect(executeJavaScript).toHaveBeenCalledTimes(1) + const script = executeJavaScript.mock.calls[0][0] + expect(script).toContain('/api/auth/sign-out') + expect(script).toContain("credentials: 'include'") + }) + + it('skips a window that is off-origin or destroyed', async () => { + // The offline page and a lookalike host must never be asked to sign out — + // the request would not be same-origin and could not carry the cookie. + for (const url of ['about:blank', 'https://sim.ai.evil.example/workspace']) { + const { win, executeJavaScript } = windowAt(url) + await revokeAppSession(win, origin) + expect(executeJavaScript).not.toHaveBeenCalled() + } + + const destroyed = windowAt(`${origin}/workspace`, true) + await revokeAppSession(destroyed.win, origin) + expect(destroyed.executeJavaScript).not.toHaveBeenCalled() + }) + + it('does not throw when the renderer rejects', async () => { + const win = { + isDestroyed: () => false, + webContents: { + getURL: () => `${origin}/workspace`, + executeJavaScript: vi.fn(async () => { + throw new Error('render frame disposed') + }), + }, + } as unknown as BrowserWindow + + await expect(revokeAppSession(win, origin)).resolves.toBeUndefined() + }) +}) + +describe('createSessionLifecycleCoordinator', () => { + it('shares session observers and signs every app window out with one teardown', async () => { + const cookiesOn = vi.fn() + const webRequestOnCompleted = vi.fn() + const clearStorageData = vi.fn(async () => {}) + const session = { + cookies: { on: cookiesOn }, + webRequest: { onCompleted: webRequestOnCompleted }, + clearStorageData, + fetch: vi.fn(async () => Response.json(null)), + } as unknown as Session + const first = new BrowserWindow() + const second = new BrowserWindow() + const clearHandoffState = vi.fn(async () => {}) + const coordinator = createSessionLifecycleCoordinator({ + appSession: session, + origin: () => APP, + events: { filePath: '/tmp/events.log', record: vi.fn() }, + clearHandoffState, + clearBrowserProfile: vi.fn(async () => {}), + getWindows: () => [first, second], + }) + + coordinator.attachWindow(first) + coordinator.attachWindow(second) + + expect(cookiesOn).toHaveBeenCalledOnce() + // The shell no longer watches API responses: session expiry is the web + // app's to detect, so nothing here should subscribe to /api/* statuses. + expect(webRequestOnCompleted).not.toHaveBeenCalled() + + const windowEventCalls = vi.mocked(first.webContents.on).mock.calls as unknown as Array< + [string, (...args: unknown[]) => unknown] + > + const navigation = windowEventCalls.find(([event]) => event === 'did-navigate-in-page')?.[1] as + | ((event: unknown, url: string) => void) + | undefined + navigation?.({}, `${APP}/login?fromLogout=true`) + + await vi.waitFor(() => { + expect(clearStorageData).toHaveBeenCalledOnce() + expect(first.loadURL).toHaveBeenCalledWith(`${APP}/login`) + expect(second.loadURL).toHaveBeenCalledWith(`${APP}/login`) + }) + expect(clearHandoffState).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts new file mode 100644 index 0000000000..e922a1bd05 --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -0,0 +1,411 @@ +import { createLogger } from '@sim/logger' +import type { Session, WebContents } from 'electron' +import { BrowserWindow, dialog } from 'electron' +import { isSafeInternalPath } from '@/main/config' +import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopSessionLifecycle') + +const SESSION_PROBE_TIMEOUT_MS = 5000 +const START_ROUTE_PROBE_TIMEOUT_MS = 1500 +const TEARDOWN_COOLDOWN_MS = 3000 + +const CLEARED_STORAGES = [ + 'cookies', + 'localstorage', + 'indexdb', + 'cachestorage', + 'serviceworkers', +] as const + +export type SessionProbeResult = 'valid' | 'invalid' | 'unknown' + +/** + * Matches the better-auth session cookie across secure and non-secure hosts: + * `better-auth.session_token` (http/localhost) and + * `__Secure-better-auth.session_token` (https). Keying off the better-auth + * cookie name — a stable library contract — is far more robust than sniffing + * a Sim UI redirect URL, and it catches every sign-out path (settings, invite + * page, stale-session recovery) uniformly. + */ +export function isSessionCookieName(name: string): boolean { + return name.endsWith('session_token') +} + +/** + * Detects the web app's sign-out navigation (general settings routes to + * /login?fromLogout=true on sign-out). This is the fast path; the cookie + * watcher below is the robust backstop for sign-out paths that don't use it. + */ +export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean { + try { + const url = new URL(rawUrl) + return ( + url.origin === appOrigin && + url.pathname === '/login' && + url.searchParams.get('fromLogout') === 'true' + ) + } catch { + return false + } +} + +/** + * Picks the route to load at launch: the last visited route (when safe and + * not itself an auth surface), falling back to /workspace. A signed-out + * partition is handled by the web app's own login redirect. + */ +export function decideStartRoute(lastRoute: string | undefined): string { + if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) { + return lastRoute + } + return '/workspace' +} + +function workspaceIdFromRoute(route: string): string | null { + try { + const pathname = new URL(route, 'https://internal.invalid').pathname + const match = /^\/workspace\/([^/]+)/.exec(pathname) + return match ? decodeURIComponent(match[1]) : null + } catch { + return null + } +} + +/** + * Validates a workspace-specific saved route before Desktop restores it. + * Only a confirmed 403 discards the route; auth, network, timeout, and server + * failures preserve normal web-app recovery instead of masquerading as a + * revoked workspace. + */ +export async function resolveStartRoute( + session: Session, + origin: string, + lastRoute: string | undefined, + timeoutMs: number = START_ROUTE_PROBE_TIMEOUT_MS +): Promise { + const route = decideStartRoute(lastRoute) + const workspaceId = workspaceIdFromRoute(route) + if (!workspaceId) { + return route + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch( + `${origin}/api/workspaces/${encodeURIComponent(workspaceId)}/host-context`, + { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + } + ) + if (response.status === 403) { + logger.info('Saved workspace route is no longer accessible; opening workspace picker') + return '/workspace' + } + return route + } catch { + return route + } finally { + clearTimeout(timer) + } +} + +/** + * Checks whether the partition currently holds a valid session by asking + * better-auth's get-session endpoint with the partition's cookies. Network + * trouble reports 'unknown' so offline never masquerades as signed-out. + */ +export async function probeSession( + session: Session, + origin: string, + timeoutMs: number = SESSION_PROBE_TIMEOUT_MS +): Promise { + const controller = new AbortController() + // `finally`, not an inline clear after the await: a thrown fetch is the case + // this function exists for, and an inline clear is skipped on that path. + // The body read is inside the deadline too, so a stalled response cannot + // outlive the timeout. + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch(`${origin}/api/auth/get-session`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (!response.ok) { + return 'unknown' + } + const data = (await response.json().catch(() => null)) as { + session?: unknown + user?: unknown + } | null + return data && (data.session || data.user) ? 'valid' : 'invalid' + } catch { + return 'unknown' + } finally { + clearTimeout(timer) + } +} + +/** + * The user id the app partition is currently signed in as, or null when signed + * out or unreachable. + * + * The OAuth connect handoff runs entirely in the system browser, which holds a + * session of its own — since the app no longer shares the browser's session row, + * the two can be different accounts. Passing this id into `/desktop/connect` + * lets the server refuse rather than silently attach the credential to whichever + * account the browser happens to be signed into. + */ +export async function readSessionUserId( + session: Session, + origin: string, + timeoutMs: number = SESSION_PROBE_TIMEOUT_MS +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch(`${origin}/api/auth/get-session`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (!response.ok) { + return null + } + const data = (await response.json().catch(() => null)) as { + user?: { id?: unknown } + } | null + return typeof data?.user?.id === 'string' ? data.user.id : null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +const SIGN_OUT_SCRIPT = `(async () => { + try { + const response = await fetch('/api/auth/sign-out', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: '{}', + }) + return response.status + } catch { + return 0 + } +})()` + +/** + * Whether this window can carry the sign-out request. The trailing slash makes + * the prefix an origin test rather than a string test, so a lookalike host + * (`https://sim.ai.evil.example`) is never asked to sign the user out. + */ +export function canRevokeIn(win: BrowserWindow, origin: string): boolean { + return !win.isDestroyed() && win.webContents.getURL().startsWith(`${origin}/`) +} + +/** + * Revokes the app's session row server-side. + * + * The desktop holds a session of its own (see + * `apps/sim/lib/auth/desktop-handoff.ts`), so clearing the partition alone + * would leave a live 30-day credential on the server that nothing can revoke — + * there is no device-management UI. "Sign Out" has to reach the server. + * + * Runs in the app-origin renderer rather than `session.fetch`: Better Auth + * rejects a cookie-bearing POST that carries no `Origin` header + * (`MISSING_OR_NULL_ORIGIN`), and only a renderer request is genuinely + * same-origin — the same reason the token redeem runs there. Best-effort by + * design: sign-out must still clear local state when offline or when the + * window is showing the offline page, and `/sign-out` is a no-op when the + * cookie is already gone (the cookie-deletion backstop path). + */ +export async function revokeAppSession(win: BrowserWindow, origin: string): Promise { + if (!canRevokeIn(win, origin)) { + return + } + try { + await win.webContents.executeJavaScript(SIGN_OUT_SCRIPT, true) + } catch (error) { + logger.warn('Could not revoke the session server-side', { error }) + } +} + +/** + * Revokes the session server-side, then clears every session-bearing storage in + * the app partition plus any pending handoff secrets — the desktop analogue of + * a browser profile sign-out. + * + * The embedded browser has its own partition, which this clears too: it holds + * the signed-in user's cookies for third-party sites, so leaving it behind + * would hand the next account on this machine a set of live sessions. Injected + * rather than imported, so this module does not pull the whole browser-agent + * subsystem — and its module-load side effects — into the auth path. + */ +export async function tearDownSession( + session: Session, + clearHandoffState: () => void | Promise, + events: EventRecorder, + clearBrowserProfile: () => Promise, + revokeSession: () => Promise +): Promise { + events.record('sign_out') + // Server-side first, while the partition still holds the session cookie the + // revoke needs. Every step below is best-effort for the same reason the + // browser-profile clear is: failing to clear something is bad, failing to + // sign out is worse. + await revokeSession().catch((error) => logger.error('Session revoke failed', { error })) + await clearHandoffState() + await clearBrowserProfile().catch((error) => + logger.error('Browser profile teardown failed', { error }) + ) + await session.clearStorageData({ storages: [...CLEARED_STORAGES] }) +} + +export interface SessionLifecycleDeps { + appSession: Session + origin: () => string + events: EventRecorder + clearHandoffState: () => void | Promise + /** Clears the embedded browser's own partition. See {@link tearDownSession}. */ + clearBrowserProfile: () => Promise +} + +export interface SessionLifecycleCoordinator { + attachWindow(win: BrowserWindow): void + /** + * Signs out through the same path web sign-out takes. Callers must use this + * rather than calling {@link tearDownSession} directly: a direct call skips + * the in-progress guard, and its own cookie removal then trips the cookie + * watcher into a second concurrent teardown. + */ + signOut(): void +} + +interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { + getWindows: () => BrowserWindow[] +} + +/** + * Owns shared app-session observers once per Electron process while allowing + * every full Sim window to contribute navigation signals. This prevents + * duplicate cookie listeners and storage clears when multiple application + * windows are open. + * + * Session *expiry* is deliberately not handled here. The web app owns it: its + * session query settles to `null` and `SessionExpired` signs out and redirects, + * by the same mechanism in the browser and the app. (The app's session row is + * its own, so the two expire on independent clocks — `updateAge` refreshes only + * the row that made the request.) A shell-side detector could only infer that + * state from cookie events and 401 statuses, which is how it ended up prompting + * on ordinary sign-outs and on launching signed out. + */ +export function createSessionLifecycleCoordinator( + deps: SessionLifecycleCoordinatorDeps +): SessionLifecycleCoordinator { + let tearingDown = false + const runTeardown = () => { + if (tearingDown) return + tearingDown = true + logger.info('Sign-out detected; clearing partition') + void tearDownSession( + deps.appSession, + deps.clearHandoffState, + deps.events, + deps.clearBrowserProfile, + // One revoke, not one per window: every window shares the partition's + // single session, so the first on-origin renderer can speak for all of + // them and the rest would be no-ops against an already-deleted row. + async () => { + const origin = deps.origin() + const win = deps.getWindows().find((candidate) => canRevokeIn(candidate, origin)) + if (win) { + await revokeAppSession(win, origin) + } + } + ) + .catch((error) => logger.error('Session teardown failed', { error })) + .finally(() => { + for (const win of deps.getWindows()) { + if (!win.isDestroyed()) { + void win.loadURL(`${deps.origin()}/login`).catch(() => {}) + } + } + // Re-arm after clearStorageData's own cookie-removal events have + // drained, so self-induced deletions never re-trigger teardown. + setTimeout(() => { + tearingDown = false + }, TEARDOWN_COOLDOWN_MS) + }) + } + + // Robust backstop: when the better-auth session cookie is deleted by ANY + // path (not just the fromLogout redirect), confirm the session is really + // gone with a probe — so cookie rotation can't cause a false teardown — then + // clear the partition. This closes the cross-account residue gap. + deps.appSession.cookies.on('changed', (_event, cookie, cause, removed) => { + if (tearingDown || !removed || cause === 'overwrite') { + return + } + if (!isSessionCookieName(cookie.name)) { + return + } + void probeSession(deps.appSession, deps.origin()).then((state) => { + if (state === 'invalid') { + runTeardown() + } + }) + }) + + return { + signOut: runTeardown, + attachWindow(win) { + const onNavigation = (url: string) => { + if (isLogoutNavigation(url, deps.origin())) { + runTeardown() + } + } + // The web app signs out with a Next.js soft navigation to + // /login?fromLogout=true, which fires did-navigate-in-page — not + // did-navigate — so both events must be observed or teardown never runs. + win.webContents.on('did-navigate', (_event, url) => onNavigation(url)) + win.webContents.on('did-navigate-in-page', (_event, url) => onNavigation(url)) + }, + } +} + +/** + * Explains that Google/Microsoft connections must finish in the browser and + * reopens the current page there — the browser holds its own signed-in + * session after the login handoff, so the connect completes and tokens land + * server-side. Back in the app, a refresh picks the connection up. + */ +export async function handleConnectIntercept( + contents: WebContents, + allowHttpLocalhost: boolean +): Promise { + const pageUrl = contents.getURL() + const win = BrowserWindow.fromWebContents(contents) + const options = { + type: 'info' as const, + buttons: ['Open in Browser', 'Cancel'], + defaultId: 0, + cancelId: 1, + message: 'Finish connecting in your browser', + detail: + 'This provider requires completing the connection in your web browser. Sim will open this page there — connect the account, then come back to the app and refresh.', + } + const { response } = win + ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options) + if (response === 0) { + await openExternalSafe(pageUrl, allowHttpLocalhost) + } +} diff --git a/apps/desktop/src/main/telemetry-policy.test.ts b/apps/desktop/src/main/telemetry-policy.test.ts new file mode 100644 index 0000000000..29b0cd2f6a --- /dev/null +++ b/apps/desktop/src/main/telemetry-policy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { shouldBlockRequest } from '@/main/telemetry-policy' + +describe('shouldBlockRequest', () => { + it('blocks third-party analytics hosts and their subdomains', () => { + expect(shouldBlockRequest('https://www.googletagmanager.com/gtm.js?id=GTM-X')).toBe(true) + expect(shouldBlockRequest('https://google-analytics.com/collect')).toBe(true) + expect(shouldBlockRequest('https://region1.google-analytics.com/g/collect')).toBe(true) + expect(shouldBlockRequest('https://analytics.google.com/g/collect')).toBe(true) + expect(shouldBlockRequest('https://stats.g.doubleclick.net/j/collect')).toBe(true) + }) + + it('leaves first-party and functional traffic alone', () => { + expect(shouldBlockRequest('https://sim.ai/api/workflows')).toBe(false) + expect(shouldBlockRequest('https://sim.ai/ingest/e')).toBe(false) + expect(shouldBlockRequest('wss://api.elevenlabs.io/v1/stt')).toBe(false) + expect(shouldBlockRequest('https://storage.googleapis.com/bucket/file')).toBe(false) + }) + + it('ignores unparseable URLs', () => { + expect(shouldBlockRequest('not a url')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/telemetry-policy.ts b/apps/desktop/src/main/telemetry-policy.ts new file mode 100644 index 0000000000..b79411f61f --- /dev/null +++ b/apps/desktop/src/main/telemetry-policy.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import type { Session } from 'electron' +import { matchesHostList } from '@/main/navigation' + +const logger = createLogger('DesktopTelemetryPolicy') + +/** + * Third-party web-analytics hosts blocked at the network layer. The hosted + * origin gates GA/GTM on isHosted (true for sim.ai), so desktop sessions + * would otherwise pollute web analytics as untagged pageviews. First-party + * product analytics (same-origin /ingest) is untouched. + */ +export const BLOCKED_ANALYTICS_HOSTS: readonly string[] = [ + 'googletagmanager.com', + 'google-analytics.com', + 'analytics.google.com', + 'stats.g.doubleclick.net', +] + +const BLOCK_URL_PATTERNS = BLOCKED_ANALYTICS_HOSTS.flatMap((host) => [ + `*://${host}/*`, + `*://*.${host}/*`, +]) + +/** + * Suffix-matches a URL's hostname against the blocked analytics hosts. + */ +export function shouldBlockRequest(rawUrl: string): boolean { + let hostname: string + try { + hostname = new URL(rawUrl).hostname + } catch { + return false + } + return matchesHostList(hostname, BLOCKED_ANALYTICS_HOSTS) +} + +/** + * Installs the desktop analytics policy on the app session. This is the only + * onBeforeRequest consumer — Electron allows a single listener per session. + */ +export function attachTelemetryPolicy(session: Session, enabled: boolean): void { + if (!enabled) { + return + } + session.webRequest.onBeforeRequest({ urls: BLOCK_URL_PATTERNS }, (details, callback) => { + callback({ cancel: shouldBlockRequest(details.url) }) + }) + logger.info('Third-party analytics blocking enabled') +} diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts new file mode 100644 index 0000000000..70adf203b6 --- /dev/null +++ b/apps/desktop/src/main/terminal/index.ts @@ -0,0 +1,951 @@ +/** + * The agent-terminal service: several concurrent shells, their tab ordering, + * and tool execution against them. + * + * Commands run unattended. There is no per-command approval and no OS jail, so + * anything the agent runs holds the user's own privileges — the only controls + * left are upstream: the desktop capability gate, and the tool-authorization + * check in ipc.ts that ties every call to a real pending Copilot tool call so + * page code cannot invent one. Reintroducing a boundary means adding it back + * here, in main, where the renderer cannot route around it. + */ +import { statSync } from 'node:fs' +import { homedir } from 'node:os' +import { createLogger } from '@sim/logger' +import { + DEFAULT_RUN_WAIT_MS, + isTerminalControlKey, + MAX_INPUT_KEYS, + MAX_RUN_WAIT_MS, + MAX_TERMINALS, + MAX_TOOL_OUTPUT_CHARS, + type TerminalCommandEvent, + type TerminalControlKey, + type TerminalCwdResult, + type TerminalErrorCode, + type TerminalHandoffResult, + type TerminalOperation, + type TerminalPanesResult, + type TerminalStartOptions, + type TerminalTabsState, + type TerminalToolArgs, + type TerminalToolResponse, +} from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' +import type { BrowserWindow, WebContents } from 'electron' +import { elide, TerminalSession } from '@/main/terminal/session' +import { + activePane, + awaitRun, + capturePane, + closeRunWindow, + isTmuxUnavailable, + killPane, + listPanes, + resolveAttachment, + sendKey, + sendText, + startRun, + TMUX_KEY_NAMES, + type TmuxAttachment, +} from '@/main/terminal/tmux' + +const logger = createLogger('DesktopTerminal') + +/** + * How long to let a just-spawned shell finish its startup files before + * concluding it has no integration. Generous because a heavy `.zshrc` + * (nvm, pyenv, starship) can take a while on a cold start. + */ +const SHELL_INTEGRATION_TIMEOUT_MS = 8_000 + +/** Grace for a program to react to input before its screen is worth reading. */ +const INPUT_ECHO_MS = 250 + +/** Enough of the screen to show whether the input took, without a wall of it. */ +const INPUT_SCREEN_LINES = 60 + +/** + * How often the active terminal's directory is reconciled against the OS. + * Fast enough that a `cd` renames the tab about as soon as the user looks at + * it, slow enough that the lookup is nowhere near a hot path. + */ +const CWD_POLL_MS = 1_000 + +/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */ +const TMUX_KEY_GAP_MS = 150 + +/** How long a resolved tmux attachment is reused before re-resolving. */ +const TMUX_ATTACHMENT_TTL_MS = 3_000 + +/** How often a handoff checks whether the user or the command has finished. */ +const HANDOFF_POLL_MS = 500 + +/** + * Ceiling on a handoff. A person can take as long as they like — they may + * have walked away mid-install — so this only exists so a forgotten handoff + * cannot hold a turn open forever. + */ +const HANDOFF_MAX_MS = 12 * 60 * 60 * 1000 + +/** + * Grace given to a command that is still running after the user hands back. + * Answering a prompt usually finishes the job within seconds; anything longer + * is an ordinary long-running command, and comes back as `running` for the + * agent to poll rather than holding the turn. + */ +const HANDOFF_SETTLE_MS = 5_000 + +/** How long to hold the turn before handing a still-running command back. */ +function resolveWaitMs(waitSeconds: number | undefined): number { + const requested = Number(waitSeconds) + return Number.isFinite(requested) && requested > 0 + ? Math.min(requested * 1000, MAX_RUN_WAIT_MS) + : DEFAULT_RUN_WAIT_MS +} + +function elideOutput(value: string): { text: string; truncated: boolean } { + return elide(value, MAX_TOOL_OUTPUT_CHARS) +} + +/** + * The keys an input call wants pressed, in order. A lone `key` is the same + * thing with one element, so both arrive here as one list. + */ +function requestedKeys(args: TerminalToolArgs): TerminalControlKey[] { + const batch = Array.isArray(args.keys) ? args.keys : [] + const keys = batch.length > 0 ? batch : isTerminalControlKey(args.key) ? [args.key] : [] + return keys.filter(isTerminalControlKey).slice(0, MAX_INPUT_KEYS) +} + +const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null } + +class TerminalError extends Error { + constructor( + readonly code: TerminalErrorCode, + message: string + ) { + super(message) + this.name = 'TerminalError' + } +} + +/** Where the service pushes live updates; wired to the renderer by ipc.ts. */ +export interface TerminalSink { + data(terminalId: string, data: string): void + tabs(state: TerminalTabsState): void + command(event: TerminalCommandEvent): void +} + +export interface TerminalServiceOptions { + /** + * Where the last session left off, so reopening the terminal resumes in the + * directory the user was working in rather than dropping them back in + * `$HOME`. Returning undefined (or a path that no longer exists) falls back + * to the home directory. + */ + loadCwd?(): string | undefined + saveCwd?(cwd: string): void +} + +export class TerminalService { + /** Insertion-ordered, which is also the tab order the user sees. */ + private readonly sessions = new Map() + private activeId: string | null = null + /** True while tearing every shell down, so an exit does not respawn one. */ + private disposing = false + private nextId = 1 + private sink: TerminalSink | null = null + private lastEmittedTabs: string | null = null + private cwdTimer: NodeJS.Timeout | null = null + /** + * The renderer whose terminal panel holds the user's keyboard focus, or null. + * + * The claim IS the owner — there is deliberately no separate boolean. A + * second field could hold `true` with no live owner, and that combination is + * precisely the latch this binding exists to prevent: every reader would + * have to remember to reject it, and the one that forgot would close a shell + * nobody was looking at. + */ + private focusOwner: WebContents | null = null + private releaseFocusListeners: (() => void) | null = null + /** Directories of recently closed terminals, newest first, for reopening. */ + private readonly recentlyClosedCwds: string[] = [] + /** Terminals handed to the user; the value is whether they have handed back. */ + private readonly handoffs = new Map() + /** Recently resolved tmux attachments, by terminal id, to avoid re-spawning. */ + private readonly tmuxCache = new Map() + + constructor(private readonly options: TerminalServiceOptions = {}) {} + + setSink(sink: TerminalSink | null): void { + this.sink = sink + // A new sink has seen nothing, so the dedupe baseline has to reset or the + // panel would wait for an unrelated change before learning the tab list. + this.lastEmittedTabs = null + if (sink) this.startCwdWatch() + else this.stopCwdWatch() + } + + /** + * Keeps the visible tab's directory honest without depending on the shell. + * + * Only the active session is polled, and only while a panel is attached and + * nothing is running in the foreground (a running command owns the label + * anyway), so this costs one cheap lookup a second at most — the reason it + * samples rather than watching every keystroke. + */ + private startCwdWatch(): void { + if (this.cwdTimer) return + this.cwdTimer = setInterval(() => { + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (!active || active.isBusy) return + void active.refreshCwd() + }, CWD_POLL_MS) + this.cwdTimer.unref?.() + } + + private stopCwdWatch(): void { + if (!this.cwdTimer) return + clearInterval(this.cwdTimer) + this.cwdTimer = null + } + + getTabs(): TerminalTabsState { + if (this.sessions.size === 0) return EMPTY_TABS + return { + tabs: [...this.sessions.values()].map((session) => + session.tabState(session.terminalId === this.activeId) + ), + activeTerminalId: this.activeId, + } + } + + /** Opens the first terminal, or adopts what is already running. */ + start(options: TerminalStartOptions): TerminalTabsState { + if (this.sessions.size === 0) { + this.spawn(this.startingCwd(), options.cols, options.rows) + } + return this.getTabs() + } + + /** + * Everything on a terminal's screen, for a freshly created view to paint + * itself from. + * + * Pulled by the view rather than pushed on start. Pushing meant the repaint + * was aimed at whoever happened to be subscribed at the time: on a first + * mount that is nobody, because the tab list is still empty and no view + * exists yet, so the paint was dropped and the panel came up blank over a + * shell that had been running all along. On later mounts it was everybody, + * so a panel that already had its content repainted anyway. A view asking + * for its own terminal is right in both cases, and asks exactly once. + */ + getScrollback(terminalId: string): string { + return this.sessions.get(terminalId)?.takeReplaySnapshot() ?? '' + } + + /** Opens an additional terminal and makes it active. */ + openTerminal(cwd?: string): TerminalTabsState { + if (this.sessions.size >= MAX_TERMINALS) { + throw new TerminalError( + 'TOO_MANY_TERMINALS', + `Up to ${MAX_TERMINALS} terminals can be open at once. Close one first.` + ) + } + const active = this.activeId ? this.sessions.get(this.activeId) : null + const size = active ? { cols: active.cols, rows: active.rows } : { cols: 80, rows: 24 } + // A new terminal opens where the current one is: the user is almost always + // continuing the same piece of work in a second shell. + this.spawn(cwd ?? active?.currentCwd ?? this.startingCwd(), size.cols, size.rows) + return this.getTabs() + } + + switchTerminal(terminalId: string): TerminalTabsState { + if (!this.sessions.has(terminalId)) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId)) + } + this.activeId = terminalId + this.emitTabs() + void this.sessions.get(terminalId)?.refreshCwd() + return this.getTabs() + } + + /** + * Closes a terminal, or resets it when it is the only one left. + * + * Emptying the panel is not an option the close button should have: the + * resource IS a terminal, so a panel with no shell in it is a dead end the + * user has to close and reopen to escape. Replacing the last shell with a + * fresh one in the same directory gives the button a sensible meaning at + * every count — the same shape as closing a browser's last tab, which + * leaves you a tab rather than an empty window. + * + * A shell that ends by itself — `exit`, or Ctrl-D — goes the same way. It + * leaves behind a session that can no longer do anything, so it has to be + * reaped either way; treating it as a close means the last one is replaced + * rather than leaving a dead tab that cannot be typed into. + */ + closeTerminal(terminalId: string): TerminalTabsState { + if (!this.sessions.has(terminalId)) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId)) + } + return this.retire(terminalId) + } + + /** + * Drops a terminal and decides what replaces it. Closing and exiting share + * this so the two cannot drift into different answers for "what happens to + * the last one". + */ + private retire(terminalId: string): TerminalTabsState { + const session = this.sessions.get(terminalId) + if (!session) return this.getTabs() + const closedCwd = session.currentCwd + const cols = session.cols + const rows = session.rows + const order = [...this.sessions.keys()] + const index = order.indexOf(terminalId) + session.dispose() + this.sessions.delete(terminalId) + this.tmuxCache.delete(terminalId) + + if (this.sessions.size === 0) { + this.spawn(this.resolveCwd(closedCwd), cols, rows) + return this.getTabs() + } + + this.rememberClosed(closedCwd) + if (this.activeId === terminalId) { + this.activeId = order[index + 1] ?? order[index - 1] ?? null + } + this.emitTabs() + return this.getTabs() + } + + /** + * Reopens the most recently closed terminal, in the directory it was in. + * + * A shell cannot be restored the way a browser tab can — its processes are + * gone and its scrollback with them — so this reopens where it was working, + * which is the part that is expensive for the user to retype. + */ + reopenClosedTerminal(ownerWindow: BrowserWindow | null): boolean { + if (!this.ownsInteraction(ownerWindow)) return false + // Peeked, not shifted: at the cap there is nothing to reopen into, and + // consuming the entry here would drop that directory on the floor. + if (this.recentlyClosedCwds.length === 0 || this.sessions.size >= MAX_TERMINALS) return false + const cwd = this.recentlyClosedCwds.shift() + this.openTerminal(cwd || undefined) + return true + } + + /** Closes the active terminal, but only while the panel owns interaction focus. */ + closeFocusedTerminal(ownerWindow: BrowserWindow | null): boolean { + if (!this.ownsInteraction(ownerWindow) || !this.activeId) return false + this.closeTerminal(this.activeId) + return true + } + + /** + * Whether the terminal panel owns keyboard focus. Menu accelerators are + * global, so Cmd-W has to know whether the user is looking at a terminal or + * at something else in the window before deciding what to close. + * + * The claim is bound to the renderer that made it. A reload or a window close + * never runs the renderer's cleanup, so without that binding the flag latched + * true forever and Cmd-W destroyed an invisible shell. + */ + setPanelFocused(focused: boolean, owner?: WebContents | null): void { + if (!focused) { + // Only the holder may release. Every renderer reports its own blur — a + // second window switching away from its terminal, or tearing its panel + // down, sends `false` from a WebContents that never held the claim. Left + // ungated, that erased the live claim of the window the user was + // actually typing in, and their next Cmd-W closed that window with its + // shells still running. A release with no owner named is the shell's own + // teardown (dispose), which may always release. + if (owner && this.focusOwner && owner !== this.focusOwner) return + this.releaseFocusOwner() + return + } + this.releaseFocusOwner() + // An unattributable claim is dropped rather than recorded: with no live + // renderer behind it there is nothing that could ever release it. + if (!owner || owner.isDestroyed()) return + this.focusOwner = owner + const release = () => this.setPanelFocused(false, owner) + const onNavigate = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { + // A same-document route change keeps the React tree that made the claim. + if (details.isMainFrame && !details.isSameDocument) release() + } + owner.once('destroyed', release) + owner.on('did-start-navigation', onNavigate) + this.releaseFocusListeners = () => { + if (owner.isDestroyed()) return + owner.removeListener('destroyed', release) + owner.removeListener('did-start-navigation', onNavigate) + } + } + + /** Drops the claim and unsubscribes from the owner's lifecycle. */ + private releaseFocusOwner(): void { + this.releaseFocusListeners?.() + this.releaseFocusListeners = null + this.focusOwner = null + } + + /** + * Whether a global accelerator fired in the window that actually holds the + * focused terminal panel. Without the window check a claim made in one window + * answered Cmd-W in every other one. + */ + private ownsInteraction(ownerWindow: BrowserWindow | null): boolean { + if (!this.focusOwner || this.focusOwner.isDestroyed()) return false + // Required, not optional, and null answers no. A window is what an + // accelerator arrives from, so "no window" cannot be a window this claim + // answers for — and making the parameter mandatory means a later caller + // cannot reintroduce the cross-window bug just by leaving it off. + if (!ownerWindow) return false + // The panel lives in the window's own top-level renderer, so this is an + // identity check on that renderer — and it keeps electron a type-only + // import here, which is what lets the service be tested without a shell. + return ownerWindow.webContents === this.focusOwner + } + + private rememberClosed(cwd: string | null): void { + this.recentlyClosedCwds.unshift(cwd ?? '') + if (this.recentlyClosedCwds.length > MAX_TERMINALS) { + this.recentlyClosedCwds.length = MAX_TERMINALS + } + } + + /** A directory that still exists, else the usual starting point. */ + private resolveCwd(candidate: string | null): string { + if (candidate) { + try { + if (statSync(candidate).isDirectory()) return candidate + } catch { + // Deleted while the shell was open; fall through. + } + } + return this.startingCwd() + } + + write(terminalId: string, data: string): void { + this.sessions.get(terminalId)?.write(data) + } + + resize(terminalId: string, cols: number, rows: number): void { + this.sessions.get(terminalId)?.resize(cols, rows) + } + + dispose(): void { + this.disposing = true + this.stopCwdWatch() + // Persisted here rather than left to the onState callback, which resolves + // the active session out of the very map this teardown empties. Losing it + // reopens the next launch in whichever directory last reported a change + // instead of the one the user was working in. + const activeCwd = this.activeId ? this.sessions.get(this.activeId)?.currentCwd : null + if (activeCwd) this.options.saveCwd?.(activeCwd) + // Remove each session before disposing it: dispose() emits state, which + // reads back through getTabs(), and a session still in the map there is + // published to the renderer as a live tab after its shell is gone. + for (const [terminalId, session] of [...this.sessions]) { + this.sessions.delete(terminalId) + session.dispose() + } + this.sessions.clear() + this.tmuxCache.clear() + this.activeId = null + // A stale claim here is what let Cmd-W close a shell that no longer exists. + this.setPanelFocused(false) + this.disposing = false + } + + async executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise { + try { + const result = await this.dispatch(toolCallId, operation, args ?? {}) + return { ok: true, result } + } catch (error) { + if (error instanceof TerminalError) { + logger.warn('Terminal operation refused', { toolCallId, operation, code: error.code }) + return { ok: false, error: error.message, code: error.code } + } + const message = (error as Error).message + logger.error('Terminal operation failed', { toolCallId, operation, error: message }) + return { ok: false, error: message } + } + } + + private async dispatch( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise { + switch (operation) { + case 'list': + return this.getTabs() + case 'new': + return this.openTerminal(typeof args.cwd === 'string' ? args.cwd : undefined) + case 'switch': + return this.switchTerminal(this.requireId(args)) + case 'close': + // A named pane is a tmux thing and needs the session resolved below; + // without one, close means the Sim terminal. + if (typeof args.pane !== 'string' || !args.pane.trim()) { + return this.closeTerminal(this.requireId(args)) + } + break + default: + break + } + + const session = this.requireSession(args) + // A tab either has tmux attached or it does not, and every operation below + // behaves differently depending on which. + const tmux = await this.resolveTmux(session) + + switch (operation) { + case 'cwd': + return { + cwd: session.currentCwd, + shellName: session.shell, + home: homedir(), + terminalId: session.terminalId, + } satisfies TerminalCwdResult + case 'close': { + if (!tmux) { + throw new TerminalError( + 'NO_TMUX', + 'That terminal is a plain shell, so it has no panes. Close the terminal itself by omitting `pane`.' + ) + } + const target = await this.resolvePane(tmux.session, args, session) + const killed = await killPane(target, session.env) + if (!killed.ok) { + throw new TerminalError( + 'NO_SUCH_PANE', + killed.stderr.trim() || `tmux could not close pane ${target}.` + ) + } + return { + closed: target, + terminalId: session.terminalId, + panes: await listPanes(tmux.session, session.env), + } + } + case 'handoff': + return this.handoff(session, args) + case 'panes': { + if (!tmux) { + throw new TerminalError( + 'NO_TMUX', + 'That terminal is a plain shell, not a tmux session, so it has no panes.' + ) + } + return { + terminalId: session.terminalId, + session: tmux.session, + panes: await listPanes(tmux.session, session.env), + } satisfies TerminalPanesResult + } + case 'run': + return tmux + ? this.runInTmux(session, tmux.session, args) + : this.run(toolCallId, session, args) + case 'read': { + const requested = Number(args.lines) + const lines = Number.isFinite(requested) && requested > 0 ? requested : 200 + if (!tmux) return await session.readScrollback(lines) + const target = await this.resolvePane(tmux.session, args, session) + const captured = await capturePane(target, lines, session.env) + if (!captured.ok) { + throw new TerminalError( + 'NO_SUCH_PANE', + captured.stderr.trim() || `tmux could not read pane ${target}.` + ) + } + return { + output: captured.stdout, + cwd: session.currentCwd, + terminalId: session.terminalId, + pane: target, + truncated: false, + running: null, + } + } + case 'input': + return tmux + ? this.inputToTmux(session, tmux.session, args) + : this.inputToShell(session, args) + case 'kill': { + const signal = + args.signal === 'SIGTERM' || args.signal === 'SIGKILL' || args.signal === 'SIGINT' + ? args.signal + : 'SIGINT' + // Inside tmux a signal has to arrive as a keypress in the pane. Killing + // the pty would take down the tmux client instead, detaching the user's + // whole session rather than stopping the one thing they asked about. + if (tmux) { + const target = await this.resolvePane(tmux.session, args, session) + await sendKey(target, signal === 'SIGKILL' ? 'C-\\' : 'C-c', session.env) + return { signal, terminalId: session.terminalId, pane: target } + } + session.kill(signal) + return { signal, terminalId: session.terminalId } + } + default: + throw new TerminalError('INVALID_REQUEST', `Unknown terminal operation: ${operation}`) + } + } + + /** + * Gives the terminal to the user and waits for them. + * + * A command sitting on a prompt it cannot answer — a password, a decision + * that is not the agent's to make — otherwise leaves the tool call spinning + * with nothing on screen to explain why. This surfaces a chip in the chat + * saying what is needed, and resolves when the command that was blocking + * finishes, so the agent resumes knowing the outcome rather than guessing + * whether the user got to it. + */ + private async handoff(session: TerminalSession, args: TerminalToolArgs): Promise { + const reason = typeof args.reason === 'string' ? args.reason.trim() : '' + const terminalId = session.terminalId + this.handoffs.set(terminalId, false) + + const settled = async (handedBack: boolean): Promise => { + const view = await session.readScrollback(INPUT_SCREEN_LINES) + return { + terminalId, + reason, + handedBack, + running: session.foreground, + output: view.output, + cwd: session.currentCwd, + } + } + + try { + const deadline = Date.now() + HANDOFF_MAX_MS + let handedBackAt: number | null = null + while (Date.now() < deadline) { + await sleep(HANDOFF_POLL_MS) + if (!session.alive) { + throw new TerminalError('SESSION_CLOSED', 'That terminal was closed during the handoff.') + } + // The command finishing is the real end of the handoff, whether or not + // the user pressed anything: it means the prompt got answered. + if (!session.isBusy) return await settled(this.handoffs.get(terminalId) === true) + if (this.handoffs.get(terminalId) === true) { + handedBackAt ??= Date.now() + if (Date.now() - handedBackAt >= HANDOFF_SETTLE_MS) return await settled(true) + } + } + return await settled(this.handoffs.get(terminalId) === true) + } finally { + this.handoffs.delete(terminalId) + } + } + + /** The user pressing the hand-back button on a waiting handoff. */ + finishHandoff(terminalId: string): void { + if (this.handoffs.has(terminalId)) this.handoffs.set(terminalId, true) + } + + /** + * The tmux session attached in a terminal, cached briefly. + * + * Resolving it spawns `tmux list-clients` and a whole-machine `ps` — a real + * cost to pay on every tool call, when a shell's attachment does not change + * between calls a second apart. A short TTL keeps the common burst of + * operations (run, then poll with read, then read again) to one resolution, + * while staying fresh enough to notice the user starting or leaving tmux. + */ + private async resolveTmux(session: TerminalSession): Promise { + if (isTmuxUnavailable()) return null + const cached = this.tmuxCache.get(session.terminalId) + if (cached && Date.now() - cached.at < TMUX_ATTACHMENT_TTL_MS) { + return cached.attachment + } + const attachment = await resolveAttachment(session.pid, session.env) + this.tmuxCache.set(session.terminalId, { at: Date.now(), attachment }) + return attachment + } + + /** The pane a call names, or the session's active one. */ + private async resolvePane( + session: string, + args: TerminalToolArgs, + terminal: TerminalSession + ): Promise { + if (typeof args.pane === 'string' && args.pane.trim()) return args.pane.trim() + const active = await activePane(session, terminal.env) + if (!active) { + throw new TerminalError('NO_SUCH_PANE', `tmux session "${session}" has no active pane.`) + } + return active + } + + /** + * Types into a tmux pane rather than the pty. + * + * Writing to the pty would reach whichever pane tmux happens to have focused + * and would be invisible to any targeting the caller asked for; send-keys + * addresses a pane directly. Unlike the plain-shell path this is allowed at + * an idle prompt, because in tmux there is no foreground command to gate on + * and typing a command into a pane is the normal way to drive one. + */ + private async inputToTmux( + terminal: TerminalSession, + session: string, + args: TerminalToolArgs + ): Promise { + const target = await this.resolvePane(session, args, terminal) + const keys = requestedKeys(args) + if (keys.length > 0) { + for (let index = 0; index < keys.length; index += 1) { + // Paced like the pty path: a pane redraws between presses, so a batch + // lands where the same keys pressed by hand would. + if (index > 0) await sleep(TMUX_KEY_GAP_MS) + await sendKey(target, TMUX_KEY_NAMES[keys[index]] ?? keys[index], terminal.env) + } + } else if (typeof args.text === 'string') { + await sendText(target, args.text, terminal.env) + // Enter is a separate send-keys for the same reason it is a separate pty + // write: a program reading one chunk treats text plus a carriage return + // as text, and the message sits unsubmitted. + if (/[\r\n]$/.test(args.text)) await sendKey(target, 'Enter', terminal.env) + } else { + throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.') + } + + await sleep(INPUT_ECHO_MS) + const captured = await capturePane(target, INPUT_SCREEN_LINES, terminal.env) + return { + sent: keys.length > 0 ? keys.join(', ') : args.text, + terminalId: terminal.terminalId, + pane: target, + output: captured.stdout, + } + } + + private async inputToShell(session: TerminalSession, args: TerminalToolArgs): Promise { + // Input is only ever delivered to a program that already holds the + // foreground. At a bare shell prompt these bytes would be a command + // line, and running commands that way would bypass the capture and + // status tracking that `run` provides. + if (!session.isBusy) { + throw new TerminalError( + 'INVALID_REQUEST', + 'Nothing is running in that terminal, so there is nothing to type into. Use the run operation to run a command.' + ) + } + // Every input returns the screen it produced. Reporting only "sent" + // lets the model assume its message went through and start waiting on + // a reply to text still sitting unsubmitted in a composer; the screen + // is the evidence of what the program actually did with the input. + const keys = requestedKeys(args) + if (keys.length > 0) { + await session.pressKeys(keys) + await sleep(INPUT_ECHO_MS) + return { sent: keys.join(', '), ...(await session.readScrollback(INPUT_SCREEN_LINES)) } + } + if (typeof args.text === 'string') { + await session.type(args.text) + await sleep(INPUT_ECHO_MS) + return { sent: args.text, ...(await session.readScrollback(INPUT_SCREEN_LINES)) } + } + throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.') + } + + /** + * Runs a command inside a tmux session, in its own window. + * + * The user's panes are theirs; borrowing one would type over whatever they + * are doing. A dedicated window is still visible to them — they can switch + * to it and watch — while output and the exit status come back through + * files, so the result is structured even though shell integration cannot + * see through tmux. + */ + private async runInTmux( + terminal: TerminalSession, + session: string, + args: TerminalToolArgs + ): Promise { + const command = typeof args.command === 'string' ? args.command.trim() : '' + if (!command) throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.') + + const started = Date.now() + const handle = await startRun(session, command, terminal.currentCwd, terminal.env) + if ('error' in handle) throw new TerminalError('SPAWN_FAILED', handle.error) + + const waitMs = resolveWaitMs(args.waitSeconds) + const outcome = await awaitRun(handle, waitMs) + if (outcome.done) { + await closeRunWindow(handle, terminal.env) + handle.dispose() + } + + const { text, truncated } = elideOutput(outcome.output) + return { + command, + output: text, + status: outcome.done ? 'completed' : 'running', + exitCode: outcome.exitCode, + durationMs: Date.now() - started, + cwd: terminal.currentCwd, + terminalId: terminal.terminalId, + pane: handle.window, + truncated, + } + } + + private async run( + toolCallId: string, + session: TerminalSession, + args: TerminalToolArgs + ): Promise { + const command = typeof args.command === 'string' ? args.command.trim() : '' + if (!command) { + throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.') + } + if (!session.hasShellIntegration) { + await session.waitForShellIntegration(SHELL_INTEGRATION_TIMEOUT_MS) + } + if (!session.hasShellIntegration) { + throw new TerminalError( + 'NO_SHELL_INTEGRATION', + 'This shell did not load Sim shell integration, so command boundaries and exit codes cannot be determined. Ask the user to run the command themselves, or use a bash/zsh session.' + ) + } + if (session.isBusy) { + throw new TerminalError( + 'BUSY', + `"${session.foreground}" is still running in that terminal. Poll it with the read operation, stop it with kill, or open another terminal with new.` + ) + } + + return session.runCommand(command, toolCallId, resolveWaitMs(args.waitSeconds)) + } + + private spawn(cwd: string, cols: number, rows: number): TerminalSession { + const terminalId = String(this.nextId++) + try { + const session = TerminalSession.create({ + terminalId, + cwd, + cols, + rows, + callbacks: { + onData: (id, data) => this.sink?.data(id, data), + onState: () => { + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (active?.currentCwd) this.options.saveCwd?.(active.currentCwd) + this.emitTabs() + }, + onCommand: (event) => this.sink?.command(event), + onExit: (id) => { + // Not during shutdown: every shell is ending then, and replacing + // the last one would spawn a shell as the app is closing. + if (this.disposing) return + this.retire(id) + }, + }, + }) + this.sessions.set(terminalId, session) + this.activeId = terminalId + this.emitTabs() + return session + } catch (error) { + throw new TerminalError('SPAWN_FAILED', (error as Error).message) + } + } + + /** + * Resolves the terminal a tool call targets: the one it named, else the + * active one. Starting a shell on demand keeps a tool call from depending on + * the panel having finished mounting — the renderer opens the resource and + * dispatches the tool in the same tick, so the panel's own `start` usually + * lands after the tool arrives. + */ + private requireSession(args: TerminalToolArgs): TerminalSession { + const requested = typeof args.terminalId === 'string' ? args.terminalId : null + if (requested) { + const session = this.sessions.get(requested) + if (!session?.alive) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(requested)) + } + return session + } + + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (active?.alive) return active + + const spawned = this.spawn(this.startingCwd(), 80, 24) + if (!spawned.alive) { + throw new TerminalError('SPAWN_FAILED', 'Could not open a terminal on this machine.') + } + return spawned + } + + private requireId(args: TerminalToolArgs): string { + const terminalId = typeof args.terminalId === 'string' ? args.terminalId.trim() : '' + if (!terminalId) { + throw new TerminalError( + 'INVALID_REQUEST', + 'This operation needs a `terminalId` from the list operation.' + ) + } + return terminalId + } + + /** + * The remembered directory when it still exists, else home. A saved path can + * disappear between launches (a branch checkout, a deleted clone), and + * spawning into a missing cwd fails outright rather than degrading. + */ + private startingCwd(): string { + const remembered = this.options.loadCwd?.() + if (remembered) { + try { + if (statSync(remembered).isDirectory()) return remembered + } catch { + // Gone since last launch; fall through to home. + } + } + return homedir() + } + + /** + * Broadcasts the tab list only when it has actually changed. + * + * Session state is emitted on every shell-integration marker, and a shell + * repaints its prompt on each resize — so a divider drag would otherwise + * push a stream of identical tab lists at the renderer and re-render the + * panel for nothing. + */ + private emitTabs(): void { + const tabs = this.getTabs() + const serialized = JSON.stringify(tabs) + if (serialized === this.lastEmittedTabs) return + this.lastEmittedTabs = serialized + this.sink?.tabs(tabs) + } +} + +function unknownTerminal(terminalId: string): string { + return `No terminal with id ${terminalId}. Call terminal_list for the open ones.` +} + +/** Narrows an IPC payload to the tool-call shape without trusting the sender. */ +export function parseToolParams(value: unknown): Record { + return isRecordLike(value) ? value : {} +} diff --git a/apps/desktop/src/main/terminal/process-cwd.test.ts b/apps/desktop/src/main/terminal/process-cwd.test.ts new file mode 100644 index 0000000000..6f88f203b2 --- /dev/null +++ b/apps/desktop/src/main/terminal/process-cwd.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { parseLsofCwd, readProcessCwd } from '@/main/terminal/process-cwd' + +describe('parseLsofCwd', () => { + it('picks the path out of lsof field output', () => { + expect(parseLsofCwd('p123\nfcwd\nn/Users/me/project\n')).toBe('/Users/me/project') + }) + + it('keeps paths containing spaces intact', () => { + expect(parseLsofCwd('p1\nfcwd\nn/Users/me/My Code/app\n')).toBe('/Users/me/My Code/app') + }) + + it('returns null when no path field is present', () => { + expect(parseLsofCwd('')).toBeNull() + expect(parseLsofCwd('p123\nfcwd\n')).toBeNull() + // A field line that is not an absolute path is not a cwd. + expect(parseLsofCwd('p123\nnrelative/path\n')).toBeNull() + }) +}) + +describe('readProcessCwd', () => { + it('rejects invalid pids without touching the OS', async () => { + await expect(readProcessCwd(0)).resolves.toBeNull() + await expect(readProcessCwd(-1)).resolves.toBeNull() + await expect(readProcessCwd(Number.NaN)).resolves.toBeNull() + }) + + it('resolves this process to a real directory', async () => { + const cwd = await readProcessCwd(process.pid) + // Unsupported platforms report null rather than guessing. + if (process.platform !== 'darwin' && process.platform !== 'linux') { + expect(cwd).toBeNull() + return + } + expect(cwd?.startsWith('/')).toBe(true) + }) + + it('returns null for a pid that does not exist', async () => { + await expect(readProcessCwd(2_147_483_600)).resolves.toBeNull() + }) +}) diff --git a/apps/desktop/src/main/terminal/process-cwd.ts b/apps/desktop/src/main/terminal/process-cwd.ts new file mode 100644 index 0000000000..4389d4d9c8 --- /dev/null +++ b/apps/desktop/src/main/terminal/process-cwd.ts @@ -0,0 +1,91 @@ +/** + * Reads a running process's working directory from the OS. + * + * The shell announces `cd` through its shell-integration hooks, but only when + * those hooks are installed — a shell we cannot instrument (fish, nushell), a + * config that clobbers the prompt hooks, or a shell started before the hooks + * loaded leaves the reported directory frozen at spawn. Asking the OS for the + * shell's real cwd needs no cooperation from the shell at all, so it is the + * source of truth the tab title falls back on. This mirrors how VS Code + * resolves a terminal's cwd (`lsof` on macOS, procfs on Linux). + */ +import { spawn } from 'node:child_process' +import { readlink } from 'node:fs/promises' +import { createLogger } from '@sim/logger' + +const logger = createLogger('DesktopTerminalProcessCwd') + +/** A hung `lsof` must never wedge the poller; the call is best-effort. */ +const LOOKUP_TIMEOUT_MS = 2_000 + +/** + * The current working directory of `pid`, or null when it cannot be resolved + * (process gone, permission denied, unsupported platform). Never throws. + */ +export async function readProcessCwd(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) return null + if (process.platform === 'linux') return readProcCwd(pid) + if (process.platform === 'darwin') return readLsofCwd(pid) + return null +} + +/** Linux: the kernel exposes the cwd as a symlink, so no subprocess is needed. */ +async function readProcCwd(pid: number): Promise { + try { + return await readlink(`/proc/${pid}/cwd`) + } catch { + return null + } +} + +/** + * macOS has no procfs, so the cwd comes from `lsof`. The query is narrowed to + * one process and one descriptor (`-a -d cwd -p `) and asks for field + * output (`-Fn`), which prints `n` — far cheaper than an unfiltered + * `lsof` that would enumerate every open file on the system. + */ +function readLsofCwd(pid: number): Promise { + return new Promise((resolve) => { + let child: ReturnType + try { + child = spawn('lsof', ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch (error) { + logger.warn('Could not run lsof for terminal cwd', { error: (error as Error).message }) + resolve(null) + return + } + + let stdout = '' + let settled = false + const finish = (value: string | null) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + + const timer = setTimeout(() => { + child.kill('SIGKILL') + finish(null) + }, LOOKUP_TIMEOUT_MS) + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.on('error', () => finish(null)) + child.on('close', () => finish(parseLsofCwd(stdout))) + }) +} + +/** + * Picks the path out of `lsof -Fn` field output, whose lines are a one-letter + * field type followed by the value (`n/Users/me/project`). + */ +export function parseLsofCwd(stdout: string): string | null { + for (const line of stdout.split('\n')) { + if (line.startsWith('n/')) return line.slice(1) + } + return null +} diff --git a/apps/desktop/src/main/terminal/selection.test.ts b/apps/desktop/src/main/terminal/selection.test.ts new file mode 100644 index 0000000000..a791350d84 --- /dev/null +++ b/apps/desktop/src/main/terminal/selection.test.ts @@ -0,0 +1,136 @@ +import { sleep } from '@sim/utils/helpers' +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { findSelectedRow } from '@/main/terminal/session' + +const REVERSE = '\u001b[7m' +const RESET = '\u001b[0m' + +/** + * Writes to a real headless emulator and lets it settle, so these exercise the + * same buffer the agent reads rather than a hand-built fake. xterm parses + * asynchronously, hence the flush. + */ +async function screen(write: (term: Terminal) => void, rows = 8): Promise { + const term = new Terminal({ cols: 40, rows, allowProposedApi: true }) + write(term) + await sleep(30) + return term +} + +/** A row painted end to end, the way a TUI marks the current item. */ +function painted(text: string): string { + return `${REVERSE}${text.padEnd(40)}${RESET}` +} + +describe('findSelectedRow', () => { + it('finds the row a menu has highlighted', async () => { + const term = await screen((t) => { + t.write('Pick one:\r\n') + t.write(' alpha\r\n') + t.write(`${painted('> bravo')}\r\n`) + t.write(' charlie\r\n') + }) + + const row = findSelectedRow(term.buffer.active) + + expect(row).not.toBeNull() + expect( + term.buffer.active + .getLine(row as number) + ?.translateToString(true) + .trim() + ).toBe('> bravo') + }) + + it('marks nothing on an ordinary screen', async () => { + // Plain command output must never come back with a row labelled selected. + const term = await screen((t) => { + t.write('total 24\r\ndrwxr-xr-x src\r\n-rw-r--r-- package.json\r\n') + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) + + it('is not fooled by a few coloured words in output', async () => { + const term = await screen((t) => { + t.write(`\u001b[31mERROR\u001b[0m something went wrong\r\n`) + t.write(`\u001b[32mPASS\u001b[0m all good\r\n`) + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) + + it('prefers a menu row over a status bar painted at the bottom', async () => { + // tmux, vim and htop all paint a full-width bar at an edge; taking that as + // the selection would point the agent at the wrong row entirely. + const term = await screen((t) => { + t.write(' alpha\r\n') + t.write(`${painted('> bravo')}\r\n`) + t.write(' charlie\r\n') + t.write('\u001b[8;1H') + t.write(painted('[0] 0:zsh* "host" 12:00')) + }) + + const row = findSelectedRow(term.buffer.active) + + expect( + term.buffer.active + .getLine(row as number) + ?.translateToString(true) + .trim() + ).toBe('> bravo') + }) + + it('marks nothing rather than guessing between several painted rows', async () => { + // A wrong label sends the agent somewhere it did not intend to go, which + // is worse than it having to look for itself. + const term = await screen((t) => { + t.write(`${painted('one')}\r\n`) + t.write(`${painted('two')}\r\n`) + t.write(`${painted('three')}\r\n`) + t.write('plain\r\n') + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) +}) + +describe('replaying retained bytes into a fresh emulator', () => { + /** + * The screen is now rendered by replaying the retained byte stream into an + * emulator built for the read, rather than keeping one fed forever. Two + * things have to hold for that to be equivalent. + */ + it('has the whole stream parsed by the time the write callback fires', async () => { + // xterm parses asynchronously in 12ms slices, so reading the buffer right + // after write() would catch it half-parsed. The callback is the signal. + const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true }) + const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`) + + await new Promise((resolve) => term.write(`${lines.join('\r\n')}\r\n`, resolve)) + + const buffer = term.buffer.active + const rendered: string[] = [] + for (let row = 0; row < buffer.length; row++) { + const text = buffer.getLine(row)?.translateToString(true) ?? '' + if (text.trim()) rendered.push(text.trim()) + } + expect(rendered.at(-1)).toBe('line 199') + expect(rendered).toHaveLength(200) + }) + + it('reconstructs the final screen of a program that redraws in place', async () => { + // A TUI overwrites the same rows, so a replay must end on the last frame + // rather than showing every frame stacked up — the reason a raw byte dump + // cannot be handed to the model directly. + const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true }) + const frames = ['Loading ', 'Loading. ', 'Loading.. ', 'Done! '] + const stream = frames.map((frame) => `\u001b[H\u001b[2K${frame}`).join('') + + await new Promise((resolve) => term.write(stream, resolve)) + + const firstRow = term.buffer.active.getLine(0)?.translateToString(true).trim() + expect(firstRow).toBe('Done!') + }) +}) diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts new file mode 100644 index 0000000000..11b1c1b564 --- /dev/null +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -0,0 +1,451 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAX_TERMINALS } from '@sim/terminal-protocol' +import { describe, expect, it, vi } from 'vitest' +import { TerminalService } from '@/main/terminal' + +/** Stub sessions by terminal id, populated by the mock below. */ +const { stubSessions } = vi.hoisted(() => ({ + stubSessions: new Map(), +})) + +/** + * These cover the rules the service enforces around closing, without spawning + * real shells: node-pty is stubbed so a "session" is just an object the + * service tracks. The behaviour under test is which terminals exist afterwards + * and which one is active, not anything a pty does. + */ +vi.mock('@/main/terminal/session', async () => { + const actual = + await vi.importActual('@/main/terminal/session') + let nextPid = 1000 + return { + ...actual, + TerminalSession: { + create: ({ + terminalId, + cwd, + cols, + rows, + callbacks, + }: Record & { + terminalId: string + callbacks: { onExit(terminalId: string): void } + }) => { + const state = { cwd, disposed: false, busy: false } + const stub = { + setBusy: (busy: boolean) => { + state.busy = busy + }, + /** Stands in for the user running `exit` or pressing Ctrl-D. */ + exit: () => { + state.disposed = true + callbacks.onExit(terminalId) + }, + terminalId, + cols, + rows, + pid: nextPid++, + env: {}, + get alive() { + return !state.disposed + }, + get currentCwd() { + return state.cwd + }, + shell: 'zsh', + get foreground() { + return state.busy ? 'sleep 1' : null + }, + get isBusy() { + return state.busy + }, + hasShellIntegration: true, + /** Real sessions poll the pty here; a stub's cwd only ever changes on open. */ + refreshCwd: async () => {}, + dispose: () => { + state.disposed = true + }, + tabState: (active: boolean) => ({ + terminalId, + title: 'zsh', + cwd: state.cwd, + running: null, + interactive: false, + active, + }), + takeReplaySnapshot: () => '', + readScrollback: () => ({ + output: 'Do you want to proceed? [y/N]', + cwd: state.cwd, + terminalId, + truncated: false, + running: state.busy ? 'sleep 1' : null, + }), + } + stubSessions.set(terminalId, stub) + return stub + }, + }, + } +}) + +function service(): TerminalService { + return new TerminalService({ loadCwd: () => '/tmp', saveCwd: () => {} }) +} + +describe('closing terminals', () => { + it('closes one of several and activates a neighbour', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal() + const secondId = second.activeTerminalId + + const after = terminal.closeTerminal(secondId as string) + + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(secondId) + }) + + it('resets the last terminal instead of emptying the panel', () => { + // A panel whose resource IS a terminal must never be left with no shell: + // there is nothing to show and no way back from inside it. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const onlyId = started.activeTerminalId as string + + const after = terminal.closeTerminal(onlyId) + + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(onlyId) + }) + + it('refuses to close a terminal that does not exist', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + + expect(() => terminal.closeTerminal('no-such-terminal')).toThrow() + }) + + it('persists the active terminal cwd on dispose', () => { + // The saved cwd is what the next launch reopens into. It is otherwise only + // written when a session REPORTS a cwd change, and switching tabs is not + // one — so without an explicit save at teardown, quitting after a switch + // reopens in the directory of whichever tab last moved. + // Real directories: a remembered cwd that no longer exists falls back to + // home, which would make this assert nothing. + const projectA = mkdtempSync(join(tmpdir(), 'sim-term-a-')) + const projectB = mkdtempSync(join(tmpdir(), 'sim-term-b-')) + const saveCwd = vi.fn() + const terminal = new TerminalService({ loadCwd: () => projectA, saveCwd }) + terminal.start({ cols: 80, rows: 24 }) + const first = terminal.getTabs().activeTerminalId as string + terminal.openTerminal(projectB) + terminal.switchTerminal(first) + saveCwd.mockClear() + + terminal.dispose() + + expect(saveCwd).toHaveBeenCalledWith(projectA) + }) +}) + +type OwnerWindow = Parameters[0] + +/** + * A stand-in for one app window and the renderer inside it. + * + * Focus claims are bound to the renderer that made them, and every accelerator + * arrives from a window, so the pair travels together — `contents` makes the + * claim, `window` is what a Cmd-W from that window looks like. Two stubs model + * two windows, which is what the cross-window cases need. + */ +function rendererStub() { + const listeners = new Map void>() + const contents = { + isDestroyed: () => false, + once: (event: string, fn: (...args: unknown[]) => void) => listeners.set(event, fn), + on: (event: string, fn: (...args: unknown[]) => void) => listeners.set(event, fn), + removeListener: (event: string) => listeners.delete(event), + } as unknown as Parameters[1] + return { + contents, + /** The window hosting this renderer, for the accelerator-side calls. */ + window: { webContents: contents } as unknown as OwnerWindow, + /** Fire a main-process lifecycle event the renderer would not survive. */ + emit: (event: string, ...args: unknown[]) => listeners.get(event)?.(...args), + } +} + +describe('focus-gated shortcuts', () => { + it('ignores close and reopen while the panel is not focused', () => { + // Cmd-W and Cmd-Shift-T are global menu accelerators, so they arrive even + // when the user is working somewhere else entirely. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + }) + + it('closes the active terminal once the panel has focus', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('reopens a closed terminal, and has nothing to reopen before one closes', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + + const second = terminal.openTerminal() + terminal.closeTerminal(second.activeTerminalId as string) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(2) + }) + + it('does not remember a reset as a closed terminal to reopen', () => { + // Resetting the last terminal replaces it in place; offering to "reopen" + // it would just add a duplicate of the shell already on screen. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + terminal.closeTerminal(started.activeTerminalId as string) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('drops the focus claim when the renderer that made it reloads', () => { + // A reload never runs the renderer's cleanup, so nothing sends focus:false. + // The claim used to latch true forever, and the next Cmd-W destroyed a + // shell the user could no longer see. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(2) + }) + + it('keeps the claim across a same-document route change', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + }) + + it('answers only the window whose renderer holds the claim', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.closeFocusedTerminal(rendererStub().window)).toBe(false) + // And null — no window at all cannot be the window a claim answers for. + expect(terminal.closeFocusedTerminal(null)).toBe(false) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + }) + + it('does not consume the reopen history when already at the terminal cap', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + const closed = terminal.openTerminal('/alpha') + terminal.closeTerminal(closed.activeTerminalId as string) + while (terminal.getTabs().tabs.length < MAX_TERMINALS) terminal.openTerminal() + + // Refused, and without opening anything. + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(MAX_TERMINALS) + + // NOTE: that the '/alpha' entry SURVIVES the refusal is the actual point of + // the guard, and it is not observable from out here — every close prepends + // one history entry and frees exactly one slot, so a reopen can never walk + // back past the entries created by the closes that made room for it. The + // ordering in reopenClosedTerminal (check the cap, then shift) is what + // carries it; this test only pins the refusal itself. + terminal.closeTerminal(terminal.getTabs().activeTerminalId as string) + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true) + }) + + it('ignores a blur reported by a renderer that does not hold the claim', () => { + // Every renderer reports its own blur, so a second window switching away + // from its terminal sends `false` from a WebContents that never claimed. + // Honouring that erased the live claim of the window the user was typing + // in, and their next Cmd-W closed that window with its shells running. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const holder = rendererStub() + const other = rendererStub() + terminal.setPanelFocused(true, holder.contents) + + terminal.setPanelFocused(false, other.contents) + + expect(terminal.closeFocusedTerminal(holder.window)).toBe(true) + }) + + it('honours a blur from the renderer that does hold the claim', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const holder = rendererStub() + terminal.setPanelFocused(true, holder.contents) + + terminal.setPanelFocused(false, holder.contents) + + expect(terminal.closeFocusedTerminal(holder.window)).toBe(false) + }) + + it('drops the focus claim when the whole service is disposed', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + terminal.dispose() + // A fresh shell after teardown, so this asserts the CLAIM was dropped + // rather than passing on the "no active terminal" arm. + terminal.start({ cols: 80, rows: 24 }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + }) +}) + +describe('handing the terminal to the user', () => { + it('resolves when the blocked command finishes, without the user pressing anything', async () => { + // Answering the prompt in the panel is the common case: the command + // completes and the agent should just carry on. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const id = started.activeTerminalId as string + stubSessions.get(id)?.setBusy(true) + + const handoff = terminal.executeTool('call-1', 'handoff', { + terminalId: id, + reason: 'Confirm the install', + }) + setTimeout(() => stubSessions.get(id)?.setBusy(false), 20) + const response = await handoff + + expect(response.ok).toBe(true) + const result = response.result as { + handedBack: boolean + running: string | null + reason: string + } + expect(result.handedBack).toBe(false) + expect(result.running).toBeNull() + expect(result.reason).toBe('Confirm the install') + }) + + it('ignores a hand-back for a terminal that is not waiting', () => { + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + + expect(() => terminal.finishHandoff(started.activeTerminalId as string)).not.toThrow() + }) + + it('fails the handoff if the terminal is closed while it waits', async () => { + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const id = started.activeTerminalId as string + stubSessions.get(id)?.setBusy(true) + + const handoff = terminal.executeTool('call-1', 'handoff', { terminalId: id, reason: 'Sign in' }) + setTimeout(() => terminal.dispose(), 20) + const response = await handoff + + expect(response.ok).toBe(false) + expect(response.code).toBe('SESSION_CLOSED') + }) +}) + +describe('closing', () => { + it('closes the Sim terminal when no pane is named', async () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal() + + const response = await terminal.executeTool('call-1', 'close', { + terminalId: second.activeTerminalId as string, + }) + + expect(response.ok).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('refuses to close a pane in a terminal that has no tmux', async () => { + // Naming a pane in a plain shell is a mistake worth saying out loud, not + // silently closing the whole terminal instead. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + + const response = await terminal.executeTool('call-1', 'close', { + terminalId: started.activeTerminalId as string, + pane: 'main:1.0', + }) + + expect(response.ok).toBe(false) + expect(response.code).toBe('NO_TMUX') + expect(terminal.getTabs().tabs).toHaveLength(1) + }) +}) + +describe('a shell that ends by itself', () => { + it('replaces the only terminal instead of leaving a dead tab', () => { + const terminal = service() + const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) + const original = activeTerminalId as string + + stubSessions.get(original)?.exit() + + // The panel's whole content is the terminal, so an exited last shell used + // to sit there unusable — nothing to type into and no way to get it back. + const after = terminal.getTabs() + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(original) + expect(after.tabs[0]?.terminalId).toBe(after.activeTerminalId) + }) + + it('removes one of several and activates a neighbour', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal().activeTerminalId as string + + stubSessions.get(second)?.exit() + + const after = terminal.getTabs() + expect(after.tabs.map((tab) => tab.terminalId)).not.toContain(second) + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).toBe(after.tabs[0]?.terminalId) + }) +}) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts new file mode 100644 index 0000000000..d11886d307 --- /dev/null +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { stripAnsi, stripTerminalQueries, toInputChunks } from '@/main/terminal/session' + +describe('toInputChunks', () => { + it('separates Enter from the text so the text is actually submitted', () => { + // A full-screen program reads one stdin chunk as one input event: "hi\r" + // arriving together is read as text, lands in the composer, and never + // submits. Enter has to be its own chunk. + expect(toInputChunks('hi\n')).toEqual(['hi', '\r']) + }) + + it('sends Enter as carriage return, not linefeed', () => { + expect(toInputChunks('hi\n')[1]).toBe('\r') + }) + + it('collapses CRLF so one Enter is not sent twice', () => { + expect(toInputChunks('hi\r\n')).toEqual(['hi', '\r']) + }) + + it('treats a bare carriage return as one Enter', () => { + expect(toInputChunks('hi\r')).toEqual(['hi', '\r']) + }) + + it('breaks multi-line input into alternating text and Enter', () => { + expect(toInputChunks('one\ntwo\nthree')).toEqual(['one', '\r', 'two', '\r', 'three']) + }) + + it('keeps a blank line as an Enter rather than an empty write', () => { + expect(toInputChunks('\n')).toEqual(['\r']) + expect(toInputChunks('a\n\nb')).toEqual(['a', '\r', '\r', 'b']) + }) + + it('leaves text without line breaks as a single chunk', () => { + expect(toInputChunks('y')).toEqual(['y']) + }) + + it('sends nothing for empty text', () => { + expect(toInputChunks('')).toEqual([]) + }) +}) + +describe('stripAnsi', () => { + it('removes colour and cursor sequences so the model reads plain text', () => { + expect(stripAnsi('\u001b[31mred\u001b[0m')).toBe('red') + expect(stripAnsi('a\u001b[2Kb')).toBe('ab') + }) + + it('removes OSC sequences including their terminator', () => { + expect(stripAnsi('before\u001b]0;window title\u0007after')).toBe('beforeafter') + }) + + it('keeps newlines and tabs, which carry real structure', () => { + expect(stripAnsi('one\ntwo\tthree')).toBe('one\ntwo\tthree') + }) +}) + +describe('stripTerminalQueries', () => { + // Replaying recorded bytes into a live emulator makes it answer every query + // in the recording, and those answers are written to the pty as keystrokes. + // With the asker long gone they pile up on the shell prompt as junk. + it('removes device attribute queries and their replies', () => { + expect(stripTerminalQueries('a\u001b[cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[>cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[?1;2cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[>0;276;0cb')).toBe('ab') + }) + + it('removes device status reports', () => { + expect(stripTerminalQueries('a\u001b[6nb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[?6nb')).toBe('ab') + }) + + it('removes the OSC colour queries that echo as rgb: junk', () => { + expect(stripTerminalQueries('a\u001b]10;?\u0007b')).toBe('ab') + expect(stripTerminalQueries('a\u001b]11;?\u001b\\b')).toBe('ab') + }) + + it('removes XTVERSION without touching cursor style', () => { + expect(stripTerminalQueries('a\u001b[>0qb')).toBe('ab') + // `CSI q` sets the cursor shape and must survive, so the `>` is required. + expect(stripTerminalQueries('a\u001b[2 qb')).toBe('a\u001b[2 qb') + }) + + it('removes the capability query', () => { + expect(stripTerminalQueries('a\u001bP+q544e\u001b\\b')).toBe('ab') + }) + + it('leaves colour and formatting in the recording intact', () => { + expect(stripTerminalQueries('\u001b[31mred\u001b[0m\r\n')).toBe('\u001b[31mred\u001b[0m\r\n') + }) + + it('leaves text with no queries untouched', () => { + expect(stripTerminalQueries('plain output\n')).toBe('plain output\n') + }) +}) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts new file mode 100644 index 0000000000..62fecc6f59 --- /dev/null +++ b/apps/desktop/src/main/terminal/session.ts @@ -0,0 +1,990 @@ +/** + * The PTY session behind the agent terminal. + * + * One `node-pty` process, shared by the user and the agent, so `cd`, exported + * variables, and scrollback are common to both. The session owns output + * batching, the scrollback ring buffer, and the command lifecycle derived from + * shell-integration markers. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { IPty } from '@lydell/node-pty' +import { spawn } from '@lydell/node-pty' +import { createLogger } from '@sim/logger' +import { + MAX_CAPTURE_CHARS, + MAX_SCROLLBACK_CHARS, + MAX_TOOL_OUTPUT_CHARS, + PROMPT_IDLE_MS, + type TerminalCommandEvent, + type TerminalControlKey, + type TerminalReadResult, + type TerminalRunResult, + type TerminalTabState, +} from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' +import { + Terminal as HeadlessTerminal, + type IBuffer, + type IBufferCell, + type IBufferLine, +} from '@xterm/headless' +import { readProcessCwd } from '@/main/terminal/process-cwd' +import { + buildShellLaunch, + createNonce, + detectShell, + ShellIntegrationParser, +} from '@/main/terminal/shell-integration' + +const logger = createLogger('DesktopTerminalSession') + +/** + * Output is batched rather than forwarded per chunk. A command like `yes` or + * `cat` on a large file emits far faster than the renderer can paint, and one + * IPC message per chunk locks up the UI process. + */ +const FLUSH_INTERVAL_MS = 8 + +/** + * When this much unflushed output has accumulated, the pty is paused until the + * next flush. Without it a runaway process grows the pending buffer without + * bound between ticks. + */ +const PAUSE_HIGH_WATER_CHARS = 512 * 1024 + +/** + * How far a retained buffer may overshoot its limit before being trimmed. + * + * Trimming to the exact limit means copying the entire buffer on every chunk + * once it is full — a quarter of a megabyte per chunk, hundreds of times a + * second under heavy output, on the process that also feeds the renderer. + * That copying is what makes the panel stutter while something is printing. + * Letting the buffer overshoot and trimming in one go amortizes it to a single + * copy per slack-sized batch. + */ +const TRIM_SLACK_CHARS = 64_000 + +/** Control keys mapped to the bytes a terminal actually sends. */ +const CONTROL_KEY_BYTES: Record = { + 'ctrl-c': '\u0003', + 'ctrl-d': '\u0004', + 'ctrl-z': '\u001a', + enter: '\r', + up: '\u001b[A', + down: '\u001b[B', + right: '\u001b[C', + left: '\u001b[D', + escape: '\u001b', + tab: '\t', +} + +/** Enter, as a keyboard sends it: carriage return, never linefeed. */ +const ENTER = '\r' + +/** + * Splits model-authored text into the separate writes a keyboard would produce. + * + * Enter has to be its own write. A full-screen program reads stdin in chunks + * and treats one chunk as one input event, so "message\r" written in a single + * call is read as text that happens to end in a carriage return: it lands in + * the composer and is never submitted, because the program's Enter handler + * only fires for a chunk that *is* Enter. Returning the breaks as their own + * chunks lets the caller space them out in time, which is what forces the + * program to see two events instead of one. + */ +export function toInputChunks(text: string): string[] { + const chunks: string[] = [] + const lines = text.replace(/\r\n|\r/g, '\n').split('\n') + lines.forEach((line, index) => { + if (line) chunks.push(line) + if (index < lines.length - 1) chunks.push(ENTER) + }) + return chunks +} + +/** + * Sequences that make a terminal answer back, removed from replayed history. + * + * A repaint feeds recorded bytes to a live emulator, and xterm cannot tell + * them from a program talking to it now: it dutifully replies to every query + * in the recording, and those replies are written to the pty as if the user + * had typed them. The original asker is long gone, so they land on the shell + * prompt as junk — `1;2c0;276;0c10;rgb:1f1f/...` sitting in front of the + * cursor. Answering history is meaningless, so history is stripped of the + * questions. Live output is untouched: a program waiting on a reply must get + * one. + * + * Covers device attributes (CSI c), device status reports (CSI n), the OSC + * colour queries, and XTVERSION. `CSI > q` is matched with its `>` so cursor + * style (`CSI q`) survives. + */ +const TERMINAL_QUERIES = [ + /\u001b\[[?>=]?[0-9;]*c/g, + /\u001b\[\??[0-9;]*n/g, + /\u001b\][0-9]+;\?(?:\u0007|\u001b\\)/g, + /\u001b\[>[0-9;]*q/g, + /\u001bP\+q[^\u001b]*\u001b\\/g, +] + +export function stripTerminalQueries(value: string): string { + return TERMINAL_QUERIES.reduce((text, pattern) => text.replace(pattern, ''), value) +} + +/** Marks the row a menu has selected, for a reader that cannot see colour. */ +const SELECTED_ROW_PREFIX = '[selected] ' + +/** + * Share of a row's cells that must be painted for it to count as highlighted. + * High enough that a few coloured words in ordinary output do not qualify — + * a selected row is painted end to end. + */ +const PAINTED_ROW_RATIO = 0.6 + +/** + * The row a menu has selected, or null when nothing looks selected. + * + * A TUI marks its current row by painting it — reverse video, or a background + * colour — and plain text throws all of that away, so an agent reading the + * screen cannot tell where it is before it starts pressing arrows. + * + * Painted rows are not always selections, though: a tmux status bar, a vim + * status line and an htop header are all painted end to end. Those live at the + * edges of the screen, so edge rows are set aside when something else is + * painted too. If that still leaves more than one, the cursor breaks the tie, + * and failing that nothing is marked — a missing label is recoverable, a label + * on the wrong row sends the agent somewhere it did not intend to go. + */ +export function findSelectedRow(buffer: IBuffer): number | null { + const painted: number[] = [] + const cell = buffer.getNullCell() + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row) + if (line && isPaintedRow(line, cell)) painted.push(row) + } + if (painted.length === 0) return null + if (painted.length === 1) return painted[0] + + const top = buffer.baseY + const bottom = buffer.baseY + buffer.viewportY + buffer.length - 1 + const inner = painted.filter((row) => row !== top && row !== bottom && row !== buffer.length - 1) + if (inner.length === 1) return inner[0] + + const cursorRow = buffer.baseY + buffer.cursorY + const candidates = inner.length > 0 ? inner : painted + return candidates.includes(cursorRow) ? cursorRow : null +} + +/** Whether a row is drawn highlighted: reverse video, or a filled background. */ +function isPaintedRow(line: IBufferLine, cell: IBufferCell): boolean { + let painted = 0 + for (let column = 0; column < line.length; column++) { + line.getCell(column, cell) + if (cell.isInverse() || !cell.isBgDefault()) painted++ + } + return line.length > 0 && painted / line.length >= PAINTED_ROW_RATIO +} + +/** Strips CSI/OSC sequences so the model reads text rather than escape codes. */ +export function stripAnsi(value: string): string { + return value + .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '') + .replace(/\u001b[[\]][0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\u001b[@-Z\\-_]/g, '') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '') +} + +/** + * Keeps the head and tail of oversized output. A long build log's useful parts + * are the start and the failure at the end; the middle is filler. + */ +export function elide(value: string, limit: number): { text: string; truncated: boolean } { + if (value.length <= limit) return { text: value, truncated: false } + const half = Math.floor(limit / 2) + const omitted = value.length - half * 2 + return { + text: `${value.slice(0, half)}\n\n[... ${omitted} characters omitted ...]\n\n${value.slice(-half)}`, + truncated: true, + } +} + +/** + * A program that switches to the alternate screen buffer has taken the whole + * terminal: an editor, a pager, or a coding agent. Its output is a stream of + * repaints rather than text, and it will not exit on its own. + */ +const ALT_SCREEN_ENTER = '\u001b[?1049h' +/** Restoring the normal screen: the full-screen program has quit. */ +const ALT_SCREEN_EXIT = '\u001b[?1049l' + +/** + * Scrollback the on-demand emulator keeps while rendering a read. + * + * Only ever needs to cover what a read returns — a couple of hundred lines by + * default — so this is generous rather than the 5,000 the old always-on + * emulator held per terminal, which at ~12 bytes a cell was megabytes of + * buffer per shell for rows nothing ever asked for. + */ +const EMULATOR_SCROLLBACK_LINES = 1_000 + +/** How often a running command is checked for having stopped mid-line. */ +const PROMPT_POLL_INTERVAL_MS = 500 + +/** + * Gap held between the writes of one typed message. Long enough that the + * program gets a separate stdin read per chunk rather than coalescing them + * into a single paste-like event, which is the whole point of splitting them. + */ +const KEYSTROKE_GAP_MS = 150 + +/** + * Cap on waiting for a program to finish redrawing after each chunk. A TUI + * with an animating spinner never goes quiet, so the settle wait needs a + * ceiling or typing would stall on it. + */ +const KEYSTROKE_SETTLE_MAX_MS = 1_000 + +interface PendingCommand { + command: string + toolCallId: string + startedAt: number + /** Last time the command produced output; the basis for prompt detection. */ + lastActivityAt: number + promptWatchdog: NodeJS.Timeout + /** Capped head of the captured output. */ + output: string + /** Rolling tail kept once {@link MAX_CAPTURE_CHARS} is exceeded. */ + overflow: string + capturing: boolean + timer: NodeJS.Timeout + resolve(result: TerminalRunResult): void +} + +export interface TerminalSessionCallbacks { + onData(terminalId: string, data: string): void + onState(): void + onCommand(event: TerminalCommandEvent): void + /** + * The shell ended by itself — the user ran `exit`, or pressed Ctrl-D at an + * empty prompt. Distinct from the service disposing the session, which is + * already-known and never reaches here. + */ + onExit(terminalId: string): void +} + +export interface TerminalSessionOptions { + terminalId: string + cwd: string + cols: number + rows: number + callbacks: TerminalSessionCallbacks +} + +export class TerminalSession { + private readonly pty: IPty + /** The environment the shell was spawned with, for tmux to reuse. */ + private readonly shellEnv: NodeJS.ProcessEnv + private readonly parser: ShellIntegrationParser + private readonly integrationDir: string + private readonly callbacks: TerminalSessionCallbacks + private readonly shellName: string + readonly terminalId: string + + /** + * Raw bytes as the shell produced them, ANSI intact. + * + * Kept rather than a rendered screen because rendering needs an emulator, + * and these replay into one on demand — for a panel repainting, or for + * answering a read. Handing the model this stream directly would not do: + * a full-screen program redraws constantly, so stripping escape codes from + * it leaves dozens of overlapping copies of the same screen rather than what + * is actually on it. + */ + private scrollback = '' + private pendingOutput = '' + /** When the program last painted anything; the basis for the settle wait. */ + private lastOutputAt = 0 + private flushTimer: NodeJS.Timeout | null = null + private paused = false + private disposed = false + + private cwd: string + private columns: number + private lines: number + private shellIntegration = false + private altScreen = false + private foregroundCommand: string | null = null + private pendingCommand: PendingCommand | null = null + /** Command line reported by the shell but not yet bracketed by output-start. */ + private announcedCommand: string | null = null + private integrationWaiters: Array<() => void> = [] + + private constructor( + options: TerminalSessionOptions, + pty: IPty, + integrationDir: string, + nonce: string, + shellName: string, + shellEnv: NodeJS.ProcessEnv + ) { + this.callbacks = options.callbacks + this.terminalId = options.terminalId + this.cwd = options.cwd + this.columns = options.cols + this.lines = options.rows + this.pty = pty + this.shellEnv = shellEnv + this.integrationDir = integrationDir + this.shellName = shellName + this.parser = new ShellIntegrationParser(nonce) + + this.pty.onData((chunk) => this.handleData(chunk)) + this.pty.onExit(() => this.handleExit()) + } + + static create(options: TerminalSessionOptions): TerminalSession { + const shellPath = process.env.SHELL || '/bin/zsh' + const shell = detectShell(shellPath) + const nonce = createNonce() + const integrationDir = mkdtempSync(join(tmpdir(), 'sim-terminal-')) + + // ELECTRON_RUN_AS_NODE is stripped by omission rather than assignment: the + // child environment is passed through verbatim, so leaving the key present + // with an undefined value hands the shell the literal string "undefined" + // and it boots as Node instead of a shell. + const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env as Record + + // A shell we cannot instrument still gives the user a working terminal; + // the agent is refused separately via NO_SHELL_INTEGRATION. + const launch = shell + ? buildShellLaunch(shell, integrationDir, nonce, env) + : { args: ['-l'], env: {} } + + const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } + const pty = spawn(shellPath, launch.args, { + name: 'xterm-256color', + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: shellEnv, + }) + + logger.info('Started terminal session', { shell: shellPath, instrumented: shell !== null }) + return new TerminalSession(options, pty, integrationDir, nonce, shell ?? shellPath, shellEnv) + } + + /** + * The shell's process id. tmux clients launched from this shell descend + * from it, which is how a terminal is matched to its tmux session. + */ + get pid(): number { + return this.pty.pid + } + + /** + * Reconciles the tracked cwd with the shell's real one, read from the OS. + * + * The shell-integration hooks report `cd` instantly when they are installed, + * but they cannot be relied on alone: a shell we cannot instrument, a config + * that overrides the prompt hooks, or a session whose hooks never loaded + * would otherwise leave the tab labelled with the directory the shell + * started in — the user's home, whose basename is their username. Asking the + * OS needs no cooperation from the shell, so it is what makes the label + * correct in every case. + */ + async refreshCwd(): Promise { + if (this.disposed) return + const cwd = await readProcessCwd(this.pty.pid) + if (this.disposed || !cwd || cwd === this.cwd) return + this.cwd = cwd + this.emitState() + } + + /** + * The shell's environment. tmux invocations made on this terminal's behalf + * reuse it so they reach the same server socket the user's client is on. + */ + get env(): NodeJS.ProcessEnv { + return this.shellEnv + } + + get alive(): boolean { + return !this.disposed + } + + get cols(): number { + return this.columns + } + + get rows(): number { + return this.lines + } + + get currentCwd(): string | null { + return this.cwd + } + + get shell(): string | null { + return this.shellName + } + + get foreground(): string | null { + return this.foregroundCommand + } + + /** + * Tab-strip view of this terminal. The label prefers the running command, + * which is what the user is actually waiting on, and falls back to the + * directory name — the two things that distinguish one terminal from another + * at a glance. + */ + tabState(active: boolean): TerminalTabState { + const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null + return { + terminalId: this.terminalId, + // The directory, always: whether to show the running command instead is + // a presentation choice, and the panel makes it (it holds a label back + // until a command has run long enough to be worth naming). Reporting the + // command here would also mean gating `running` to match, and that is + // data the agent reads — it must stay true the instant a command starts. + title: directory || 'Terminal', + cwd: this.cwd, + // Never an empty string. A shell can start a command without announcing + // its text, and "" would read as "nothing is running" to everything + // downstream while the terminal is in fact busy — the agent would treat + // it as free and the tab would render a blank label. + running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', + interactive: this.altScreen, + active, + } + } + + get isBusy(): boolean { + return this.foregroundCommand !== null + } + + get hasShellIntegration(): boolean { + return this.shellIntegration + } + + /** + * Resolves once the shell has emitted its first integration marker, or when + * `timeoutMs` elapses. A shell takes a few hundred milliseconds to run its + * startup files, so a command issued immediately after spawn would otherwise + * be refused for having no integration when it is merely early. + */ + waitForShellIntegration(timeoutMs: number): Promise { + if (this.shellIntegration) return Promise.resolve(true) + if (this.disposed) return Promise.resolve(false) + return new Promise((resolve) => { + const notify = () => { + clearTimeout(timer) + resolve(this.shellIntegration) + } + const timer = setTimeout(() => { + this.integrationWaiters = this.integrationWaiters.filter((entry) => entry !== notify) + resolve(this.shellIntegration) + }, timeoutMs) + this.integrationWaiters.push(notify) + }) + } + + write(data: string): void { + if (this.disposed) return + this.pty.write(data) + } + + sendKey(key: TerminalControlKey): void { + this.write(CONTROL_KEY_BYTES[key]) + } + + /** + * Presses keys in order, spaced the way typed text is. + * + * Same reasoning as {@link type}: a program reads one stdin chunk as one + * event, so three arrows written together can arrive as a single keystroke. + * The pause also lets a menu redraw between presses, which is what makes a + * batch land on the row a person pressing the same keys would reach. + */ + async pressKeys(keys: TerminalControlKey[]): Promise { + for (let index = 0; index < keys.length; index += 1) { + if (this.disposed) return + if (index > 0) await this.settleBetweenKeystrokes() + this.sendKey(keys[index]) + } + } + + /** + * Types text the way a person would: each line and each Enter as its own + * write, spaced out so the program reads them as separate keystrokes and + * gets a chance to redraw between them. See {@link toInputChunks} for why + * sending it all at once leaves the text unsubmitted. + */ + async type(text: string): Promise { + const chunks = toInputChunks(text) + for (let index = 0; index < chunks.length; index += 1) { + if (this.disposed) return + if (index > 0) await this.settleBetweenKeystrokes() + this.write(chunks[index]) + } + } + + /** Holds a gap, then lets any resulting redraw finish before the next write. */ + private async settleBetweenKeystrokes(): Promise { + await sleep(KEYSTROKE_GAP_MS) + const deadline = Date.now() + KEYSTROKE_SETTLE_MAX_MS + while (!this.disposed) { + const quietFor = Date.now() - this.lastOutputAt + const remaining = Math.min(KEYSTROKE_GAP_MS - quietFor, deadline - Date.now()) + if (remaining <= 0) return + await sleep(remaining) + } + } + + resize(cols: number, rows: number): void { + if (this.disposed || cols <= 0 || rows <= 0) return + this.columns = cols + this.lines = rows + try { + this.pty.resize(cols, rows) + } catch (error) { + logger.warn('Failed to resize pty', { error: (error as Error).message }) + } + this.emitState() + } + + kill(signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void { + if (this.disposed) return + // SIGINT is delivered as a keystroke so the foreground process group gets + // it the way Ctrl-C would, rather than only the shell. + if (signal === 'SIGINT') { + this.write('\u0003') + return + } + try { + this.pty.kill(signal) + } catch (error) { + logger.warn('Failed to signal pty', { signal, error: (error as Error).message }) + } + } + + /** + * Writes a command into the shell so it echoes and streams exactly as if the + * user had typed it, then waits for it to finish. + * + * The wait is deliberately short. Anything still going when it elapses comes + * back as `running` with the output so far, rather than blocking the turn: + * the agent polls it from there, which keeps the user seeing progress and + * lets the agent react to what appears. + */ + runCommand(command: string, toolCallId: string, waitMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => this.resolveStillRunning(false), waitMs) + const promptWatchdog = setInterval(() => this.checkForPrompt(), PROMPT_POLL_INTERVAL_MS) + this.pendingCommand = { + command, + toolCallId, + startedAt: Date.now(), + lastActivityAt: Date.now(), + promptWatchdog, + output: '', + overflow: '', + capturing: false, + timer, + resolve, + } + this.foregroundCommand = command + this.emitState() + this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId }) + + // Ctrl-U clears anything half-typed at the prompt so the agent's command + // is not appended to a partial line. Safe because a command only starts + // when nothing holds the foreground. + this.write('\u0015') + this.write(`${command}\r`) + }) + } + + /** + * Raw scrollback for repainting a freshly mounted xterm, with the pending + * batch consumed rather than flushed: those bytes are already part of the + * scrollback, so delivering them again after the repaint would duplicate + * them on screen. + */ + takeReplaySnapshot(): string { + this.pendingOutput = '' + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + return stripTerminalQueries(this.scrollback) + } + + /** + * Renders the terminal's screen, building an emulator on demand. + * + * The bytes are already retained raw, so the screen can be reconstructed by + * replaying them — the same thing the panel does when a view repaints. The + * alternative, keeping a live emulator fed with every byte forever, meant a + * full VT parser per terminal running on the Electron main process for the + * life of the shell, and xterm parses in self-rescheduling 12ms slices + * because it was written for a renderer with one terminal to a thread. With + * several terminals producing output that is most of the event loop that + * every window's IPC also has to get through, which is why unrelated UI went + * sluggish. Parsing on demand moves that cost from always to per read. + */ + async readScrollback(lines: number): Promise { + const emulator = new HeadlessTerminal({ + cols: this.columns, + rows: this.lines, + scrollback: EMULATOR_SCROLLBACK_LINES, + allowProposedApi: true, + }) + try { + // xterm parses asynchronously in slices, so the buffer is only complete + // once the write callback fires. + await new Promise((resolve) => emulator.write(this.scrollback, resolve)) + return this.renderScreen(emulator.buffer.active, lines) + } finally { + emulator.dispose() + } + } + + private renderScreen(buffer: IBuffer, lines: number): TerminalReadResult { + const selected = findSelectedRow(buffer) + // `buffer.active` is the alternate buffer while a full-screen program is + // up and the normal one otherwise, so this reads correctly either way. + const rowAt = (row: number) => buffer.getLine(row)?.translateToString(true) ?? '' + + // The buffer is always a full screen tall, so its bottom rows are blank + // padding below the content. Anchor to the last row with anything on it — + // counting back from the raw bottom would return nothing but blanks. + let lastRow = buffer.length - 1 + while (lastRow >= 0 && rowAt(lastRow).trim() === '') lastRow-- + if (lastRow < 0) { + return { + output: '', + cwd: this.cwd, + terminalId: this.terminalId, + truncated: false, + running: this.foregroundCommand, + } + } + + const wanted = lines > 0 ? lines : lastRow + 1 + const firstRow = Math.max(0, lastRow - wanted + 1) + const rendered: string[] = [] + for (let row = firstRow; row <= lastRow; row++) { + rendered.push(row === selected ? `${SELECTED_ROW_PREFIX}${rowAt(row)}` : rowAt(row)) + } + + const { text, truncated } = elide(rendered.join('\n'), MAX_TOOL_OUTPUT_CHARS) + return { + output: text, + cwd: this.cwd, + terminalId: this.terminalId, + truncated: truncated || firstRow > 0, + running: this.foregroundCommand, + } + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = null + this.finishCommand(null) + try { + this.pty.kill() + } catch { + // Already gone. + } + this.cleanupIntegrationDir() + this.emitState() + } + + /** + * Removes the generated startup files. Never throws: the shell writes into + * this directory as it exits (zsh drops a `.zcompdump` there, since it is + * also ZDOTDIR), which races the delete and raises ENOTEMPTY. Teardown runs + * on app quit, so letting that escape would break shutdown over a temp file + * the OS reclaims anyway. One deferred retry catches the common race. + */ + private cleanupIntegrationDir(retry = true): void { + try { + rmSync(this.integrationDir, { recursive: true, force: true }) + } catch { + if (!retry) return + setTimeout(() => this.cleanupIntegrationDir(false), 2_000).unref() + } + } + + private handleData(chunk: string): void { + const { text, markers } = this.parser.parse(chunk) + + for (const marker of markers) { + this.applyMarker(marker) + } + + if (text) { + this.lastOutputAt = Date.now() + this.trackAltScreen(text) + this.scrollback += text + if (this.scrollback.length > MAX_SCROLLBACK_CHARS + TRIM_SLACK_CHARS) { + this.scrollback = this.scrollback.slice(-MAX_SCROLLBACK_CHARS) + } + if (this.pendingCommand?.capturing) { + this.pendingCommand.lastActivityAt = Date.now() + this.captureOutput(this.pendingCommand, text) + if (text.includes(ALT_SCREEN_ENTER)) { + this.resolveInteractiveCommand() + } + } + this.pendingOutput += text + this.scheduleFlush() + } + } + + private applyMarker( + marker: ReturnType['markers'][number] + ): void { + switch (marker.kind) { + case 'prompt-start': + if (!this.shellIntegration) { + this.shellIntegration = true + this.emitState() + const waiters = this.integrationWaiters + this.integrationWaiters = [] + for (const notify of waiters) notify() + } + break + case 'command-line': + this.announcedCommand = marker.command + break + case 'output-start': { + if (this.pendingCommand) { + this.pendingCommand.capturing = true + break + } + // No agent command in flight, so the user typed this one. + const command = this.announcedCommand ?? '' + this.foregroundCommand = command + this.emitState() + this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command }) + break + } + case 'output-end': + this.finishCommand(marker.exitCode) + break + case 'cwd': + if (marker.cwd && marker.cwd !== this.cwd) { + this.cwd = marker.cwd + this.emitState() + } + break + } + } + + /** + * Follows the alternate-screen switches so the tab can tell an open + * application from a command that is merely slow. Entering is what makes a + * program full-screen; leaving means it has quit and given the shell back. + */ + private trackAltScreen(text: string): void { + const entered = text.lastIndexOf(ALT_SCREEN_ENTER) + const exited = text.lastIndexOf(ALT_SCREEN_EXIT) + if (entered === -1 && exited === -1) return + const next = entered > exited + if (next === this.altScreen) return + this.altScreen = next + this.emitState() + } + + /** + * Buffers captured output with a fixed ceiling: the head is kept intact and + * everything past the cap collapses into a rolling tail, so a program + * repainting the screen thousands of times cannot exhaust memory. + */ + private captureOutput(pending: PendingCommand, text: string): void { + if (pending.output.length < MAX_CAPTURE_CHARS) { + pending.output += text + return + } + pending.overflow += text + if (pending.overflow.length > MAX_CAPTURE_CHARS + TRIM_SLACK_CHARS) { + pending.overflow = pending.overflow.slice(-MAX_CAPTURE_CHARS) + } + } + + /** + * Answers a command that has taken over the screen, without waiting for it to + * finish — it will not. The foreground stays held because the program really + * is still running: the user can drive it in the panel, and the agent can + * stop it with terminal_kill. The captured redraws are discarded rather than + * returned, since they are frames rather than output. + */ + private resolveInteractiveCommand(): void { + this.detachStillRunning((pending) => ({ + command: pending.command, + output: + 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', + status: 'interactive', + exitCode: null, + durationMs: Date.now() - pending.startedAt, + cwd: this.cwd, + terminalId: this.terminalId, + truncated: false, + })) + } + + /** + * Hands back a command that is still going when the wait window elapses, + * with whatever it has printed so far. Not a failure: the agent polls from + * here with wait + terminal_read, which keeps the user seeing progress and + * lets the agent notice a prompt or an error as it appears. + */ + /** + * Hands a command back early when it has stopped mid-line and gone quiet — + * the shape of something sitting on a prompt. Output that ends with a + * newline, or that is still arriving, is a command doing work and is left to + * run out the full wait window. + */ + private checkForPrompt(): void { + const pending = this.pendingCommand + if (!pending?.capturing) return + if (Date.now() - pending.lastActivityAt < PROMPT_IDLE_MS) return + const captured = this.capturedText(pending) + if (!captured.trim() || /[\r\n]$/.test(captured)) return + this.resolveStillRunning(true) + } + + private resolveStillRunning(awaitingInput: boolean): void { + this.detachStillRunning((pending) => { + const { text, truncated } = elide( + stripAnsi(this.capturedText(pending)).trim(), + MAX_TOOL_OUTPUT_CHARS + ) + return { + command: pending.command, + output: text, + status: 'running', + exitCode: null, + durationMs: Date.now() - pending.startedAt, + cwd: this.cwd, + terminalId: this.terminalId, + truncated, + ...(awaitingInput ? { awaitingInput: true } : {}), + } + }) + } + + /** + * Resolves the pending promise while LEAVING the foreground held. In both + * non-completion cases the command really is still running, so releasing the + * slot would let the next terminal_run interleave with it instead of + * correctly reporting BUSY. + */ + private detachStillRunning(build: (pending: PendingCommand) => TerminalRunResult): void { + const pending = this.pendingCommand + if (!pending) return + clearTimeout(pending.timer) + clearInterval(pending.promptWatchdog) + this.pendingCommand = null + pending.resolve(build(pending)) + } + + private capturedText(pending: PendingCommand): string { + return pending.overflow + ? `${pending.output}\n\n[... output truncated ...]\n\n${pending.overflow}` + : pending.output + } + + private finishCommand(exitCode: number | null): void { + const pending = this.pendingCommand + const command = this.foregroundCommand + + if (pending) { + clearTimeout(pending.timer) + clearInterval(pending.promptWatchdog) + this.pendingCommand = null + const { text, truncated } = elide( + stripAnsi(this.capturedText(pending)).trim(), + MAX_TOOL_OUTPUT_CHARS + ) + const durationMs = Date.now() - pending.startedAt + pending.resolve({ + command: pending.command, + output: text, + status: 'completed', + exitCode, + durationMs, + cwd: this.cwd, + terminalId: this.terminalId, + truncated, + }) + this.callbacks.onCommand({ + terminalId: this.terminalId, + phase: 'end', + command: pending.command, + toolCallId: pending.toolCallId, + ...(exitCode === null ? {} : { exitCode }), + durationMs, + }) + } else if (command !== null) { + this.callbacks.onCommand({ + terminalId: this.terminalId, + phase: 'end', + command, + ...(exitCode === null ? {} : { exitCode }), + }) + } + + this.foregroundCommand = null + this.announcedCommand = null + this.altScreen = false + this.emitState() + } + + private scheduleFlush(): void { + if (this.pendingOutput.length >= PAUSE_HIGH_WATER_CHARS && !this.paused) { + this.paused = true + this.pty.pause() + } + if (this.flushTimer) return + this.flushTimer = setTimeout(() => { + this.flushTimer = null + this.flush() + }, FLUSH_INTERVAL_MS) + } + + private flush(): void { + if (!this.pendingOutput) return + const data = this.pendingOutput + this.pendingOutput = '' + this.callbacks.onData(this.terminalId, data) + if (this.paused) { + this.paused = false + this.pty.resume() + } + } + + private handleExit(): void { + if (this.disposed) return + this.disposed = true + const waiters = this.integrationWaiters + this.integrationWaiters = [] + for (const notify of waiters) notify() + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = null + this.flush() + this.finishCommand(null) + this.cleanupIntegrationDir() + this.emitState() + this.callbacks.onExit(this.terminalId) + } + + private emitState(): void { + this.callbacks.onState() + } +} diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts new file mode 100644 index 0000000000..ab47bf5a8a --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { detectShell, ShellIntegrationParser } from '@/main/terminal/shell-integration' + +const NONCE = 'testnonce' + +function osc(body: string): string { + return `\u001b]633;${body}\u0007` +} + +describe('ShellIntegrationParser', () => { + it('extracts command lifecycle markers and strips them from the display stream', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text, markers } = parser.parse( + `${osc(`E;npm test;${NONCE}`)}${osc(`C;${NONCE}`)}output here${osc(`D;0;${NONCE}`)}` + ) + + expect(text).toBe('output here') + expect(markers).toEqual([ + { kind: 'command-line', command: 'npm test' }, + { kind: 'output-start' }, + { kind: 'output-end', exitCode: 0 }, + ]) + }) + + it('ignores markers carrying the wrong nonce', () => { + const parser = new ShellIntegrationParser(NONCE) + // A file rendered with `cat` can contain a literal finish sequence. Acting + // on it would hand the agent a fabricated exit code, so it must be inert. + const { markers } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) + + expect(markers).toEqual([]) + }) + + it('still removes forged sequences from the display stream', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) + + expect(text).toBe('BEFOREAFTER') + }) + + it('reassembles sequences split across chunks', () => { + const parser = new ShellIntegrationParser(NONCE) + const full = `hello${osc(`D;7;${NONCE}`)}world` + const split = full.length - 8 + + const first = parser.parse(full.slice(0, split)) + const second = parser.parse(full.slice(split)) + + expect(first.markers).toEqual([]) + expect(second.markers).toEqual([{ kind: 'output-end', exitCode: 7 }]) + expect(first.text + second.text).toBe('helloworld') + }) + + it('unescapes semicolons and newlines in command lines', () => { + const parser = new ShellIntegrationParser(NONCE) + const { markers } = parser.parse(osc(`E;echo a\\x3bb\\x0ac;${NONCE}`)) + + expect(markers).toEqual([{ kind: 'command-line', command: 'echo a;b\nc' }]) + }) + + it('tracks the working directory', () => { + const parser = new ShellIntegrationParser(NONCE) + const { markers } = parser.parse(osc(`P;Cwd=/tmp/some dir;${NONCE}`)) + + expect(markers).toEqual([{ kind: 'cwd', cwd: '/tmp/some dir' }]) + }) + + it('accepts ST as well as BEL as a terminator', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text, markers } = parser.parse(`a\u001b]633;A;${NONCE}\u001b\\b`) + + expect(text).toBe('ab') + expect(markers).toEqual([{ kind: 'prompt-start' }]) + }) + + it('releases an unterminated sequence rather than buffering without bound', () => { + const parser = new ShellIntegrationParser(NONCE) + const runaway = `\u001b]633;${'x'.repeat(9000)}` + const { text } = parser.parse(runaway) + + expect(text).toBe(runaway) + }) +}) + +describe('detectShell', () => { + it('recognises the shells we can instrument', () => { + expect(detectShell('/bin/zsh')).toBe('zsh') + expect(detectShell('/usr/local/bin/bash')).toBe('bash') + }) + + it('returns null for shells without hooks, leaving the terminal uninstrumented', () => { + expect(detectShell('/usr/bin/fish')).toBeNull() + expect(detectShell('/bin/sh')).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts new file mode 100644 index 0000000000..f69e86e1d2 --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -0,0 +1,297 @@ +/** + * Shell integration: the mechanism that turns an opaque byte stream into + * structured commands. + * + * Writing `npm test\r` into a PTY tells you nothing about where that command's + * output begins, when it ends, or what it exited with. The fix, pioneered by + * FinalTerm and standardised in practice by VS Code, is to have the shell + * itself announce those boundaries with OSC escape sequences emitted from its + * prompt hooks. We speak VS Code's `OSC 633` grammar because it is the + * best-tested variant and its semantics are documented. + * + * Every sequence carries a per-session nonce. This is a security requirement, + * not decoration: the terminal renders untrusted bytes, so `cat` of a file + * containing a literal `\e]633;D;0\a` would otherwise let arbitrary file + * content forge "the command finished successfully" and feed the agent a + * fabricated exit code. Markers whose nonce does not match are ignored. + */ +import { randomBytes } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' + +/** Shells we can install prompt hooks into. */ +export type SupportedShell = 'zsh' | 'bash' + +export function detectShell(shellPath: string): SupportedShell | null { + const name = basename(shellPath) + if (name === 'zsh' || name === '-zsh') return 'zsh' + if (name === 'bash' || name === '-bash') return 'bash' + return null +} + +export function createNonce(): string { + return randomBytes(16).toString('hex') +} + +/** + * Marker kinds we act on. `A` (prompt start) doubles as the "integration is + * live" signal; `C`/`D` bracket a command's output; `E` reports the exact + * command line; `P` tracks the working directory across `cd`. + */ +export type ShellMarker = + | { kind: 'prompt-start' } + | { kind: 'command-line'; command: string } + | { kind: 'output-start' } + | { kind: 'output-end'; exitCode: number } + | { kind: 'cwd'; cwd: string } + +export interface ParseResult { + /** Stream with the OSC 633 sequences removed, safe to hand to xterm.js. */ + text: string + markers: ShellMarker[] +} + +/** Undoes the escaping applied by the shell hooks. */ +function unescapeValue(value: string): string { + return value.replace(/\\x([0-9a-fA-F]{2})/g, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ) +} + +/** + * Incremental parser. PTY chunks split escape sequences at arbitrary byte + * offsets, so a partial trailing sequence is held back until the rest arrives + * rather than being emitted as garbage or mis-parsed. + */ +export class ShellIntegrationParser { + private pending = '' + + constructor(private readonly nonce: string) {} + + /** + * Cap on a held-back partial sequence. A stream containing a bare `\e]` that + * never terminates would otherwise grow `pending` without bound; past this + * length we accept that it was not a marker and release it. + */ + private static readonly MAX_PENDING = 8192 + + parse(chunk: string): ParseResult { + const buffer = this.pending + chunk + this.pending = '' + + const markers: ShellMarker[] = [] + let text = '' + let index = 0 + + while (index < buffer.length) { + const start = buffer.indexOf('\u001b]633;', index) + if (start === -1) { + text += buffer.slice(index) + break + } + text += buffer.slice(index, start) + + const terminator = findTerminator(buffer, start) + if (terminator === null) { + // Incomplete sequence: hold it for the next chunk unless it has grown + // implausibly long, in which case treat it as ordinary text. + const tail = buffer.slice(start) + if (tail.length > ShellIntegrationParser.MAX_PENDING) { + text += tail + } else { + this.pending = tail + } + break + } + + const body = buffer.slice(start + '\u001b]633;'.length, terminator.index) + const marker = this.toMarker(body) + if (marker) markers.push(marker) + index = terminator.index + terminator.length + } + + return { text, markers } + } + + private toMarker(body: string): ShellMarker | null { + const parts = body.split(';') + const kind = parts[0] + + // The nonce is always last. Without a match the sequence did not come from + // our prompt hooks, so it is untrusted output that must not be acted on. + const nonce = parts[parts.length - 1] + if (nonce !== this.nonce) return null + + switch (kind) { + case 'A': + return { kind: 'prompt-start' } + case 'C': + return { kind: 'output-start' } + case 'D': { + const exitCode = Number.parseInt(parts[1] ?? '', 10) + return { kind: 'output-end', exitCode: Number.isFinite(exitCode) ? exitCode : 0 } + } + case 'E': + return { kind: 'command-line', command: unescapeValue(parts.slice(1, -1).join(';')) } + case 'P': { + const value = parts.slice(1, -1).join(';') + if (!value.startsWith('Cwd=')) return null + return { kind: 'cwd', cwd: unescapeValue(value.slice('Cwd='.length)) } + } + default: + return null + } + } +} + +/** OSC sequences end with BEL or ST; both appear in the wild. */ +function findTerminator(buffer: string, from: number): { index: number; length: number } | null { + const bel = buffer.indexOf('\u0007', from) + const st = buffer.indexOf('\u001b\\', from) + if (bel !== -1 && (st === -1 || bel < st)) return { index: bel, length: 1 } + if (st !== -1) return { index: st, length: 2 } + return null +} + +/** + * zsh reads all of its startup files from `ZDOTDIR`, so pointing that at a + * generated directory is the only hook that works for login *and* interactive + * shells. Each generated file sources the user's real one first, so their + * prompt, aliases, and PATH win over ours. + */ +function writeZshFiles(dir: string, nonce: string, originalZdotdir: string): void { + const sourceOriginal = (file: string) => + `[ -f "$SIM_ZDOTDIR_ORIG/${file}" ] && builtin source "$SIM_ZDOTDIR_ORIG/${file}"` + + writeFileSync( + join(dir, '.zshenv'), + `SIM_ZDOTDIR_ORIG="\${SIM_ZDOTDIR_ORIG:-${originalZdotdir}}"\n${sourceOriginal('.zshenv')}\n` + ) + writeFileSync(join(dir, '.zprofile'), `${sourceOriginal('.zprofile')}\n`) + writeFileSync(join(dir, '.zlogin'), `${sourceOriginal('.zlogin')}\n`) + + writeFileSync( + join(dir, '.zshrc'), + `${sourceOriginal('.zshrc')} + +# Restore ZDOTDIR so anything the user's config spawns behaves normally. +ZDOTDIR="$SIM_ZDOTDIR_ORIG" + +__sim_nonce='${nonce}' +__sim_in_cmd='' + +__sim_esc() { + local s=\${1//\\\\/\\\\\\\\} + s=\${s//;/\\\\x3b} + s=\${s//$'\\n'/\\\\x0a} + builtin printf '%s' "$s" +} + +__sim_preexec() { + __sim_in_cmd=1 + builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$1")" "$__sim_nonce" + builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" +} + +__sim_precmd() { + local st=$? + # Cwd is reported before the finish marker so a \`cd\` is already visible by + # the time the command's result is resolved. + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + if [ -n "$__sim_in_cmd" ]; then + builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" + fi + __sim_in_cmd='' + builtin printf '\\e]633;A;%s\\a' "$__sim_nonce" +} + +# zsh appends this marker when output does not end in a newline. It is display +# noise that would otherwise be captured as part of a command's output. +PROMPT_EOL_MARK='' + +autoload -Uz add-zsh-hook +add-zsh-hook preexec __sim_preexec +add-zsh-hook precmd __sim_precmd +` + ) +} + +/** + * bash has no preexec hook, so command start is detected with a DEBUG trap and + * command end from PROMPT_COMMAND. The trap fires once per command in a + * pipeline, hence the in-command latch. + */ +function writeBashFile(dir: string, nonce: string): string { + const rcPath = join(dir, 'sim-bash-rc.sh') + writeFileSync( + rcPath, + `[ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc" + +__sim_nonce='${nonce}' +__sim_in_cmd='' + +__sim_esc() { + local s=\${1//\\\\/\\\\\\\\} + s=\${s//;/\\\\x3b} + s=\${s//$'\\n'/\\\\x0a} + builtin printf '%s' "$s" +} + +__sim_preexec() { + case "$BASH_COMMAND" in __sim_*) return ;; esac + [ -n "$__sim_in_cmd" ] && return + __sim_in_cmd=1 + builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$BASH_COMMAND")" "$__sim_nonce" + builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" +} + +__sim_precmd() { + local st=$? + # Cwd is reported before the finish marker so a \`cd\` is already visible by + # the time the command's result is resolved. + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + if [ -n "$__sim_in_cmd" ]; then + builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" + fi + __sim_in_cmd='' + builtin printf '\\e]633;A;%s\\a' "$__sim_nonce" + return $st +} + +trap '__sim_preexec' DEBUG +PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" +` + ) + return rcPath +} + +export interface ShellLaunch { + args: string[] + env: Record +} + +/** + * Generates the startup files for `shell` inside `dir` and returns the + * arguments and environment overrides needed to make it load them. + */ +export function buildShellLaunch( + shell: SupportedShell, + dir: string, + nonce: string, + env: Record +): ShellLaunch { + mkdirSync(dir, { recursive: true }) + + if (shell === 'zsh') { + writeZshFiles(dir, nonce, env.ZDOTDIR || env.HOME || '') + return { + args: ['-l'], + env: { ZDOTDIR: dir, SIM_ZDOTDIR_ORIG: env.ZDOTDIR || env.HOME || '' }, + } + } + + const rcPath = writeBashFile(dir, nonce) + // `--init-file` is honoured only by interactive non-login bash, so the login + // flag is deliberately omitted here; the generated file sources ~/.bashrc. + return { args: ['--init-file', rcPath, '-i'], env: {} } +} diff --git a/apps/desktop/src/main/terminal/tmux.test.ts b/apps/desktop/src/main/terminal/tmux.test.ts new file mode 100644 index 0000000000..b89c6cc7eb --- /dev/null +++ b/apps/desktop/src/main/terminal/tmux.test.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + awaitRun, + isDescendantOf, + parseFormatLines, + parseProcessParents, + pollRun, + type TmuxRunHandle, +} from '@/main/terminal/tmux' + +/** The separator the format strings use; no tmux field can contain it. */ +const F = '\u001f' + +describe('parseFormatLines', () => { + it('splits a line into its fields', () => { + expect(parseFormatLines(`12345${F}/dev/ttys004${F}main\n`, 3)).toEqual([ + ['12345', '/dev/ttys004', 'main'], + ]) + }) + + it('keeps fields that contain spaces intact', () => { + // Splitting on whitespace would shift every later field for a window named + // "my project" or a path under "Application Support". + const line = `main:1.0${F}my project${F}zsh${F}/Users/me/Application Support${F}1` + expect(parseFormatLines(line, 5)).toEqual([ + ['main:1.0', 'my project', 'zsh', '/Users/me/Application Support', '1'], + ]) + }) + + it('ignores blank lines and trailing newlines', () => { + expect(parseFormatLines(`a${F}b\n\n\n`, 2)).toEqual([['a', 'b']]) + }) + + it('drops lines with the wrong field count rather than mis-assigning them', () => { + expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']]) + }) + + it('returns nothing for empty output', () => { + expect(parseFormatLines('', 3)).toEqual([]) + }) +}) + +describe('parseProcessParents', () => { + it('reads pid and parent pid pairs', () => { + const parents = parseProcessParents(' 501 1\n 777 501\n') + expect(parents.get(501)).toBe(1) + expect(parents.get(777)).toBe(501) + }) + + it('skips lines that are not two numbers', () => { + expect(parseProcessParents('header row\n 42 7\n').size).toBe(1) + }) +}) + +describe('isDescendantOf', () => { + // A tmux client is usually a direct child of the shell, but an rc file that + // execs through a wrapper can put another process in between. + const parents = new Map([ + [100, 1], + [200, 100], + [300, 200], + [900, 1], + ]) + + it('matches the process itself', () => { + expect(isDescendantOf(100, 100, parents)).toBe(true) + }) + + it('matches a direct child', () => { + expect(isDescendantOf(200, 100, parents)).toBe(true) + }) + + it('matches through an intermediate process', () => { + expect(isDescendantOf(300, 100, parents)).toBe(true) + }) + + it('rejects an unrelated process', () => { + expect(isDescendantOf(900, 100, parents)).toBe(false) + }) + + it('rejects rather than looping when the chain cycles', () => { + const cyclic = new Map([ + [10, 20], + [20, 10], + ]) + expect(isDescendantOf(10, 999, cyclic)).toBe(false) + }) +}) + +describe('run status files', () => { + const dirs: string[] = [] + + const handleIn = (dir: string): TmuxRunHandle => ({ + window: '@1', + outPath: join(dir, 'out'), + statusPath: join(dir, 'status'), + dispose: () => {}, + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'tmux-run-test-')) + dirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + it('reports a command as unfinished until the status file exists', () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'building...\n') + + expect(pollRun(handleIn(dir))).toEqual({ output: 'building...\n', exitCode: null, done: false }) + }) + + it('reads the real exit code once the status file lands', () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'boom\n') + writeFileSync(join(dir, 'status'), '7') + + expect(pollRun(handleIn(dir))).toEqual({ output: 'boom\n', exitCode: 7, done: true }) + }) + + it('treats a command that produced no output as finished, not missing', () => { + const dir = scratch() + writeFileSync(join(dir, 'status'), '0') + + expect(pollRun(handleIn(dir))).toEqual({ output: '', exitCode: 0, done: true }) + }) + + it('reports done with no code rather than NaN when the status is unreadable', () => { + const dir = scratch() + writeFileSync(join(dir, 'status'), 'not-a-number') + + expect(pollRun(handleIn(dir))).toEqual({ output: '', exitCode: null, done: true }) + }) + + it('returns as soon as a command finishes rather than waiting out the window', async () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'done\n') + writeFileSync(join(dir, 'status'), '0') + + const started = Date.now() + const outcome = await awaitRun(handleIn(dir), 5_000) + + expect(outcome.done).toBe(true) + expect(Date.now() - started).toBeLessThan(1_000) + }) + + it('hands back a still-running command when the window elapses', async () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'still going\n') + + // The whole point of the status file over `tmux wait-for`: a command that + // outlives the wait leaves a pollable state rather than a blocked waiter. + const outcome = await awaitRun(handleIn(dir), 300) + + expect(outcome).toEqual({ output: 'still going\n', exitCode: null, done: false }) + }) +}) diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts new file mode 100644 index 0000000000..2af584a6d0 --- /dev/null +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -0,0 +1,423 @@ +/** + * tmux support for the agent terminal. + * + * When a user runs tmux in one of Sim's shells, the agent goes blind: tmux is + * itself a terminal emulator, so it parses its children's output and re-renders + * it, and the OSC 633 markers our shell integration relies on never reach us. + * Command boundaries, exit codes, and the working directory all disappear + * behind a full-screen program. + * + * tmux does expose all of it through its own CLI, though, so this module talks + * to the tmux server directly instead of guessing at the screen. Every call is + * a short-lived child process sharing the shell's environment, which is what + * points it at the same socket the user's client is on. + * + * The tab-to-session mapping goes through process ids: node-pty gives us the + * shell's pid, tmux reports each client's pid, and the client is a descendant + * of the shell that launched it. + */ +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { TerminalPaneState } from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' + +const logger = createLogger('DesktopTmux') + +/** + * Ceiling on any single tmux invocation. These are local socket round trips + * that normally return in milliseconds; a hang means the server is wedged, and + * blocking a tool call on it forever is worse than reporting failure. + */ +const TMUX_TIMEOUT_MS = 5_000 + +/** How often the status file is checked while a tmux-run command is going. */ +const RUN_POLL_INTERVAL_MS = 250 + +/** Field separator for `-F` output. Chosen because no tmux field contains it. */ +const FIELD = '\u001f' + +export interface TmuxCommandResult { + ok: boolean + stdout: string + stderr: string +} + +export interface TmuxRunOutcome { + output: string + /** Null while the command is still going. */ + exitCode: number | null + done: boolean +} + +/** A shell's tmux attachment, resolved from its pid. */ +export interface TmuxAttachment { + session: string + clientTty: string +} + +/** + * Runs one tmux command. Never throws: a missing binary, a dead server, and a + * bad target all arrive as `ok: false` with tmux's own message, which is more + * useful to the model than an exception. + */ +/** + * Set once a `tmux` spawn fails with ENOENT: the binary is not installed, and + * it will not appear mid-session, so every later call short-circuits instead + * of paying a failed spawn. Most machines running this have no tmux at all, + * and without this every terminal tool call spawned a doomed process. + */ +let tmuxBinaryMissing = false + +export function isTmuxUnavailable(): boolean { + return tmuxBinaryMissing +} + +export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve) => { + if (tmuxBinaryMissing) { + resolve({ ok: false, stdout: '', stderr: 'tmux is not installed' }) + return + } + let child: ReturnType + try { + child = spawn('tmux', args, { env, stdio: ['ignore', 'pipe', 'pipe'] }) + } catch (error) { + resolve({ ok: false, stdout: '', stderr: (error as Error).message }) + return + } + + let stdout = '' + let stderr = '' + let settled = false + const finish = (result: TmuxCommandResult) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + const timer = setTimeout(() => { + child.kill('SIGKILL') + finish({ ok: false, stdout, stderr: 'tmux did not respond' }) + }, TMUX_TIMEOUT_MS) + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') tmuxBinaryMissing = true + finish({ ok: false, stdout, stderr: error.message }) + }) + child.on('close', (code) => { + finish({ ok: code === 0, stdout, stderr }) + }) + }) +} + +/** + * Parses `list-clients`/`list-panes` output into records. + * + * Split on a control character rather than whitespace: window names and + * working directories contain spaces, and a path with a space would otherwise + * shift every later field by one. + */ +export function parseFormatLines(stdout: string, fields: number): string[][] { + return stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .map((line) => line.split(FIELD)) + .filter((parts) => parts.length === fields) +} + +/** + * Builds the pid -> parent pid map used to decide whether a tmux client + * belongs to one of our shells. One `ps` call rather than one per candidate: + * the client is usually a direct child, but a wrapper (`exec tmux` from an rc + * file, a login shell in between) can put it further down. + */ +export function parseProcessParents(psOutput: string): Map { + const parents = new Map() + for (const line of psOutput.split('\n')) { + const [pid, ppid] = line.trim().split(/\s+/) + const childId = Number(pid) + const parentId = Number(ppid) + if (Number.isInteger(childId) && Number.isInteger(parentId)) { + parents.set(childId, parentId) + } + } + return parents +} + +/** + * Whether `pid` is `ancestor` or descends from it. Bounded rather than + * following the chain to init, so a cycle in malformed `ps` output cannot spin. + */ +export function isDescendantOf( + pid: number, + ancestor: number, + parents: Map +): boolean { + let current = pid + for (let hops = 0; hops < 32; hops += 1) { + if (current === ancestor) return true + const parent = parents.get(current) + if (parent === undefined || parent <= 1) return false + current = parent + } + return false +} + +function listProcessParents(): Promise> { + return new Promise((resolve) => { + const child = spawn('ps', ['-Ao', 'pid=,ppid='], { stdio: ['ignore', 'pipe', 'ignore'] }) + let out = '' + child.stdout?.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + child.on('error', () => resolve(new Map())) + child.on('close', () => resolve(parseProcessParents(out))) + }) +} + +/** + * Finds the tmux session attached in the shell with `shellPid`, or null when + * that shell is not running tmux. + * + * Matching on the client's pid rather than its tty because node-pty exposes + * the shell's pid but not the pty's device path, and the client's tty is only + * comparable if we already know ours. + */ +export async function resolveAttachment( + shellPid: number, + env: NodeJS.ProcessEnv +): Promise { + const format = ['#{client_pid}', '#{client_tty}', '#{client_session}'].join(FIELD) + const listed = await runTmux(['list-clients', '-F', format], env) + if (!listed.ok) return null + + const clients = parseFormatLines(listed.stdout, 3) + if (clients.length === 0) return null + + const parents = await listProcessParents() + for (const [clientPid, clientTty, session] of clients) { + const pid = Number(clientPid) + if (!Number.isInteger(pid)) continue + if (isDescendantOf(pid, shellPid, parents)) { + return { session, clientTty } + } + } + return null +} + +/** The active pane of a session, as a target usable by every other call. */ +export async function activePane(session: string, env: NodeJS.ProcessEnv): Promise { + const result = await runTmux( + ['display-message', '-p', '-t', session, '#{session_name}:#{window_index}.#{pane_index}'], + env + ) + const target = result.stdout.trim() + return result.ok && target ? target : null +} + +export async function listPanes( + session: string, + env: NodeJS.ProcessEnv +): Promise { + const format = [ + '#{session_name}:#{window_index}.#{pane_index}', + '#{window_name}', + '#{pane_current_command}', + '#{pane_current_path}', + '#{pane_active}', + ].join(FIELD) + const result = await runTmux(['list-panes', '-s', '-t', session, '-F', format], env) + if (!result.ok) return [] + return parseFormatLines(result.stdout, 5).map( + ([target, windowName, command, cwd, active]): TerminalPaneState => ({ + target, + windowName, + command, + cwd: cwd || null, + active: active === '1', + }) + ) +} + +/** Captures a pane's visible screen plus `lines` of scrollback above it. */ +export async function capturePane( + target: string, + lines: number, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['capture-pane', '-p', '-t', target, '-S', `-${Math.max(0, lines)}`], env) +} + +/** + * Types into a pane. `-l` sends the text literally, so a command containing + * something like `C-c` is typed rather than interpreted as a key. + */ +export async function sendText( + target: string, + text: string, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['send-keys', '-t', target, '-l', '--', text], env) +} + +/** Presses a key in a pane, using tmux's key names (`Enter`, `C-c`, `Up`). */ +export async function sendKey( + target: string, + key: string, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['send-keys', '-t', target, key], env) +} + +/** Control keys as tmux names them, for `input` against a pane. */ +export const TMUX_KEY_NAMES: Record = { + 'ctrl-c': 'C-c', + 'ctrl-d': 'C-d', + 'ctrl-z': 'C-z', + enter: 'Enter', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', + escape: 'Escape', + tab: 'Tab', +} + +/** + * A command running in its own tmux window, with its output and exit status + * landing in files rather than being scraped off the screen. + */ +export interface TmuxRunHandle { + window: string + outPath: string + statusPath: string + dispose(): void +} + +/** + * Starts a command in a dedicated tmux window. + * + * The window is the user's to watch — this is their tmux session, so work Sim + * does should be visible in it rather than hidden. Output is teed so it both + * scrolls on screen and lands in a file, and the exit status is written + * separately once the pipeline finishes. + * + * Deliberately not built on `tmux wait-for`: that is a rendezvous rather than + * a latch, so a command that finishes before the waiter starts leaves the wait + * blocked forever. A file appearing has no such race. + */ +export async function startRun( + session: string, + command: string, + cwd: string | null, + env: NodeJS.ProcessEnv +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-run-')) + const outPath = join(dir, 'out') + const statusPath = join(dir, 'status') + const dispose = () => { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Temp dir; the OS reclaims it. + } + } + + // PIPESTATUS keeps the command's own exit code rather than tee's, which is + // always 0. bash rather than the user's shell because PIPESTATUS is not + // portable and this wrapper is ours, not something they have to read. + const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)}` + const wrapper = `bash -lc ${JSON.stringify(`{ ${script}; } 2>&1 | tee ${JSON.stringify(outPath)}`)}` + + const args = ['new-window', '-d', '-P', '-F', '#{window_id}', '-t', session, '-n', 'sim-run'] + if (cwd) args.push('-c', cwd) + args.push(wrapper) + + const created = await runTmux(args, env) + if (!created.ok) { + dispose() + return { error: created.stderr.trim() || 'tmux could not open a window for the command.' } + } + + return { window: created.stdout.trim(), outPath, statusPath, dispose } +} + +function readIfPresent(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** Reads a run's current output and, once written, its exit code. */ +export function pollRun(handle: TmuxRunHandle): TmuxRunOutcome { + const status = readIfPresent(handle.statusPath) + const output = readIfPresent(handle.outPath) ?? '' + if (status === null) { + return { output, exitCode: null, done: false } + } + const exitCode = Number.parseInt(status.trim(), 10) + return { output, exitCode: Number.isNaN(exitCode) ? null : exitCode, done: true } +} + +/** + * Whether the run has finished, decided from the tiny status file alone. + * + * The command's output file grows without bound and `tee` appends to it for + * the whole run, so reading it every poll — as reading the full outcome did — + * meant re-reading and decoding everything printed so far several times a + * second, quadratic in output size. Liveness only needs the status file, which + * is a few bytes; the output is read once, when the run is settled. + */ +function isRunComplete(handle: TmuxRunHandle): boolean { + return readIfPresent(handle.statusPath) !== null +} + +/** + * Waits for a run to finish, up to `waitMs`. Returns as soon as the status + * file appears; a command still going when the window elapses comes back + * undone, for the caller to report as running and poll again later. The full + * output is read only once, on the terminating poll. + */ +export async function awaitRun(handle: TmuxRunHandle, waitMs: number): Promise { + const deadline = Date.now() + waitMs + for (;;) { + if (isRunComplete(handle)) return pollRun(handle) + const remaining = deadline - Date.now() + if (remaining <= 0) return pollRun(handle) + await sleep(Math.min(RUN_POLL_INTERVAL_MS, remaining)) + } +} + +/** + * Closes a pane, taking whatever runs in it with it. + * + * The way to be rid of a program that will not take an interrupt — a coding + * agent, an editor with unsaved state, anything that treats Ctrl-C as "cancel + * the current thing" rather than "quit". tmux tidies up after it: emptying a + * window closes the window, and emptying the last window ends the session. + */ +export async function killPane(target: string, env: NodeJS.ProcessEnv): Promise { + return runTmux(['kill-pane', '-t', target], env) +} + +/** Closes a window opened by {@link startRun}. */ +export async function closeRunWindow(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise { + if (!handle.window) return + const killed = await runTmux(['kill-window', '-t', handle.window], env) + if (!killed.ok) { + logger.warn('Could not close the tmux run window', { error: killed.stderr.trim() }) + } +} diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts new file mode 100644 index 0000000000..84a8e0ad05 --- /dev/null +++ b/apps/desktop/src/main/tray.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { nativeImage, session } from 'electron' +import { + addTrayEnvironmentSubscript, + buildTrayMenuTemplate, + chatRoute, + installTray, + parseRecentChats, + type RecentChat, + type TrayDeps, + trayEnvironmentMarker, +} from '@/main/tray' +// Same module instance the vi.mock factory returns, with mock-typed statics. +import { Menu, Tray } from '@/test/electron-mock' + +function makeDeps(overrides: Partial = {}): TrayDeps { + return { + partition: () => 'persist:sim', + appOrigin: () => 'https://sim.ai', + lastRoute: () => '/workspace/ws1/home', + openMainWindow: vi.fn(), + ...overrides, + } +} + +function chat(id: number): RecentChat { + return { id: `c${id}`, title: `Chat ${id}`, workspaceId: 'ws1', status: 'none' } +} + +describe('parseRecentChats', () => { + it('keeps only well-formed workspace chats, capped at the limit', () => { + const payload = { + chats: [ + { id: 'c1', title: 'Build a workflow', workspaceId: 'ws1' }, + { id: 'c2', title: '', workspaceId: 'ws1' }, + { id: 'c3', title: 'No workspace', workspaceId: null }, + { id: 42, title: 'Bad id', workspaceId: 'ws1' }, + { id: 'c4', title: 'Four', workspaceId: 'ws2' }, + { id: 'c5', title: 'Five', workspaceId: 'ws2' }, + { id: 'c6', title: 'Six', workspaceId: 'ws2' }, + { id: 'c7', title: 'Seven', workspaceId: 'ws2' }, + ], + } + const chats = parseRecentChats(payload, 5) + expect(chats.map((chat) => chat.id)).toEqual(['c1', 'c2', 'c4', 'c5', 'c6']) + expect(chats[1].title).toBe('Untitled chat') + }) + + it('returns empty for malformed payloads', () => { + expect(parseRecentChats(null)).toEqual([]) + expect(parseRecentChats({})).toEqual([]) + expect(parseRecentChats({ chats: 'nope' })).toEqual([]) + }) + + it('derives the sidebar status semantics: active > unread > none', () => { + const payload = { + chats: [ + // Streaming right now → active, regardless of seen state. + { + id: 'c1', + title: 'Streaming', + workspaceId: 'ws1', + activeStreamId: 's1', + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T11:00:00Z', + }, + // Finished after last seen → unread. + { + id: 'c2', + title: 'Fresh reply', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T09:00:00Z', + }, + // Never opened → unread. + { + id: 'c3', + title: 'Never seen', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: null, + }, + // Seen since the last update → no dot. + { + id: 'c4', + title: 'Caught up', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T11:00:00Z', + }, + // Legacy row without the status fields → no dot. + { id: 'c5', title: 'Legacy', workspaceId: 'ws1' }, + ], + } + expect(parseRecentChats(payload).map((chat) => chat.status)).toEqual([ + 'active', + 'unread', + 'unread', + 'none', + 'none', + ]) + }) +}) + +describe('routes', () => { + it('deep-links chats into their workspace', () => { + expect(chatRoute({ id: 'c1', title: 't', workspaceId: 'ws9', status: 'none' })).toBe( + '/workspace/ws9/chat/c1' + ) + }) +}) + +describe('tray environment marker', () => { + it('marks only non-production channels', () => { + expect(trayEnvironmentMarker('prod')).toBeNull() + expect(trayEnvironmentMarker('local')).toBe('L') + expect(trayEnvironmentMarker('dev')).toBe('D') + expect(trayEnvironmentMarker('staging')).toBe('S') + }) + + it('adds a larger antialiased subscript without modifying the source icon', () => { + const source = nativeImage.createFromPath('/tmp/simTemplate.png') + const marked = addTrayEnvironmentSubscript(source, 'D') + + expect(marked).not.toBe(source) + const [bitmap, options] = vi.mocked(nativeImage.createFromBitmap).mock.calls.at(-1) ?? [] + expect(options).toEqual({ width: 76, height: 32, scaleFactor: 2 }) + expect(Buffer.isBuffer(bitmap)).toBe(true) + expect((bitmap as Buffer).some((value) => value === 255)).toBe(true) + expect((bitmap as Buffer).some((value) => value > 0 && value < 255)).toBe(true) + }) +}) + +describe('buildTrayMenuTemplate', () => { + it('shows recents inline, then actions and quit', () => { + const deps = makeDeps() + const template = buildTrayMenuTemplate(deps, [ + { id: 'c1', title: 'Fix the sync', workspaceId: 'ws1', status: 'none' }, + ]) + const labels = template.map((item) => item.label ?? item.role ?? item.type) + expect(labels).toEqual([ + 'Recent Chats', + 'Fix the sync', + 'separator', + 'New Chat', + 'Open Sim', + 'separator', + 'Quit Sim', + ]) + + const chatItem = template.find((item) => item.label === 'Fix the sync') + ;(chatItem?.click as () => void)() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/chat/c1') + + const newChat = template.find((item) => item.label === 'New Chat') + ;(newChat?.click as () => void)() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/home') + + // Quit is a plain item (role:'quit' would get a system icon on macOS 26). + const quit = template.find((item) => item.label === 'Quit Sim') + expect(quit?.role).toBeUndefined() + ;(quit?.click as () => void)() + }) + + it('marks active and unread chats with a status dot icon', () => { + const template = buildTrayMenuTemplate(makeDeps(), [ + { id: 'c1', title: 'Working', workspaceId: 'ws1', status: 'active' }, + { id: 'c2', title: 'Fresh', workspaceId: 'ws1', status: 'unread' }, + { id: 'c3', title: 'Seen', workspaceId: 'ws1', status: 'none' }, + ]) + const working = template.find((item) => item.label === 'Working') + const fresh = template.find((item) => item.label === 'Fresh') + const seen = template.find((item) => item.label === 'Seen') + expect(working?.icon).toBeDefined() + expect(fresh?.icon).toBeDefined() + // Active (yellow) and unread (green) use distinct images; read chats get none. + expect(working?.icon).not.toBe(fresh?.icon) + expect(seen?.icon).toBeUndefined() + }) + + it('overflows chats beyond the inline count into a More submenu', () => { + const deps = makeDeps() + const chats = Array.from({ length: 9 }, (_, i) => chat(i + 1)) + const template = buildTrayMenuTemplate(deps, chats) + + const inlineLabels = template + .filter((item) => item.label?.startsWith('Chat ')) + .map((item) => item.label) + expect(inlineLabels).toEqual(['Chat 1', 'Chat 2', 'Chat 3', 'Chat 4', 'Chat 5']) + + const more = template.find((item) => item.label === 'More') + expect(more).toBeDefined() + const submenu = more?.submenu as { label?: string; click?: () => void }[] + expect(submenu.map((item) => item.label)).toEqual(['Chat 6', 'Chat 7', 'Chat 8', 'Chat 9']) + submenu[0].click?.() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/chat/c6') + }) + + it('omits More when everything fits inline and recents when empty', () => { + const fits = buildTrayMenuTemplate(makeDeps(), [chat(1), chat(2)]) + expect(fits.some((item) => item.label === 'More')).toBe(false) + + const empty = buildTrayMenuTemplate(makeDeps(), []) + expect(empty.some((item) => item.label === 'Recent Chats')).toBe(false) + expect(empty.map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'New Chat', + 'Open Sim', + 'separator', + 'Quit Sim', + ]) + }) +}) + +describe('installTray', () => { + beforeEach(() => { + Tray.instances.length = 0 + vi.mocked(nativeImage.createFromBitmap).mockClear() + Menu.buildFromTemplate.mockClear() + }) + + it('fetches fresh chats on click and pops the menu', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Hello', workspaceId: 'ws1' }] }), + })) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + const handle = installTray(makeDeps()) + expect(handle).not.toBeNull() + expect(Tray.instances).toHaveLength(1) + const tray = Tray.instances[0] + + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1)) + expect(fetchMock).toHaveBeenCalledWith( + 'https://sim.ai/api/copilot/chats', + expect.objectContaining({ credentials: 'include' }) + ) + }) + + it('still pops the menu when the chats fetch fails', async () => { + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as never) + + installTray(makeDeps()) + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1)) + }) + + it('drops the previous user’s chats when the server says signed out', async () => { + // A 401 is an answer ("no user, no chats"), not a transport failure. If it + // were treated as a failure the menu would keep listing the signed-out + // user's chat titles to whoever picks the machine up next. + let signedIn = true + const fetchMock = vi.fn(async () => + signedIn + ? { + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), + } + : { ok: false, status: 401, json: async () => ({}) } + ) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + installTray(makeDeps()) + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + + // Each click pops from cache then refreshes it, so poll until the menu + // settles rather than assuming which click sees the new state. + const lastMenu = () => JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1)) + await vi.waitFor(() => { + clickHandler() + expect(lastMenu()).toContain('Secret') + }) + + signedIn = false + await vi.waitFor(() => { + clickHandler() + expect(lastMenu()).not.toContain('Secret') + }) + expect(tray.popUpContextMenu).toHaveBeenCalled() + }) + + it('clearRecentChats empties the menu immediately and voids an in-flight fetch', async () => { + // Sign-out teardown calls this. The menu pops from cache BEFORE refreshing, + // so without it the next open would show the old titles one more time. + let release: (value: unknown) => void = () => {} + const inFlight = new Promise((resolve) => { + release = resolve + }) + const fetchMock = vi.fn(async () => { + await inFlight + return { + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), + } + }) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + const handle = installTray(makeDeps()) + handle?.clearRecentChats() + release(undefined) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalled()) + expect(JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1))).not.toContain('Secret') + }) + + it('marks dev, staging, and local status items while leaving production unchanged', () => { + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as never) + + installTray(makeDeps()) + expect(nativeImage.createFromBitmap).not.toHaveBeenCalled() + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim') + + installTray(makeDeps({ appOrigin: () => 'https://dev.sim.ai' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(1) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Dev') + + installTray(makeDeps({ appOrigin: () => 'https://staging.sim.ai' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(2) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Staging') + + installTray(makeDeps({ appOrigin: () => 'http://localhost:3000' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(3) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Local') + }) +}) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 0000000000..59384682d5 --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,507 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { truncate } from '@sim/utils/string' +import type { MenuItemConstructorOptions, NativeImage } from 'electron' +import { app, Menu, nativeImage, session, Tray } from 'electron' +import { newChatRoute } from '@/main/app-routes' +import { APP_NAME_FOR_CHANNEL, channelForOrigin, type DesktopChannel } from '@/main/config' + +const logger = createLogger('DesktopTray') + +/** Chat status dot colors, mirroring the sidebar's ConversationListItem. */ +const ACTIVE_DOT_COLOR = { r: 0xea, g: 0xb3, b: 0x08 } // yellow: stream in progress +const UNREAD_DOT_COLOR = { r: 0x33, g: 0xc4, b: 0x82 } // green: finished, not yet seen + +/** Chats shown inline at the top of the menu. */ +const RECENT_CHATS_INLINE = 5 +/** Total chats kept (inline + the "More" hover submenu). */ +const RECENT_CHATS_TOTAL = 30 +// Generous: the refresh is async (a click always pops the cached menu +// immediately), so a slow dev-server compile just delays the NEXT open's +// recents instead of dropping them. +const CHATS_FETCH_TIMEOUT_MS = 5000 +const TRAY_ICON_SCALE_FACTOR = 2 +const TRAY_SUBSCRIPT_WIDTH = 5 +const TRAY_SUBSCRIPT_HEIGHT = 7 +const TRAY_SUBSCRIPT_STROKE_WIDTH = 1.1 +const TRAY_SUBSCRIPT_SUPERSAMPLE = 4 + +type TrayEnvironmentMarker = 'L' | 'D' | 'S' +type GlyphPoint = { x: number; y: number } +type GlyphSegment = readonly [GlyphPoint, GlyphPoint] + +function segmentsFromPoints(points: GlyphPoint[]): GlyphSegment[] { + return points.slice(1).map((point, index) => [points[index], point] as const) +} + +function cubicBezier( + start: GlyphPoint, + control1: GlyphPoint, + control2: GlyphPoint, + end: GlyphPoint, + steps = 10 +): GlyphSegment[] { + const points = Array.from({ length: steps + 1 }, (_, index) => { + const t = index / steps + const inverse = 1 - t + return { + x: + inverse ** 3 * start.x + + 3 * inverse ** 2 * t * control1.x + + 3 * inverse * t ** 2 * control2.x + + t ** 3 * end.x, + y: + inverse ** 3 * start.y + + 3 * inverse ** 2 * t * control1.y + + 3 * inverse * t ** 2 * control2.y + + t ** 3 * end.y, + } + }) + return segmentsFromPoints(points) +} + +function traySubscriptSegments(marker: TrayEnvironmentMarker): GlyphSegment[] { + if (marker === 'L') { + return [ + [ + { x: 0.65, y: 0.55 }, + { x: 0.65, y: 6.4 }, + ], + [ + { x: 0.65, y: 6.4 }, + { x: 4.4, y: 6.4 }, + ], + ] + } + if (marker === 'D') { + return [ + [ + { x: 0.65, y: 0.55 }, + { x: 0.65, y: 6.45 }, + ], + ...cubicBezier( + { x: 0.65, y: 0.55 }, + { x: 3.15, y: 0.55 }, + { x: 4.4, y: 1.55 }, + { x: 4.4, y: 3.5 }, + 12 + ), + ...cubicBezier( + { x: 4.4, y: 3.5 }, + { x: 4.4, y: 5.45 }, + { x: 3.15, y: 6.45 }, + { x: 0.65, y: 6.45 }, + 12 + ), + ] + } + return [ + ...cubicBezier( + { x: 4.4, y: 1.05 }, + { x: 3.3, y: 0.25 }, + { x: 0.6, y: 0.3 }, + { x: 0.6, y: 2.25 } + ), + ...cubicBezier( + { x: 0.6, y: 2.25 }, + { x: 0.6, y: 3.4 }, + { x: 4.4, y: 3.15 }, + { x: 4.4, y: 4.65 } + ), + ...cubicBezier( + { x: 4.4, y: 4.65 }, + { x: 4.4, y: 6.75 }, + { x: 1.45, y: 6.75 }, + { x: 0.55, y: 5.85 } + ), + ] +} + +function distanceToSegment(point: GlyphPoint, [start, end]: GlyphSegment): number { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + const projection = + lengthSquared === 0 + ? 0 + : Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared) + ) + return Math.hypot(point.x - (start.x + projection * dx), point.y - (start.y + projection * dy)) +} + +export function trayEnvironmentMarker(channel: DesktopChannel): TrayEnvironmentMarker | null { + switch (channel) { + case 'local': + return 'L' + case 'dev': + return 'D' + case 'staging': + return 'S' + default: + return null + } +} + +/** + * Adds a compact lower-right environment letter to the monochrome status + * icon. The glyph is drawn as a supersampled vector stroke so its curves match + * the smooth Sim mark at menu-bar scale; template rendering lets macOS tint + * both parts together. + */ +export function addTrayEnvironmentSubscript( + source: NativeImage, + marker: TrayEnvironmentMarker +): NativeImage { + const sourceSize = source.getSize() + const sourceWidth = sourceSize.width * TRAY_ICON_SCALE_FACTOR + const sourceHeight = sourceSize.height * TRAY_ICON_SCALE_FACTOR + const sourceBitmap = source.toBitmap({ scaleFactor: TRAY_ICON_SCALE_FACTOR }) + const targetLogicalWidth = sourceSize.width + TRAY_SUBSCRIPT_WIDTH + 1 + const targetWidth = targetLogicalWidth * TRAY_ICON_SCALE_FACTOR + const targetBitmap = Buffer.alloc(targetWidth * sourceHeight * 4) + + for (let y = 0; y < sourceHeight; y++) { + sourceBitmap.copy( + targetBitmap, + y * targetWidth * 4, + y * sourceWidth * 4, + (y + 1) * sourceWidth * 4 + ) + } + + const glyphX = (sourceSize.width + 1) * TRAY_ICON_SCALE_FACTOR + const glyphY = (sourceSize.height - TRAY_SUBSCRIPT_HEIGHT) * TRAY_ICON_SCALE_FACTOR + const segments = traySubscriptSegments(marker) + const sampleCount = TRAY_SUBSCRIPT_SUPERSAMPLE ** 2 + for (let y = glyphY; y < sourceHeight; y++) { + for (let x = glyphX; x < targetWidth; x++) { + let coveredSamples = 0 + for (let sampleY = 0; sampleY < TRAY_SUBSCRIPT_SUPERSAMPLE; sampleY++) { + for (let sampleX = 0; sampleX < TRAY_SUBSCRIPT_SUPERSAMPLE; sampleX++) { + const point = { + x: (x - glyphX + (sampleX + 0.5) / TRAY_SUBSCRIPT_SUPERSAMPLE) / TRAY_ICON_SCALE_FACTOR, + y: (y - glyphY + (sampleY + 0.5) / TRAY_SUBSCRIPT_SUPERSAMPLE) / TRAY_ICON_SCALE_FACTOR, + } + if ( + segments.some( + (segment) => distanceToSegment(point, segment) <= TRAY_SUBSCRIPT_STROKE_WIDTH / 2 + ) + ) { + coveredSamples++ + } + } + } + const offset = (y * targetWidth + x) * 4 + targetBitmap[offset + 3] = Math.round((coveredSamples / sampleCount) * 255) + } + } + + return nativeImage.createFromBitmap(targetBitmap, { + width: targetWidth, + height: sourceHeight, + scaleFactor: TRAY_ICON_SCALE_FACTOR, + }) +} +const CHATS_API_PATH = '/api/copilot/chats' + +/** + * Chat status for the menu dot, mirroring the sidebar's semantics: + * `active` (yellow) = a stream is running; `unread` (green) = finished after + * the user last saw the chat. + */ +export type RecentChatStatus = 'active' | 'unread' | 'none' + +export interface RecentChat { + id: string + title: string + workspaceId: string + status: RecentChatStatus +} + +function deriveChatStatus(chat: { + activeStreamId?: unknown + lastSeenAt?: unknown + updatedAt?: unknown +}): RecentChatStatus { + if (typeof chat.activeStreamId === 'string' && chat.activeStreamId) { + return 'active' + } + const updatedAt = typeof chat.updatedAt === 'string' ? Date.parse(chat.updatedAt) : Number.NaN + if (Number.isNaN(updatedAt)) { + return 'none' + } + if (chat.lastSeenAt === null || chat.lastSeenAt === undefined) { + return 'unread' + } + const lastSeenAt = typeof chat.lastSeenAt === 'string' ? Date.parse(chat.lastSeenAt) : Number.NaN + if (Number.isNaN(lastSeenAt)) { + return 'none' + } + return updatedAt > lastSeenAt ? 'unread' : 'none' +} + +/** + * Extracts tray-usable chats from the /api/copilot/chats response. Chats + * without a workspace id are dropped — the deep link needs one — and the + * response is already sorted by recency server-side. + */ +export function parseRecentChats( + payload: unknown, + limit: number = RECENT_CHATS_TOTAL +): RecentChat[] { + if (typeof payload !== 'object' || payload === null) { + return [] + } + const chats = (payload as { chats?: unknown }).chats + if (!Array.isArray(chats)) { + return [] + } + const result: RecentChat[] = [] + for (const chat of chats) { + if (result.length >= limit) { + break + } + if (typeof chat !== 'object' || chat === null) { + continue + } + const { id, title, workspaceId } = chat as { + id?: unknown + title?: unknown + workspaceId?: unknown + } + if (typeof id !== 'string' || !id || typeof workspaceId !== 'string' || !workspaceId) { + continue + } + result.push({ + id, + title: typeof title === 'string' && title.trim() ? title.trim() : 'Untitled chat', + workspaceId, + status: deriveChatStatus(chat as Record), + }) + } + return result +} + +/** + * Renders a small filled circle as a menu-item icon (menus can't color text, + * so the sidebar's status dot becomes a NativeImage). Drawn at 2x with a 1px + * anti-aliased edge; BGRA premultiplied, as createFromBitmap expects. + */ +function createDotImage(color: { r: number; g: number; b: number }): NativeImage { + const scaleFactor = 2 + const size = 6 * scaleFactor + const buffer = Buffer.alloc(size * size * 4) + const center = (size - 1) / 2 + const radius = size / 2 + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const alpha = Math.max(0, Math.min(1, radius - Math.hypot(x - center, y - center))) + const i = (y * size + x) * 4 + buffer[i] = Math.round(color.b * alpha) + buffer[i + 1] = Math.round(color.g * alpha) + buffer[i + 2] = Math.round(color.r * alpha) + buffer[i + 3] = Math.round(255 * alpha) + } + } + return nativeImage.createFromBitmap(buffer, { width: size, height: size, scaleFactor }) +} + +let dotImages: { active: NativeImage; unread: NativeImage } | null = null + +function statusDotImage(status: RecentChatStatus): NativeImage | undefined { + if (status === 'none') { + return undefined + } + if (!dotImages) { + dotImages = { + active: createDotImage(ACTIVE_DOT_COLOR), + unread: createDotImage(UNREAD_DOT_COLOR), + } + } + return status === 'active' ? dotImages.active : dotImages.unread +} + +export function chatRoute(chat: RecentChat): string { + return `/workspace/${chat.workspaceId}/chat/${chat.id}` +} + +export interface TrayDeps { + partition: () => string + appOrigin: () => string + lastRoute: () => string | undefined + openMainWindow: (route?: string) => void +} + +function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { + const icon = statusDotImage(chat.status) + return { + label: truncate(chat.title, 57, '…'), + ...(icon ? { icon } : {}), + click: () => deps.openMainWindow(chatRoute(chat)), + } +} + +/** + * Menu shape (modeled on ChatGPT's status item): a Recent section with the + * newest chats inline and the rest under a "More" hover submenu, then New + * Chat / Open Sim grouped together, then Quit in its own section. + */ +export function buildTrayMenuTemplate( + deps: TrayDeps, + recentChats: RecentChat[] +): MenuItemConstructorOptions[] { + const template: MenuItemConstructorOptions[] = [] + if (recentChats.length > 0) { + template.push({ label: 'Recent Chats', enabled: false }) + for (const chat of recentChats.slice(0, RECENT_CHATS_INLINE)) { + template.push(chatMenuItem(chat, deps)) + } + const overflow = recentChats.slice(RECENT_CHATS_INLINE) + if (overflow.length > 0) { + template.push({ + label: 'More', + submenu: overflow.map((chat) => chatMenuItem(chat, deps)), + }) + } + template.push({ type: 'separator' }) + } + template.push( + { label: 'New Chat', click: () => deps.openMainWindow(newChatRoute(deps.lastRoute())) }, + { label: 'Open Sim', click: () => deps.openMainWindow() }, + { type: 'separator' }, + // Plain item, not role:'quit' — macOS Tahoe auto-decorates standard roles + // with SF Symbol icons and the ⌘Q badge, which this menu doesn't want. + { label: 'Quit Sim', click: () => app.quit() } + ) + return template +} + +export interface TrayHandle { + /** + * Drops the cached chat titles. Called from the sign-out teardown: the titles + * are the previous user's data and must not outlive their session, and the + * menu pops from cache before it refreshes, so waiting for the next fetch + * would show them one more time. + */ + clearRecentChats(): void + destroy(): void +} + +/** + * The macOS status item. No static context menu is attached — macOS shows an + * attached menu synchronously without emitting 'click', which would freeze + * the recent-chats section at creation time. Instead each click fetches the + * chat list (bounded by a short timeout, falling back to the last good list) + * and pops the freshly built menu. + */ +export function installTray(deps: TrayDeps): TrayHandle | null { + const iconPath = join(app.getAppPath(), 'static', 'tray', 'simTemplate.png') + const sourceIcon = nativeImage.createFromPath(iconPath) + if (sourceIcon.isEmpty()) { + logger.error('Tray icon missing; skipping tray install', { iconPath }) + return null + } + const channel = channelForOrigin(deps.appOrigin()) + const marker = trayEnvironmentMarker(channel) + const icon = marker ? addTrayEnvironmentSubscript(sourceIcon, marker) : sourceIcon + icon.setTemplateImage(true) + + const tray = new Tray(icon) + tray.setToolTip(APP_NAME_FOR_CHANNEL[channel]) + + let cachedChats: RecentChat[] = [] + let refreshing = false + let cacheGeneration = 0 + + const fetchRecentChats = async (): Promise => { + const ses = session.fromPartition(deps.partition()) + const response = await ses.fetch(`${deps.appOrigin()}${CHATS_API_PATH}`, { + credentials: 'include', + signal: AbortSignal.timeout(CHATS_FETCH_TIMEOUT_MS), + }) + // Signed out is an ANSWER, not a failure: the correct list for no user is + // empty. Throwing here would fall into the catch below and leave the + // previous user's chat titles in the menu until someone signed back in. + if (response.status === 401 || response.status === 403) { + return [] + } + if (!response.ok) { + throw new Error(`chats fetch failed: ${response.status}`) + } + return parseRecentChats(await response.json()) + } + + /** Update the cached chat list for the NEXT open; never blocks a click. */ + const refreshChats = async () => { + if (refreshing) return + refreshing = true + const generation = cacheGeneration + try { + const chats = await fetchRecentChats() + // A sign-out while this was in flight invalidates the result — it was + // read with the previous user's cookie. + if (generation === cacheGeneration) { + cachedChats = chats + } + } catch (error) { + // Offline or an older server: keep the last good list rather than + // blanking a menu that is still correct. + logger.info('Recent chats unavailable for tray menu', { error }) + } finally { + refreshing = false + } + } + + /** + * Pop the menu from the cached chat list so the click feels instant, then + * refresh the cache in the background for the next open. One exception: when + * the cache is EMPTY (failed launch warm-up, fresh sign-in) the menu would + * pop without a Recent section and stay wrong until the next click — so wait + * briefly for a refresh, popping no later than the grace period either way. + */ + const EMPTY_CACHE_POP_GRACE_MS = 600 + const popMenu = async () => { + if (cachedChats.length === 0) { + await Promise.race([refreshChats(), sleep(EMPTY_CACHE_POP_GRACE_MS)]) + } + if (tray.isDestroyed()) return + tray.popUpContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, cachedChats))) + void refreshChats() + } + + // Warm the cache with retries: at launch the first fetch races the server + // (dev recompiles, app cold start), and a single failed warm-up would leave + // the first tray open without recents until a second click. + const WARM_UP_BACKOFF_MS = [2_000, 5_000, 10_000, 20_000] + const warmUp = async (attempt = 0) => { + await refreshChats() + if (cachedChats.length === 0 && attempt < WARM_UP_BACKOFF_MS.length && !tray.isDestroyed()) { + setTimeout(() => void warmUp(attempt + 1), WARM_UP_BACKOFF_MS[attempt]).unref?.() + } + } + void warmUp() + + // Keep the cache (and the status dots) current even when the tray hasn't + // been clicked in a while. + const refreshTimer = setInterval(() => void refreshChats(), 60_000) + refreshTimer.unref?.() + + tray.on('click', () => void popMenu()) + tray.on('right-click', () => void popMenu()) + + return { + clearRecentChats() { + cacheGeneration += 1 + cachedChats = [] + }, + destroy() { + clearInterval(refreshTimer) + if (!tray.isDestroyed()) { + tray.destroy() + } + }, + } +} diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts new file mode 100644 index 0000000000..c5d1140851 --- /dev/null +++ b/apps/desktop/src/main/updater.test.ts @@ -0,0 +1,424 @@ +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const autoUpdaterMock = { + channel: '', + allowDowngrade: false, + autoDownload: true, + autoInstallOnAppQuit: false, + logger: null as unknown, + on: vi.fn(), + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(() => Promise.resolve(null)), + downloadUpdate: vi.fn(() => Promise.resolve([])), + quitAndInstall: vi.fn(), +} + +import { app, dialog, shell } from 'electron' +import { + checkForUpdatesInteractive, + feedUrlForOrigin, + initUpdater, + isDowngrade, + isNewerVersion, + parseSemver, + resolveUpdateChannel, +} from '@/main/updater' + +describe('resolveUpdateChannel', () => { + it('maps stable versions to latest', () => { + expect(resolveUpdateChannel('1.2.3')).toBe('latest') + expect(resolveUpdateChannel('0.5.24')).toBe('latest') + }) + + it('maps prerelease versions to their channel', () => { + expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('beta') + expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('alpha') + }) +}) + +describe('parseSemver', () => { + it('parses plain and v-prefixed versions', () => { + expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) + expect(parseSemver('v0.5.24')).toEqual({ major: 0, minor: 5, patch: 24, prerelease: '' }) + expect(parseSemver('1.2.3-beta.1')?.prerelease).toBe('beta.1') + }) + + it('returns null for garbage', () => { + expect(parseSemver('latest')).toBeNull() + expect(parseSemver('1.2')).toBeNull() + expect(parseSemver('')).toBeNull() + }) +}) + +describe('isDowngrade', () => { + it('rejects lower versions', () => { + expect(isDowngrade('1.2.3', '1.2.2')).toBe(true) + expect(isDowngrade('1.2.3', '1.1.9')).toBe(true) + expect(isDowngrade('2.0.0', '1.9.9')).toBe(true) + }) + + it('accepts equal and higher versions', () => { + expect(isDowngrade('1.2.3', '1.2.3')).toBe(false) + expect(isDowngrade('1.2.3', '1.2.4')).toBe(false) + expect(isDowngrade('1.2.3', '2.0.0')).toBe(false) + }) + + it('treats a prerelease of the current stable core as a downgrade', () => { + expect(isDowngrade('1.2.3', '1.2.3-beta.1')).toBe(true) + expect(isDowngrade('1.2.3-beta.1', '1.2.3')).toBe(false) + }) + + it('compares prerelease identifiers within the same core version', () => { + expect(isDowngrade('1.4.0-beta.5', '1.4.0-beta.2')).toBe(true) + expect(isDowngrade('1.4.0-beta.2', '1.4.0-beta.10')).toBe(false) + expect(isDowngrade('1.4.0-beta.2', '1.4.0-beta.2')).toBe(false) + expect(isDowngrade('1.4.0-rc.1', '1.4.0-beta.9')).toBe(true) + }) + + it('treats unparseable versions as downgrades', () => { + expect(isDowngrade('1.2.3', 'nightly')).toBe(true) + expect(isDowngrade('garbage', '1.2.3')).toBe(true) + }) +}) + +describe('isNewerVersion', () => { + it('is true only for strictly newer candidates', () => { + expect(isNewerVersion('1.2.4', '1.2.3')).toBe(true) + expect(isNewerVersion('1.2.4-alpha.3', '1.2.3')).toBe(true) + expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false) + expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false) + }) + + it('never offers an unparseable feed version', () => { + expect(isNewerVersion('latest', '1.2.3')).toBe(false) + expect(isNewerVersion('', '1.2.3')).toBe(false) + }) +}) + +describe('feedUrlForOrigin', () => { + it('builds the per-env feed URL from the configured origin', () => { + expect(feedUrlForOrigin('https://www.dev.sim.ai')).toBe( + 'https://www.dev.sim.ai/api/desktop/update' + ) + expect(feedUrlForOrigin('http://localhost:3000')).toBe( + 'http://localhost:3000/api/desktop/update' + ) + }) + + it('rejects non-http origins and garbage', () => { + expect(feedUrlForOrigin('file:///tmp/app')).toBeNull() + expect(feedUrlForOrigin('not a url')).toBeNull() + }) +}) + +describe('initUpdater state machine', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + function emit(event: string, ...args: unknown[]) { + for (const [name, listener] of autoUpdaterMock.on.mock.calls) { + if (name === event) { + ;(listener as (...values: unknown[]) => void)(...args) + } + } + } + + async function createUpdater(options?: { autoDownload?: boolean; feedAvailable?: boolean }) { + const states: DesktopUpdateState[] = [] + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + autoDownload: () => options?.autoDownload ?? true, + onStateChange: (state) => states.push(state), + loadAutoUpdater: () => + autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], + probeOriginFeed: async () => options?.feedAvailable ?? false, + canSelfUpdate: async () => true, + }) + // Engine selection (signature detection) resolves asynchronously. + await vi.advanceTimersByTimeAsync(0) + return { handle, states } + } + + beforeEach(() => { + vi.useFakeTimers() + autoUpdaterMock.on.mockClear() + autoUpdaterMock.setFeedURL.mockClear() + autoUpdaterMock.checkForUpdates.mockClear() + autoUpdaterMock.downloadUpdate.mockClear() + autoUpdaterMock.quitAndInstall.mockClear() + // Keep the update-downloaded dialog from resolving into quitAndInstall. + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('walks check -> download -> ready and installs only from ready', async () => { + const { handle, states } = await createUpdater() + expect(handle.getState()).toEqual({ status: 'idle' }) + + handle.install() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + emit('checking-for-update') + emit('update-available', { version: '2.0.0' }) + emit('download-progress', { percent: 41.7 }) + emit('update-downloaded', { version: '2.0.0' }) + + expect(states).toEqual([ + { status: 'checking' }, + { status: 'downloading', version: '2.0.0' }, + { status: 'downloading', version: '2.0.0', percent: 42 }, + { status: 'ready', version: '2.0.0' }, + ]) + + handle.install() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('stops at available and downloads on demand when auto-download is off', async () => { + autoUpdaterMock.autoDownload = false + const { handle } = await createUpdater({ autoDownload: false }) + + emit('update-available', { version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) + + handle.check() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('checks from idle and ignores re-entrant checks while busy', async () => { + const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + emit('checking-for-update') + handle.check() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + it('resets to idle when a downloaded update is a blocked downgrade', async () => { + const { handle } = await createUpdater() + emit('update-downloaded', { version: '0.0.1' }) + expect(handle.getState()).toEqual({ status: 'idle' }) + handle.install() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + }) + + it('surfaces updater errors and recovers via update-not-available', async () => { + const { handle } = await createUpdater() + emit('error', new Error('feed unreachable')) + expect(handle.getState()).toEqual({ status: 'error' }) + emit('update-not-available') + expect(handle.getState()).toEqual({ status: 'idle' }) + }) + + it('switches to the per-env origin feed when the origin serves one', async () => { + await createUpdater({ feedAvailable: true }) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: 'https://www.dev.sim.ai/api/desktop/update', + channel: 'latest', + }) + expect(autoUpdaterMock.channel).toBe('latest') + }) + + it('keeps the packaged GitHub feed when the origin has no feed', async () => { + await createUpdater({ feedAvailable: false }) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + }) + + it('skips checks on prerelease builds when the origin feed is down', async () => { + // The GitHub fallback is stable-only: a Sim Dev shell can never apply a + // prod-identity artifact, so it must not check against it. + vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + try { + const { handle } = await createUpdater({ feedAvailable: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) + + it('checks prerelease builds normally through the origin feed', async () => { + vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + try { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) +}) + +function manifest(version: string): string { + return [ + `version: ${version}`, + 'files:', + ` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + ' sha512: abc', + ` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal.dmg`, + ' sha512: def', + `path: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') +} + +describe('initUpdater manual mode (no Developer ID signature)', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + async function createManualUpdater(fetchManifest: (url: string) => Promise) { + const states: DesktopUpdateState[] = [] + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + onStateChange: (state) => states.push(state), + canSelfUpdate: async () => false, + fetchManifest, + }) + await vi.advanceTimersByTimeAsync(0) + return { handle, states } + } + + beforeEach(() => { + vi.useFakeTimers() + events.record.mockClear() + vi.mocked(shell.openExternal).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('offers a newer feed version as a manual download of the dmg', async () => { + const fetchManifest = vi.fn(async () => manifest('9.9.9')) + const { handle } = await createManualUpdater(fetchManifest) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(fetchManifest).toHaveBeenCalledWith( + 'https://www.dev.sim.ai/api/desktop/update/latest-mac.yml' + ) + expect(handle.getState()).toEqual({ status: 'available', version: '9.9.9', manual: true }) + + // The `available` advance opens the browser instead of downloading. + handle.check() + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-universal.dmg' + ) + + // install() from manual `available` opens the same download. + handle.install() + expect(shell.openExternal).toHaveBeenCalledTimes(2) + }) + + it('stays idle when the feed version is not newer', async () => { + const { handle } = await createManualUpdater(async () => manifest(app.getVersion())) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('stays idle when the origin serves no feed', async () => { + const { handle } = await createManualUpdater(async () => null) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + }) + + it('surfaces manifest fetch failures as errors', async () => { + const { handle } = await createManualUpdater(async () => { + throw new Error('network down') + }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + }) + + it('checks on the scheduled interval', async () => { + const fetchManifest = vi.fn(async () => manifest('9.9.9')) + await createManualUpdater(fetchManifest) + await vi.advanceTimersByTimeAsync(10_000) + expect(fetchManifest).toHaveBeenCalledTimes(1) + }) +}) + +describe('checkForUpdatesInteractive', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + async function manualHandle(version: string) { + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest: async () => manifest(version), + }) + await vi.advanceTimersByTimeAsync(0) + return handle + } + + beforeEach(() => { + vi.useFakeTimers() + ;(app as unknown as { isPackaged: boolean }).isPackaged = true + events.record.mockClear() + vi.mocked(dialog.showMessageBox).mockClear() + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) + vi.mocked(shell.openExternal).mockClear() + }) + + afterEach(() => { + ;(app as unknown as { isPackaged: boolean }).isPackaged = false + vi.useRealTimers() + }) + + it('offers the manual download and opens it on Download', async () => { + const handle = await manualHandle('9.9.9') + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false }) + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Sim 9.9.9 is available' }) + ) + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-universal.dmg' + ) + }) + + it('reports up to date when the feed has nothing newer', async () => { + const handle = await manualHandle(app.getVersion()) + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Sim is up to date' }) + ) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('only explains packaged-build updates when unpackaged', async () => { + ;(app as unknown as { isPackaged: boolean }).isPackaged = false + checkForUpdatesInteractive({ getWindow: () => null, events, handle: null }) + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Updates are only available in packaged builds' }) + ) + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 0000000000..82d3a16f32 --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,630 @@ +import { execFile } from 'node:child_process' +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { BrowserWindow } from 'electron' +import { app, dialog, net, shell } from 'electron' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopUpdater') + +const INITIAL_CHECK_DELAY_MS = 10_000 +const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 + +export type UpdateChannel = 'latest' | 'beta' | 'alpha' + +/** + * The per-environment update feed served by the Sim deployment this shell is + * pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins + * which shell build its clients are offered — dev serves alpha builds, + * staging beta, prod stable — so the environment, not the client, is the + * channel. Returns null for origins that can't host a feed. + */ +export function feedUrlForOrigin(origin: string): string | null { + try { + const url = new URL(origin) + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + return `${url.origin}/api/desktop/update` + } catch { + return null + } +} + +/** + * Maps the running version to its update channel: prerelease builds follow + * their prerelease channel, stable builds only ever see stable releases. + * Channels are strictly isolated — alpha/beta builds carry their own app + * identity (Sim Dev / Sim Staging) and update only via their origin feed; + * the packaged GitHub fallback feed is stable-only. + */ +export function resolveUpdateChannel(version: string): UpdateChannel { + if (version.includes('-alpha')) { + return 'alpha' + } + if (version.includes('-beta')) { + return 'beta' + } + return 'latest' +} + +interface ParsedSemver { + major: number + minor: number + patch: number + prerelease: string +} + +/** + * Minimal semver parser for the defensive downgrade check (electron-updater + * already enforces allowDowngrade=false; this guards against a tampered or + * misconfigured feed). + */ +export function parseSemver(version: string): ParsedSemver | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim()) + if (!match) { + return null + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? '', + } +} + +/** + * Compares two prerelease strings by semver precedence: a missing prerelease + * outranks any prerelease, dotted identifiers compare left to right, numeric + * identifiers compare numerically and rank below alphanumeric ones, and a + * shorter identifier set ranks lower when all preceding fields are equal. + * Returns <0 when a precedes b, 0 when equal, >0 when a follows b. + */ +function comparePrerelease(a: string, b: string): number { + if (a === b) { + return 0 + } + if (a === '') { + return 1 + } + if (b === '') { + return -1 + } + const as = a.split('.') + const bs = b.split('.') + for (let i = 0; i < Math.max(as.length, bs.length); i++) { + const ai = as[i] + const bi = bs[i] + if (ai === undefined) { + return -1 + } + if (bi === undefined) { + return 1 + } + const aNum = /^\d+$/.test(ai) + const bNum = /^\d+$/.test(bi) + if (aNum && bNum) { + const diff = Number(ai) - Number(bi) + if (diff !== 0) { + return diff < 0 ? -1 : 1 + } + } else if (aNum) { + return -1 + } else if (bNum) { + return 1 + } else if (ai !== bi) { + return ai < bi ? -1 : 1 + } + } + return 0 +} + +/** + * True when candidate is a lower version than current, including a lower + * prerelease of the same core version. Unparseable versions are treated as + * downgrades and rejected. + */ +export function isDowngrade(currentVersion: string, candidateVersion: string): boolean { + const current = parseSemver(currentVersion) + const candidate = parseSemver(candidateVersion) + if (!current || !candidate) { + return true + } + const currentCore = [current.major, current.minor, current.patch] + const candidateCore = [candidate.major, candidate.minor, candidate.patch] + for (let i = 0; i < 3; i++) { + if (candidateCore[i] !== currentCore[i]) { + return candidateCore[i] < currentCore[i] + } + } + return comparePrerelease(candidate.prerelease, current.prerelease) < 0 +} + +export interface UpdaterDeps { + getWindow: () => BrowserWindow | null + events: EventRecorder + /** The Sim origin this shell is pointed at — hosts the per-env update feed. */ + appOrigin: () => string + autoDownload?: () => boolean + /** Pushed on every pipeline state change (renderer update UI). */ + onStateChange?: (state: DesktopUpdateState) => void + /** Test seam: overrides the lazy electron-updater load. */ + loadAutoUpdater?: () => typeof import('electron-updater')['autoUpdater'] + /** Test seam: overrides the origin feed availability probe. */ + probeOriginFeed?: (feedUrl: string) => Promise + /** + * Test seam: overrides Squirrel self-update capability detection (whether + * the running bundle carries a real Developer ID signature). + */ + canSelfUpdate?: () => Promise + /** Test seam: overrides the manual-mode manifest fetch (body or null). */ + fetchManifest?: (url: string) => Promise +} + +export interface UpdaterHandle { + setAutoDownload(enabled: boolean): void + /** Current pipeline state for the renderer update UI. */ + getState(): DesktopUpdateState + /** + * Renderer-initiated advance: checks for an update, or starts the download + * when one is already known to be available (auto-download off / manual). + */ + check(): void + /** + * Quit and install a downloaded update (`ready`), or open the manual + * download for an `available` update on a shell that can't self-update. + */ + install(): void + /** Main-process state subscription (menu feedback). Returns unsubscribe. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +const NOOP_UPDATER_HANDLE: UpdaterHandle = { + setAutoDownload: () => {}, + getState: () => ({ status: 'idle' }), + check: () => {}, + install: () => {}, + onState: () => () => {}, +} + +/** True when candidate is strictly newer than current. */ +export function isNewerVersion(candidateVersion: string, currentVersion: string): boolean { + if (!parseSemver(candidateVersion)) { + return false + } + return isDowngrade(candidateVersion, currentVersion) +} + +/** + * Whether Squirrel.Mac can swap this bundle in place. It validates a + * downloaded update against the running app's code signature, so only builds + * carrying a real Developer ID (a TeamIdentifier) can self-update. Local + * `install:local` builds and pre-signing CI prereleases are ad-hoc signed + * (`TeamIdentifier=not set`) and would fail the swap — those shells get the + * manual pipeline instead. + */ +async function detectSelfUpdateCapability(): Promise { + if (process.platform !== 'darwin') { + return true + } + const exe = app.getPath('exe') + const bundleEnd = exe.indexOf('.app/') + if (bundleEnd < 0) { + return false + } + const bundlePath = exe.slice(0, bundleEnd + 4) + return new Promise((resolve) => { + execFile('codesign', ['-dv', '--verbose=2', bundlePath], (error, _stdout, stderr) => { + if (error) { + resolve(false) + return + } + const team = /^TeamIdentifier=(.+)$/m.exec(stderr ?? '') + resolve(team !== null && team[1].trim() !== 'not set') + }) + }) +} + +/** + * One update pipeline behind the shared handle: `check` looks for an update, + * `advance` performs the `available` action (background download vs opening + * the download in the browser), `install` performs the `ready` action. + */ +interface UpdateEngine { + check(): void + advance(): void + install(): void + setAutoDownload(enabled: boolean): void +} + +/** + * Keeps installed shells current against the per-environment update feed: + * checks on launch and every four hours, and mirrors pipeline state to the + * renderer for the settings update UI and the minimum-shell-version gate. + * + * Developer-ID-signed builds use electron-updater (background download, + * install on user confirmation — never mid-session without consent). Builds + * that can't self-update (ad-hoc signed: local installs, pre-signing CI + * prereleases) still poll the same feed but surface `available` as a manual + * download link, so the whole pipeline is testable before signing exists. + */ +export function initUpdater(deps: UpdaterDeps): UpdaterHandle { + if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { + return NOOP_UPDATER_HANDLE + } + + const currentVersion = app.getVersion() + let state: DesktopUpdateState = { status: 'idle' } + const listeners = new Set<(state: DesktopUpdateState) => void>() + const setState = (next: DesktopUpdateState) => { + state = next + deps.onStateChange?.(next) + for (const listener of listeners) { + listener(next) + } + } + + const buildAutoEngine = (): UpdateEngine | null => { + let autoUpdater: typeof import('electron-updater')['autoUpdater'] + try { + autoUpdater = deps.loadAutoUpdater + ? deps.loadAutoUpdater() + : (require('electron-updater') as typeof import('electron-updater')).autoUpdater + } catch (error) { + logger.error('electron-updater unavailable', { error }) + return null + } + + autoUpdater.channel = resolveUpdateChannel(currentVersion) + autoUpdater.allowDowngrade = false + autoUpdater.autoDownload = deps.autoDownload?.() ?? true + // Never install without vetting the downloaded version first. Enabled per + // download in the update-downloaded handler, but only for accepted updates + // — so a blocked/downgrade build that was already downloaded is never + // silently installed on quit. + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.logger = null + + autoUpdater.on('checking-for-update', () => { + setState({ status: 'checking' }) + }) + + autoUpdater.on('update-not-available', () => { + setState({ status: 'idle' }) + }) + + autoUpdater.on('update-available', (info) => { + deps.events.record('update_check', { available: info.version }) + // With auto-download on, download-progress events follow immediately; + // `available` is the terminal state only when downloads are manual. + setState({ + status: autoUpdater.autoDownload ? 'downloading' : 'available', + version: info.version, + }) + }) + + autoUpdater.on('download-progress', (progress) => { + setState({ + status: 'downloading', + version: state.version, + percent: Math.round(progress.percent), + }) + }) + + autoUpdater.on('update-downloaded', (info) => { + if (isDowngrade(currentVersion, info.version)) { + autoUpdater.autoInstallOnAppQuit = false + deps.events.record('update_blocked_version', { version: info.version }) + setState({ status: 'idle' }) + return + } + autoUpdater.autoInstallOnAppQuit = true + deps.events.record('update_downloaded', { version: info.version }) + setState({ status: 'ready', version: info.version }) + const win = deps.getWindow() + const options = { + type: 'info' as const, + buttons: ['Restart Now', 'Later'], + defaultId: 0, + cancelId: 1, + message: `Sim ${info.version} is ready to install`, + detail: 'Restart to finish updating. If you choose Later, the update installs on quit.', + } + const prompt = win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options) + void prompt.then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall() + } + }) + }) + + autoUpdater.on('error', (error) => { + deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version }) + }) + + /** + * Prefer the per-environment feed served by the configured origin; fall + * back to the packaged GitHub feed when the origin doesn't serve one (an + * older or partial deployment). The origin feed is channel-resolved + * server-side, so the client always requests plain `latest-mac.yml`. + * + * The fallback is stable-only: prerelease (dev/staging) builds exist + * solely on their origin feed, and the GitHub feed could only offer them + * a stable prod-identity artifact their Squirrel identity can't apply — + * so when the origin feed is down, a prerelease shell skips checking + * rather than erroring on every cycle. Resolves to whether checks may run. + */ + const probeOriginFeed = + deps.probeOriginFeed ?? + (async (feedUrl: string) => { + const response = await net.fetch(`${feedUrl}/latest-mac.yml`) + return response.ok + }) + const feedConfigured: Promise = (async () => { + const feedUrl = feedUrlForOrigin(deps.appOrigin()) + const stableBuild = resolveUpdateChannel(currentVersion) === 'latest' + if (!feedUrl) { + return stableBuild + } + try { + if (!(await probeOriginFeed(feedUrl))) { + throw new Error('feed responded non-OK') + } + autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' }) + autoUpdater.channel = 'latest' + deps.events.record('update_feed', { url: feedUrl }) + return true + } catch (error) { + logger.warn( + stableBuild + ? 'Origin update feed unavailable; using default GitHub feed' + : 'Origin update feed unavailable; prerelease build skips update checks', + { feedUrl, message: getErrorMessage(error, 'unknown') } + ) + return stableBuild + } + })() + + return { + check() { + void feedConfigured.then((mayCheck) => { + if (!mayCheck) { + return + } + autoUpdater.checkForUpdates().catch((error) => { + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + }) + }) + }, + advance() { + autoUpdater.downloadUpdate().catch((error) => { + logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version }) + }) + }, + install() { + autoUpdater.quitAndInstall() + }, + setAutoDownload(enabled) { + autoUpdater.autoDownload = enabled + }, + } + } + + const buildManualEngine = (): UpdateEngine => { + const fetchManifest = + deps.fetchManifest ?? + (async (url: string) => { + const response = await net.fetch(url) + return response.ok ? await response.text() : null + }) + let downloadUrl: string | null = null + + const doCheck = async () => { + setState({ status: 'checking', manual: true }) + try { + const feedUrl = feedUrlForOrigin(deps.appOrigin()) + const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null + if (!manifest || !version || !isNewerVersion(version, currentVersion)) { + setState({ status: 'idle', manual: true }) + return + } + // The feed rewrites manifest urls to absolute GitHub asset URLs; + // prefer the dmg for a human download. + const urls = Array.from(manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1]) + downloadUrl = + urls.find((url) => url.endsWith('.dmg')) ?? + urls.find((url) => url.endsWith('.zip')) ?? + urls[0] ?? + null + deps.events.record('update_check', { available: version, manual: true }) + setState({ status: 'available', version, manual: true }) + } catch (error) { + logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version, manual: true }) + } + } + + const openDownload = () => { + if (downloadUrl) { + deps.events.record('update_manual_download', { url: downloadUrl }) + void shell.openExternal(downloadUrl) + } + } + + return { + check: () => void doCheck(), + advance: openDownload, + install: openDownload, + setAutoDownload: () => {}, + } + } + + let engine: UpdateEngine | null = null + let pendingAutoDownload: boolean | null = null + + const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability + void canSelfUpdate() + .catch(() => true) + .then((capable) => { + engine = capable ? buildAutoEngine() : buildManualEngine() + if (!engine) { + return + } + if (pendingAutoDownload !== null) { + engine.setAutoDownload(pendingAutoDownload) + } + if (!capable) { + deps.events.record('update_manual_mode', {}) + } + const check = () => engine?.check() + setTimeout(check, INITIAL_CHECK_DELAY_MS) + setInterval(check, CHECK_INTERVAL_MS) + }) + + return { + setAutoDownload(enabled) { + if (engine) { + engine.setAutoDownload(enabled) + } else { + pendingAutoDownload = enabled + } + }, + getState: () => state, + check() { + if (!engine || state.status === 'checking' || state.status === 'downloading') { + return + } + if (state.status === 'available') { + engine.advance() + return + } + engine.check() + }, + install() { + if (!engine) { + return + } + if (state.status === 'ready') { + engine.install() + return + } + if (state.status === 'available' && state.manual) { + engine.advance() + } + }, + onState(callback) { + listeners.add(callback) + return () => { + listeners.delete(callback) + } + }, + } +} + +const INTERACTIVE_CHECK_TIMEOUT_MS = 30_000 + +/** + * Menu-triggered manual check with user-visible feedback. A thin dialog layer + * over the updater handle — it drives whichever pipeline initUpdater selected + * (electron-updater or the manual feed poll), so a shell that can't + * self-update gets a working "Download" dialog instead of a Squirrel error. + */ +export function checkForUpdatesInteractive( + deps: Pick & { handle: UpdaterHandle | null } +): void { + if (!app.isPackaged) { + void dialog.showMessageBox({ + type: 'info', + message: 'Updates are only available in packaged builds', + }) + return + } + const handle = deps.handle + if (!handle) { + return + } + deps.events.record('update_check', { manual: true }) + + const showDialog = (options: Electron.MessageBoxOptions) => { + const win = deps.getWindow() + return win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options) + } + + const settle = (state: DesktopUpdateState) => { + switch (state.status) { + case 'available': { + const label = state.version ? `Sim ${state.version} is available` : 'An update is available' + void showDialog({ + type: 'info', + buttons: ['Download', 'Later'], + defaultId: 0, + cancelId: 1, + message: label, + detail: state.manual + ? 'This build updates manually: the download opens in your browser, then replace the installed app.' + : 'The update downloads in the background and installs when you restart.', + }).then(({ response }) => { + if (response !== 0) { + return + } + if (state.manual) { + handle.install() + } else { + handle.check() + } + }) + return + } + case 'downloading': + void showDialog({ + type: 'info', + message: state.version ? `Downloading Sim ${state.version}…` : 'Downloading update…', + detail: 'You will be prompted to restart when it is ready.', + }) + return + case 'ready': + // The download pipeline already shows its own restart prompt. + return + case 'error': + void showDialog({ + type: 'error', + message: 'Could not check for updates', + detail: 'Something went wrong reaching the update server. Try again later.', + }) + return + default: + void showDialog({ type: 'info', message: 'Sim is up to date' }) + } + } + + const initial = handle.getState() + // Already mid-pipeline (or an update already found): report it directly + // instead of advancing the pipeline behind a "check" click. + if (initial.status !== 'idle' && initial.status !== 'checking' && initial.status !== 'error') { + settle(initial) + return + } + + let settled = false + const finish = (state: DesktopUpdateState) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + unsubscribe() + settle(state) + } + const unsubscribe = handle.onState((state) => { + if (state.status === 'checking') { + return + } + finish(state) + }) + const timeout = setTimeout(() => finish(handle.getState()), INTERACTIVE_CHECK_TIMEOUT_MS) + handle.check() +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts new file mode 100644 index 0000000000..97096cb5c7 --- /dev/null +++ b/apps/desktop/src/main/window.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow } from 'electron' +import type { ConfigStore } from '@/main/config' +import type { EventRecorder } from '@/main/observability' +import { + backgroundColorFor, + createMainWindow, + createSecureWebPreferences, + resolvePermission, + sanitizeBounds, +} from '@/main/window' + +const APP = 'https://sim.ai' + +describe('resolvePermission', () => { + it('allows sanitized clipboard writes from the trusted origin', () => { + expect(resolvePermission('clipboard-sanitized-write', APP, APP)).toBe(true) + expect(resolvePermission('clipboard-sanitized-write', 'https://evil.example', APP)).toBe(false) + expect(resolvePermission('clipboard-sanitized-write', '', APP)).toBe(false) + }) + + it('allows clipboard reads from the trusted origin only, so terminal Paste works', () => { + expect(resolvePermission('clipboard-read', APP, APP)).toBe(true) + expect(resolvePermission('clipboard-read', 'https://evil.example', APP)).toBe(false) + expect(resolvePermission('clipboard-read', '', APP)).toBe(false) + }) + + it('default-denies everything else, including media and unknown future permissions', () => { + for (const permission of [ + 'media', + 'geolocation', + 'notifications', + 'camera', + 'midi', + 'pointerLock', + 'openExternal', + 'some-future-permission', + ]) { + expect(resolvePermission(permission, APP, APP)).toBe(false) + } + }) +}) + +describe('backgroundColorFor', () => { + it('matches the persisted web-app theme', () => { + expect(backgroundColorFor('dark', false)).toBe('#0c0c0c') + expect(backgroundColorFor('light', true)).toBe('#ffffff') + }) + + it('falls back to the system theme before first capture', () => { + expect(backgroundColorFor(undefined, true)).toBe('#0c0c0c') + expect(backgroundColorFor(undefined, false)).toBe('#ffffff') + }) +}) + +describe('sanitizeBounds', () => { + it('passes plausible bounds through', () => { + const bounds = { x: 20, y: 40, width: 1200, height: 800 } + expect(sanitizeBounds(bounds)).toEqual(bounds) + }) + + it('drops implausible or malformed bounds', () => { + expect(sanitizeBounds(undefined)).toBeUndefined() + expect(sanitizeBounds({ width: 10, height: 10 })).toBeUndefined() + expect(sanitizeBounds({ width: Number.NaN, height: 800 })).toBeUndefined() + }) + + it('drops only the position when coordinates are malformed', () => { + expect(sanitizeBounds({ x: Number.NaN, y: 0, width: 1200, height: 800 })).toEqual({ + width: 1200, + height: 800, + }) + }) +}) + +describe('createSecureWebPreferences', () => { + it('locks down the renderer', () => { + const prefs = createSecureWebPreferences('persist:sim', '/tmp/preload.cjs', true) + expect(prefs).toMatchObject({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: false, + partition: 'persist:sim', + preload: '/tmp/preload.cjs', + }) + }) + + it('enables DevTools only for unpackaged builds', () => { + expect(createSecureWebPreferences('persist:sim', '/p', false).devTools).toBe(true) + }) + + it('passes the shell version to the preload as an argv flag', () => { + expect(createSecureWebPreferences('persist:sim', '/p', true).additionalArguments).toEqual([ + '--sim-desktop-version=1.0.0', + ]) + }) +}) + +describe('createMainWindow', () => { + it('keeps the native macOS fullscreen titlebar blank', () => { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + + const win = createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + platform: 'darwin', + }) + + const MockBrowserWindow = BrowserWindow as typeof BrowserWindow & { + lastOptions?: Record + } + const windowEventCalls = vi.mocked(win.on).mock.calls as unknown as Array< + [string, (...args: unknown[]) => unknown] + > + + expect(MockBrowserWindow.lastOptions?.title).toBe('Sim') + // The overlay is what publishes `titlebar-area-*` to the page, so the web + // app can reserve the traffic-light lane from the platform rather than from + // pixels that shrink under page zoom while the OS-drawn lights do not. + expect(MockBrowserWindow.lastOptions).toMatchObject({ + titleBarStyle: 'hiddenInset', + titleBarOverlay: true, + trafficLightPosition: { x: 12, y: 12 }, + }) + + const pageTitleHandler = windowEventCalls.find( + ([event]) => event === 'page-title-updated' + )?.[1] as ((event: { preventDefault: () => void }) => void) | undefined + const enterFullscreenHandler = windowEventCalls.find( + ([event]) => event === 'enter-full-screen' + )?.[1] as (() => void) | undefined + const leaveFullscreenHandler = windowEventCalls.find( + ([event]) => event === 'leave-full-screen' + )?.[1] as (() => void) | undefined + + enterFullscreenHandler?.() + expect(win.setTitle).toHaveBeenLastCalledWith('') + + vi.mocked(win.isFullScreen).mockReturnValue(true) + const event = { preventDefault: vi.fn() } + pageTitleHandler?.(event) + + expect(pageTitleHandler).toBeTypeOf('function') + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(win.setTitle).toHaveBeenLastCalledWith('') + + leaveFullscreenHandler?.() + expect(win.setTitle).toHaveBeenLastCalledWith('Sim') + }) + + it('lets the OS cascade secondary windows instead of reusing the saved position', () => { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => ({ x: 40, y: 60, width: 1200, height: 800 })), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + + createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + restorePosition: false, + }) + + const MockBrowserWindow = BrowserWindow as typeof BrowserWindow & { + lastOptions?: Record + } + expect(MockBrowserWindow.lastOptions).toMatchObject({ width: 1200, height: 800 }) + expect(MockBrowserWindow.lastOptions?.x).toBeUndefined() + expect(MockBrowserWindow.lastOptions?.y).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts new file mode 100644 index 0000000000..e5bdfb979b --- /dev/null +++ b/apps/desktop/src/main/window.ts @@ -0,0 +1,352 @@ +import { createLogger } from '@sim/logger' +import type { Session, WebPreferences } from 'electron' +import { app, BrowserWindow, dialog, nativeTheme } from 'electron' +import { type ConfigStore, isSafeInternalPath, type WindowBounds } from '@/main/config' +import { isAppOrigin, isAuthSurfacePath } from '@/main/navigation' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopWindow') + +const DARK_BACKGROUND = '#0c0c0c' +const LIGHT_BACKGROUND = '#ffffff' +const DEFAULT_WIDTH = 1360 +const DEFAULT_HEIGHT = 860 +const MIN_WIDTH = 800 +const MIN_HEIGHT = 600 +const WINDOW_TITLE = 'Sim' +const BOUNDS_SAVE_DELAY_MS = 400 +const ROUTE_SAVE_DELAY_MS = 500 + +const THEME_PROBE_SCRIPT = `(() => { + try { + return document.documentElement.classList.contains('dark') + } catch { + return null + } +})()` + +/** + * The hardened webPreferences shared by the main window and any child window. + * The preload injects nothing into the page; it only exposes a whitelisted + * IPC bridge. The shell version rides in as a preload argv flag so the web + * app can enforce its minimum shell version without an IPC round-trip. + */ +export function createSecureWebPreferences( + partition: string, + preloadPath: string, + isPackaged: boolean +): WebPreferences { + return { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: !isPackaged, + spellcheck: true, + partition, + preload: preloadPath, + additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], + } +} + +/** + * The permission matrix: clipboard access for the trusted app origin, + * default-deny for everything else including unknown future permissions + * (media/camera/microphone stay denied). + * + * Clipboard reads are what the terminal's Paste action runs on — xterm has no + * native paste target to fall back to, so a denied read is a Paste that fails. + * The grant is scoped to the app's own origin, which already reaches far more + * sensitive surfaces through the preload bridge, so it widens nothing that a + * compromise of that origin would not already own. + */ +export function resolvePermission( + permission: string, + requestingOrigin: string, + appOrigin: string +): boolean { + if (!requestingOrigin || requestingOrigin !== appOrigin) { + return false + } + return permission === 'clipboard-sanitized-write' || permission === 'clipboard-read' +} + +function originOf(raw: string): string { + try { + return new URL(raw).origin + } catch { + return '' + } +} + +/** + * Installs both permission handlers (request + check) on a session from the + * shared permission matrix. + */ +export function setupPermissionHandlers(session: Session, getAppOrigin: () => string): void { + session.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = details.requestingUrl || webContents?.getURL() || '' + callback(resolvePermission(permission, originOf(requestingUrl), getAppOrigin())) + }) + + session.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { + return resolvePermission(permission, originOf(requestingOrigin), getAppOrigin()) + }) +} + +/** + * Picks the pre-paint window background from the persisted web-app theme so + * dark-mode users never see a white flash before the remote page paints. + */ +export function backgroundColorFor( + theme: 'dark' | 'light' | undefined, + systemPrefersDark: boolean +): string { + if (theme === 'dark') { + return DARK_BACKGROUND + } + if (theme === 'light') { + return LIGHT_BACKGROUND + } + return systemPrefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND +} + +/** + * Drops persisted bounds that are malformed or implausibly small so a bad + * settings file can never produce an unusable window. + */ +export function sanitizeBounds(bounds: WindowBounds | undefined): WindowBounds | undefined { + if (!bounds) { + return undefined + } + const { x, y, width, height } = bounds + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return undefined + } + if (width < MIN_WIDTH || height < MIN_HEIGHT) { + return undefined + } + if ((x !== undefined && !Number.isFinite(x)) || (y !== undefined && !Number.isFinite(y))) { + return { width, height } + } + return bounds +} + +export interface CreateMainWindowDeps { + config: ConfigStore + events: EventRecorder + appOrigin: () => string + partition: string + preloadPath: string + isPackaged: boolean + onClosed: () => void + onFullScreenChange?: (isFullScreen: boolean) => void + /** + * Restores the persisted screen position for the first window. Secondary + * windows omit it so the OS can cascade them instead of stacking every + * window at the exact same coordinates. + */ + restorePosition?: boolean + /** Injectable for platform-specific window behavior tests. */ + platform?: NodeJS.Platform +} + +/** + * Creates the hardened main window: persisted bounds and zoom, theme-matched + * background, beforeunload passthrough, renderer crash/hang recovery, and + * last-route tracking for relaunch restore. + */ +export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { + const bounds = sanitizeBounds(deps.config.get('windowBounds')) + const restorePosition = deps.restorePosition ?? true + const platform = deps.platform ?? process.platform + const win = new BrowserWindow({ + title: WINDOW_TITLE, + width: bounds?.width ?? DEFAULT_WIDTH, + height: bounds?.height ?? DEFAULT_HEIGHT, + x: restorePosition ? bounds?.x : undefined, + y: restorePosition ? bounds?.y : undefined, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + // No separate title bar: the page renders full-bleed to the window's top + // edge with the traffic lights inset over it (Codex-style). + ...(platform === 'darwin' + ? { + titleBarStyle: 'hiddenInset' as const, + // Explicit so the published `titlebar-area-*` geometry is stable. + trafficLightPosition: { x: 12, y: 12 }, + // Publishes the traffic lights' geometry (81x38 DIP) as the + // `titlebar-area-*` CSS env vars, which Chromium rescales under page + // zoom so the reserved lane holds its physical size. + titleBarOverlay: true, + } + : {}), + show: false, + backgroundColor: backgroundColorFor( + deps.config.get('themeBackground'), + nativeTheme.shouldUseDarkColors + ), + webPreferences: createSecureWebPreferences(deps.partition, deps.preloadPath, deps.isPackaged), + }) + + win.once('ready-to-show', () => { + win.show() + }) + + let boundsTimer: NodeJS.Timeout | undefined + const persistBounds = () => { + clearTimeout(boundsTimer) + boundsTimer = setTimeout(() => { + if (win.isDestroyed() || win.isFullScreen() || win.isMaximized()) { + return + } + deps.config.set('windowBounds', win.getNormalBounds()) + }, BOUNDS_SAVE_DELAY_MS) + } + win.on('resize', persistBounds) + win.on('move', persistBounds) + win.on('enter-full-screen', () => { + if (platform === 'darwin') { + win.setTitle('') + } + deps.onFullScreenChange?.(true) + }) + win.on('leave-full-screen', () => { + if (platform === 'darwin') { + win.setTitle(WINDOW_TITLE) + } + deps.onFullScreenChange?.(false) + }) + win.on('page-title-updated', (event) => { + if (platform === 'darwin' && win.isFullScreen()) { + event.preventDefault() + win.setTitle('') + } + }) + + win.webContents.on('will-prevent-unload', (event) => { + const choice = dialog.showMessageBoxSync(win, { + type: 'question', + buttons: ['Leave', 'Stay'], + defaultId: 0, + cancelId: 1, + message: 'Leave Sim?', + detail: 'Changes you made may not be saved.', + }) + if (choice === 0) { + event.preventDefault() + } + }) + + win.webContents.on('render-process-gone', (_event, details) => { + if (details.reason === 'clean-exit') { + return + } + deps.events.record('renderer_gone', { + reason: details.reason, + exitCode: details.exitCode, + crashDumpDir: app.getPath('crashDumps'), + }) + setTimeout(() => { + if (win.isDestroyed()) { + return + } + void dialog + .showMessageBox(win, { + type: 'error', + buttons: ['Reload', 'Quit Sim'], + defaultId: 0, + cancelId: 0, + message: 'Sim encountered a problem', + detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', + }) + .then(({ response }) => { + if (win.isDestroyed()) { + return + } + if (response === 0) { + win.webContents.reload() + } else { + app.quit() + } + }) + }, 0) + }) + + let hangDialogOpen = false + win.webContents.on('unresponsive', () => { + if (hangDialogOpen || win.isDestroyed()) { + return + } + hangDialogOpen = true + deps.events.record('renderer_unresponsive') + void dialog + .showMessageBox(win, { + type: 'warning', + buttons: ['Wait', 'Reload'], + defaultId: 0, + cancelId: 0, + message: 'Sim isn’t responding', + detail: 'You can wait for it to recover or reload the page.', + }) + .then(({ response }) => { + hangDialogOpen = false + if (!win.isDestroyed() && response === 1) { + win.webContents.reload() + } + }) + }) + win.webContents.on('responsive', () => { + hangDialogOpen = false + }) + + let zoomRestored = false + win.webContents.on('did-finish-load', () => { + if (!zoomRestored) { + zoomRestored = true + const zoomLevel = deps.config.get('zoomLevel') + if (typeof zoomLevel === 'number' && Number.isFinite(zoomLevel)) { + win.webContents.setZoomLevel(zoomLevel) + } + } + const url = win.webContents.getURL() + if (isAppOrigin(url, deps.appOrigin())) { + void win.webContents + .executeJavaScript(THEME_PROBE_SCRIPT, true) + .then((isDark) => { + if (typeof isDark === 'boolean') { + deps.config.set('themeBackground', isDark ? 'dark' : 'light') + } + }) + .catch(() => {}) + } + }) + + let routeTimer: NodeJS.Timeout | undefined + const recordRoute = (url: string) => { + const origin = deps.appOrigin() + if (!isAppOrigin(url, origin)) { + return + } + const path = url.slice(origin.length) || '/' + if (!isSafeInternalPath(path) || isAuthSurfacePath(path)) { + return + } + clearTimeout(routeTimer) + routeTimer = setTimeout(() => { + if (!win.isDestroyed()) { + deps.config.set('lastRoute', path) + } + }, ROUTE_SAVE_DELAY_MS) + } + win.webContents.on('did-navigate', (_event, url) => recordRoute(url)) + win.webContents.on('did-navigate-in-page', (_event, url) => recordRoute(url)) + + win.on('closed', () => { + clearTimeout(routeTimer) + deps.onClosed() + }) + + return win +} diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts new file mode 100644 index 0000000000..70c21ead0c --- /dev/null +++ b/apps/desktop/src/main/windows.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { WebContents } from 'electron' +import { shell } from 'electron' +import { attachWindowOpenPolicy, isPopupContents, registerPopupContents } from '@/main/windows' + +const APP = 'https://sim.ai' + +interface FakeContents { + setWindowOpenHandler: ReturnType + on: ReturnType + handler?: (details: { url: string; frameName: string }) => { action: string } +} + +function makeContents(): FakeContents { + const contents: FakeContents = { + setWindowOpenHandler: vi.fn((handler) => { + contents.handler = handler + }), + on: vi.fn(), + } + return contents +} + +describe('attachWindowOpenPolicy', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + function setup() { + const contents = makeContents() + const openAppWindow = vi.fn() + attachWindowOpenPolicy(contents as unknown as WebContents, { + appOrigin: () => APP, + openAppWindow, + allowHttpLocalhost: false, + }) + return { contents, openAppWindow } + } + + it('allows the MCP OAuth popup', () => { + const { contents } = setup() + const result = contents.handler?.({ + url: 'https://mcp.example/authorize', + frameName: 'mcp-oauth-s1', + }) + expect(result).toEqual({ action: 'allow' }) + }) + + it('allows blank children for the blank-then-assign pattern', () => { + const { contents } = setup() + expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toEqual({ action: 'allow' }) + }) + + it('opens internal new-window requests as full Sim windows', () => { + const { contents, openAppWindow } = setup() + const result = contents.handler?.({ url: `${APP}/workspace/ws1/w/wf1`, frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(openAppWindow).toHaveBeenCalledWith(`${APP}/workspace/ws1/w/wf1`) + }) + + it('routes external opens to the system browser', () => { + const { contents } = setup() + const result = contents.handler?.({ url: 'https://docs.sim.ai/blocks', frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.sim.ai/blocks') + }) + + it('denies non-web schemes without opening anything', () => { + const { contents, openAppWindow } = setup() + const result = contents.handler?.({ url: 'javascript:alert(1)', frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(shell.openExternal).not.toHaveBeenCalled() + expect(openAppWindow).not.toHaveBeenCalled() + }) + + it('registers guards on created child windows', () => { + const { contents } = setup() + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + expect(didCreateWindow).toBeDefined() + }) +}) + +describe('popup registry', () => { + it('tracks popup contents identity', () => { + const contents = {} as WebContents + expect(isPopupContents(contents)).toBe(false) + registerPopupContents(contents) + expect(isPopupContents(contents)).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts new file mode 100644 index 0000000000..44a16332b8 --- /dev/null +++ b/apps/desktop/src/main/windows.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow, WebContents } from 'electron' +import { + classifyBlankChildNavigation, + classifyWindowOpen, + openExternalSafe, +} from '@/main/navigation' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopWindows') + +const popupContents = new WeakSet() + +/** + * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) + * so the navigation classifier can apply the more permissive popup policy. + */ +export function registerPopupContents(contents: WebContents): void { + popupContents.add(contents) +} + +export function isPopupContents(contents: WebContents): boolean { + return popupContents.has(contents) +} + +export interface WindowPolicyDeps { + appOrigin: () => string + openAppWindow: (url: string) => void + allowHttpLocalhost: boolean +} + +/** + * Applies the window.open routing policy to a WebContents. Internal requests + * open as full, independently navigable Sim windows; MCP OAuth popups and + * blank-then-assign children are allowed in the same partition so + * window.opener/postMessage keep working; everything else goes to the system + * browser. + */ +export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicyDeps): void { + contents.setWindowOpenHandler((details) => { + const action = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) + switch (action) { + case 'popup-mcp': + case 'popup-blank': + return { action: 'allow' } + case 'popup-internal': { + deps.openAppWindow(details.url) + return { action: 'deny' } + } + case 'external': + void openExternalSafe(details.url, deps.allowHttpLocalhost) + return { action: 'deny' } + default: + logger.warn('Denied window.open', { url: scrubUrl(details.url) }) + return { action: 'deny' } + } + }) + + contents.on('did-create-window', (child, details) => { + registerPopupContents(child.webContents) + attachWindowOpenPolicy(child.webContents, deps) + const kind = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) + if (kind === 'popup-blank') { + attachBlankChildGuards(child, deps) + } + }) +} + +/** + * Routes the first real navigation of an about:blank child: same-origin URLs + * open in a full Sim window, external URLs open in the system browser, and + * the child closes either way. + */ +function attachBlankChildGuards(child: BrowserWindow, deps: WindowPolicyDeps): void { + child.webContents.on('will-navigate', (event, url) => { + const action = classifyBlankChildNavigation(url, deps.appOrigin()) + if (action === 'ignore') { + return + } + event.preventDefault() + if (action === 'internal') { + deps.openAppWindow(url) + } else if (action === 'external') { + void openExternalSafe(url, deps.allowHttpLocalhost) + } + if (!child.isDestroyed()) { + child.close() + } + }) +} diff --git a/apps/desktop/src/preload/browser/index.ts b/apps/desktop/src/preload/browser/index.ts new file mode 100644 index 0000000000..a968f91717 --- /dev/null +++ b/apps/desktop/src/preload/browser/index.ts @@ -0,0 +1,176 @@ +import { ipcRenderer } from 'electron' + +/** + * The preload for pages inside the built-in browser. + * + * It exists for exactly one job: notice that the page has a login form, tell + * the main process only that fact, and — when the main process says the user + * chose an account — put the credential into the two fields it already found. + * + * It deliberately exposes nothing. There is no `contextBridge` call here, so + * the page cannot see or call any of this, and the fill can only be initiated + * by the main process. What travels out is a page origin and two booleans; + * field names, values, and page content never do. + * + * Runs in the top-level frame only (subframe preloads are not enabled), so + * cross-origin iframe login flows are out of scope for now — filling those + * needs its own threat review. + */ + +const FORM_STATE_CHANNEL = 'browser-credentials:form-state' +const FILL_CHANNEL = 'browser-credentials:fill' +const RESCAN_DEBOUNCE_MS = 250 + +interface DetectedForm { + username: HTMLInputElement | null + /** Absent on an identifier-first step, which asks for the email alone. */ + password: HTMLInputElement | null +} + +/** Held only in this isolated world; never serialized to main or the page. */ +let detected: DetectedForm | null = null +let lastReported = '' +let rescanTimer: ReturnType | null = null + +function isFillable(field: HTMLInputElement): boolean { + if (field.disabled || field.readOnly) return false + const rect = field.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 +} + +/** + * Matches the same definition the agent guards use: a reveal toggle flips a + * password field to `type="text"` without making it any less secret, and the + * autocomplete token is the page's own declaration either way. + */ +function isPasswordField(field: HTMLInputElement): boolean { + if (String(field.type || '').toLowerCase() === 'password') return true + const hint = String(field.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' +} + +function findPasswordField(): HTMLInputElement | null { + for (const field of document.querySelectorAll('input')) { + if (isPasswordField(field) && isFillable(field)) return field + } + return null +} + +/** + * The username field for a password field: the nearest preceding text-like + * input in the same form, which is how essentially every login form is built. + */ +function findUsernameField(password: HTMLInputElement): HTMLInputElement | null { + const scope: ParentNode = password.form ?? document + const candidates: HTMLInputElement[] = [] + for (const field of scope.querySelectorAll('input')) { + const type = String(field.type || 'text').toLowerCase() + if (['text', 'email', 'tel', 'username'].includes(type) && isFillable(field)) { + candidates.push(field) + } + } + const preceding = candidates.filter( + (field) => (password.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_PRECEDING) !== 0 + ) + return preceding.at(-1) ?? candidates[0] ?? null +} + +/** + * The identifier field of a sign-in step that has no password field yet. + * + * Two-step sign-in — email, Continue, then the password on the next screen — + * is now the norm at Google, Okta, and most workplace tools. Requiring a + * password field would mean the key icon never appears on the step where the + * user actually needs it. Only the page's own declaration counts here: an + * `autocomplete` token naming a username or email, or an email input. That is + * narrow enough to leave newsletter boxes and search fields alone. + */ +function findIdentifierField(): HTMLInputElement | null { + for (const field of document.querySelectorAll('input')) { + if (!isFillable(field)) continue + const hint = String(field.getAttribute('autocomplete') || '').toLowerCase() + if (hint === 'username' || hint === 'email') return field + if (String(field.type || '').toLowerCase() === 'email') return field + } + return null +} + +function detectForm(): DetectedForm | null { + const password = findPasswordField() + if (password) return { password, username: findUsernameField(password) } + const username = findIdentifierField() + return username ? { password: null, username } : null +} + +function reportFormState(): void { + detected = detectForm() + + const origin = window.location.origin + const hasPasswordField = detected?.password != null + const fingerprint = `${origin}|${detected !== null}|${hasPasswordField}` + if (fingerprint === lastReported) return + lastReported = fingerprint + ipcRenderer.send(FORM_STATE_CHANNEL, { + origin, + hasLoginForm: detected !== null, + hasPasswordField, + }) +} + +function scheduleRescan(): void { + if (rescanTimer !== null) clearTimeout(rescanTimer) + rescanTimer = setTimeout(() => { + rescanTimer = null + reportFormState() + }, RESCAN_DEBOUNCE_MS) +} + +/** + * Writes through the native value setter so frameworks that track their own + * input state (React and friends) see the change instead of reverting it on + * the next render. + */ +function setFieldValue(field: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (setter) { + setter.call(field, value) + } else { + field.value = value + } + field.dispatchEvent(new Event('input', { bubbles: true })) + field.dispatchEvent(new Event('change', { bubbles: true })) +} + +ipcRenderer.on( + FILL_CHANNEL, + (_event, payload: { origin: string; username: string; password?: string }) => { + // Last line of defence against a navigation between the user's choice and + // this message arriving: the main process binds the fill to an origin, and + // the live document has to still agree. + if (!payload || payload.origin !== window.location.origin || !detected) return + if (detected.username && payload.username) { + setFieldValue(detected.username, payload.username) + } + if (detected.password && payload.password) { + setFieldValue(detected.password, payload.password) + } + // Never submitted. Autofill and submission stay separate so the user can + // confirm the site and the account before anything is sent. Focus lands on + // whichever field the user still has to deal with. + ;(detected.password ?? detected.username)?.focus() + } +) + +document.addEventListener('DOMContentLoaded', reportFormState) +window.addEventListener('load', reportFormState) +window.addEventListener('pageshow', reportFormState) + +// Login forms are routinely rendered after first paint, behind a "Sign in" +// toggle, or swapped in by a single-page router — a one-shot scan would miss +// most of them. +new MutationObserver(scheduleRescan).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['type', 'autocomplete', 'disabled', 'readonly'], +}) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 0000000000..82bc2f79f5 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,352 @@ +import type { + BrowserDataKind, + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSessionsState, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserPanelSnapshot, + BrowserTabsState, + BrowserTheme, + BrowserToolName, + BrowserToolResponse, +} from '@sim/browser-protocol' +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserCredentialMetadata, + BrowserFillAvailability, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, + BrowserSiteInfo, + DesktopCommand, + DesktopNotificationPayload, + DesktopOAuthConnectResult, + DesktopOAuthConnectScope, + DesktopPreferenceKey, + DesktopPreferences, + DesktopUpdateState, + DesktopWindowState, + LocalFilesystemRequest, + LocalFilesystemResponse, + SimDesktopApi, +} from '@sim/desktop-bridge' +import { + TERMINAL_TOOL_NAME, + type TerminalCommandEvent, + type TerminalOperation, + type TerminalStartOptions, + type TerminalTabsState, + type TerminalToolArgs, + type TerminalToolResponse, +} from '@sim/terminal-protocol' +import { contextBridge, ipcRenderer } from 'electron' + +const VERSION_ARG_PREFIX = '--sim-desktop-version=' + +/** + * The shell version injected by the main process as a preload argv flag (see + * createSecureWebPreferences). Read synchronously so the web app's minimum + * shell version gate has it at first paint. + */ +function shellVersion(): string | undefined { + const arg = process.argv.find((value) => value.startsWith(VERSION_ARG_PREFIX)) + const version = arg?.slice(VERSION_ARG_PREFIX.length) + return version || undefined +} + +/** + * The narrow bridge exposed to pages. Every channel is validated and gated in + * the main process — nothing here grants page code any privilege by itself. + */ +const api: SimDesktopApi = { + ...(shellVersion() ? { version: shellVersion() } : {}), + openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url), + beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise => + ipcRenderer.invoke('desktop:oauth-connect', providerId, scope), + onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => { + const listener = (_event: unknown, result: DesktopOAuthConnectResult) => callback(result) + ipcRenderer.on('desktop:oauth-connect-complete', listener) + return () => { + ipcRenderer.removeListener('desktop:oauth-connect-complete', listener) + } + }, + offlineRetry: (): void => { + ipcRenderer.send('offline:retry') + }, + localFilesystem: (request: LocalFilesystemRequest): Promise => + ipcRenderer.invoke('desktop:local-filesystem', request), + onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { + const listener = (_event: unknown, command: DesktopCommand) => callback(command) + ipcRenderer.on('desktop:command', listener) + return () => { + ipcRenderer.removeListener('desktop:command', listener) + } + }, + windowState: { + getState: (): Promise => ipcRenderer.invoke('desktop:window-state:get'), + onStateChange: (callback: (state: DesktopWindowState) => void): (() => void) => { + const listener = (_event: unknown, state: DesktopWindowState) => callback(state) + ipcRenderer.on('desktop:window-state:changed', listener) + return () => { + ipcRenderer.removeListener('desktop:window-state:changed', listener) + } + }, + }, + settings: { + getPreferences: (): Promise => ipcRenderer.invoke('desktop:settings:get'), + setPreference: (key: DesktopPreferenceKey, value: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', key, value), + notify: (payload: DesktopNotificationPayload): Promise => + ipcRenderer.invoke('desktop:settings:notify', payload), + setTrayEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'trayEnabled', enabled), + setBrowserEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'browserEnabled', enabled), + setTerminalEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'terminalEnabled', enabled), + }, + updates: { + getState: (): Promise => ipcRenderer.invoke('desktop:updates:get-state'), + check: (): void => { + ipcRenderer.send('desktop:updates:check') + }, + install: (): void => { + ipcRenderer.send('desktop:updates:install') + }, + onState: (callback: (state: DesktopUpdateState) => void): (() => void) => { + const listener = (_event: unknown, state: DesktopUpdateState) => callback(state) + ipcRenderer.on('desktop:updates:state', listener) + return () => { + ipcRenderer.removeListener('desktop:updates:state', listener) + } + }, + }, + browserAgent: { + executeTool: ( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise => + ipcRenderer.invoke('browser-agent:execute-tool', toolCallId, tool, params), + panelAction: (action: BrowserPanelAction): void => { + ipcRenderer.send('browser-agent:panel-action', action) + }, + setTabPinned: (tabId: string, pinned: boolean): void => { + ipcRenderer.send('browser-agent:set-tab-pinned', tabId, pinned) + }, + reorderTab: (tabId: string, targetIndex: number): void => { + ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex) + }, + setPanelBounds: ( + bounds: BrowserPanelBounds | null, + anchor?: BrowserPanelAnchor | null + ): void => { + ipcRenderer.send('browser-agent:set-panel-bounds', bounds, anchor ?? null) + }, + setPanelFocused: (focused: boolean): void => { + ipcRenderer.send('browser-agent:set-panel-focused', focused) + }, + setPanelOccluded: (occluded: boolean): void => { + ipcRenderer.send('browser-agent:set-panel-occluded', occluded) + }, + setTheme: (theme: BrowserTheme): void => { + ipcRenderer.send('browser-agent:set-theme', theme) + }, + onFocusOmnibox: (callback: (mode: BrowserOmniboxFocusMode) => void): (() => void) => { + const listener = (_event: unknown, mode: BrowserOmniboxFocusMode) => callback(mode) + ipcRenderer.on('browser-agent:focus-omnibox', listener) + return () => { + ipcRenderer.removeListener('browser-agent:focus-omnibox', listener) + } + }, + find: (request: BrowserFindRequest): void => { + ipcRenderer.send('browser-agent:find', request) + }, + stopFind: (focusPage?: boolean): void => { + ipcRenderer.send('browser-agent:stop-find', focusPage === true) + }, + onOpenFind: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('browser-agent:open-find', listener) + return () => { + ipcRenderer.removeListener('browser-agent:open-find', listener) + } + }, + onCloseFind: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('browser-agent:close-find', listener) + return () => { + ipcRenderer.removeListener('browser-agent:close-find', listener) + } + }, + onFindResult: (callback: (result: BrowserFindResult) => void): (() => void) => { + const listener = (_event: unknown, result: BrowserFindResult) => callback(result) + ipcRenderer.on('browser-agent:find-result', listener) + return () => { + ipcRenderer.removeListener('browser-agent:find-result', listener) + } + }, + onPanelSnapshot: (callback: (snapshot: BrowserPanelSnapshot) => void): (() => void) => { + const listener = (_event: unknown, snapshot: BrowserPanelSnapshot) => callback(snapshot) + ipcRenderer.on('browser-agent:panel-snapshot', listener) + return () => { + ipcRenderer.removeListener('browser-agent:panel-snapshot', listener) + } + }, + onPageState: (callback: (state: BrowserPageState) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserPageState) => callback(state) + ipcRenderer.on('browser-agent:page-state', listener) + return () => { + ipcRenderer.removeListener('browser-agent:page-state', listener) + } + }, + getTabsState: (): Promise => + ipcRenderer.invoke('browser-agent:get-tabs-state'), + getKnownSessions: (): Promise => + ipcRenderer.invoke('browser-agent:get-known-sessions'), + clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise => + ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds), + onTabsState: (callback: (state: BrowserTabsState) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserTabsState) => callback(state) + ipcRenderer.on('browser-agent:tabs-state', listener) + return () => { + ipcRenderer.removeListener('browser-agent:tabs-state', listener) + } + }, + onSessionStatus: (callback: (alive: boolean) => void): (() => void) => { + const listener = (_event: unknown, alive: boolean) => callback(alive) + ipcRenderer.on('browser-agent:session-status', listener) + return () => { + ipcRenderer.removeListener('browser-agent:session-status', listener) + } + }, + }, + // Omitted entirely off macOS, so the web app's feature detection reflects + // whether an import can actually run rather than only whether the shell is + // new enough. Chrome's App-Bound Encryption on Windows is deliberately not + // worked around. + ...(process.platform === 'darwin' + ? { + browserImport: { + listChromeProfiles: (): Promise => + ipcRenderer.invoke('browser-import:list-profiles'), + listSites: (): Promise => ipcRenderer.invoke('browser-import:sites'), + importChromeCookies: (profileId?: string): Promise => + ipcRenderer.invoke('browser-import:cookies', profileId), + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-import:all', profileId, policy), + }, + } + : {}), + // Note what is absent: there is no method that returns a password, and none + // that names a credential to fill. Filling is completed by a native menu in + // the main process, so the strongest thing a compromised renderer can do + // here is ask for that menu to open. + browserCredentials: { + isAvailable: (): Promise => ipcRenderer.invoke('browser-credentials:available'), + list: (): Promise => + ipcRenderer.invoke('browser-credentials:list'), + forget: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:forget', id), + forgetAll: (): Promise => + ipcRenderer.invoke('browser-credentials:forget-all'), + reveal: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:reveal', id), + copy: (id: string): Promise => ipcRenderer.invoke('browser-credentials:copy', id), + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-credentials:import', profileId, policy), + showChooser: (anchor: { x: number; y: number }): Promise => + ipcRenderer.invoke('browser-credentials:show-chooser', anchor), + onFillAvailability: (callback: (state: BrowserFillAvailability) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserFillAvailability) => callback(state) + ipcRenderer.on('browser-credentials:fill-availability', listener) + return () => { + ipcRenderer.removeListener('browser-credentials:fill-availability', listener) + } + }, + }, + terminal: { + start: async (options: TerminalStartOptions): Promise => { + const response = (await ipcRenderer.invoke('terminal:start', options)) as + | { ok: true; tabs: TerminalTabsState } + | { ok: false; code?: string; error?: string } + if (!response?.ok) { + const failure = new Error(response?.error ?? 'Could not open a terminal.') + failure.name = response?.code ?? 'SPAWN_FAILED' + throw failure + } + return response.tabs + }, + // The tool name rides alongside the call because the main process + // re-fetches the server's authorized arguments by tool call id and uses + // those, not these — what the renderer passes is only a request. + executeTool: ( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise => + ipcRenderer.invoke('terminal:execute-tool', toolCallId, TERMINAL_TOOL_NAME, { + operation, + args, + }), + write: (terminalId: string, data: string): void => { + ipcRenderer.send('terminal:write', terminalId, data) + }, + resize: (terminalId: string, cols: number, rows: number): void => { + ipcRenderer.send('terminal:resize', terminalId, cols, rows) + }, + openTerminal: (cwd?: string): Promise => + ipcRenderer.invoke('terminal:open', cwd), + switchTerminal: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:switch', terminalId), + closeTerminal: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:close', terminalId), + getTabs: (): Promise => ipcRenderer.invoke('terminal:get-tabs'), + dispose: (): void => { + ipcRenderer.send('terminal:dispose') + }, + onData: (callback: (terminalId: string, data: string) => void): (() => void) => { + const listener = (_event: unknown, terminalId: string, data: string) => + callback(terminalId, data) + ipcRenderer.on('terminal:data', listener) + return () => { + ipcRenderer.removeListener('terminal:data', listener) + } + }, + getScrollback: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:scrollback', terminalId), + setFocused: (focused: boolean): void => { + ipcRenderer.send('terminal:focused', focused) + }, + finishHandoff: (terminalId: string): void => { + ipcRenderer.send('terminal:handoff-done', terminalId) + }, + onTabs: (callback: (state: TerminalTabsState) => void): (() => void) => { + const listener = (_event: unknown, state: TerminalTabsState) => callback(state) + ipcRenderer.on('terminal:tabs', listener) + return () => { + ipcRenderer.removeListener('terminal:tabs', listener) + } + }, + onCommand: (callback: (event: TerminalCommandEvent) => void): (() => void) => { + const listener = (_event: unknown, payload: TerminalCommandEvent) => callback(payload) + ipcRenderer.on('terminal:command', listener) + return () => { + ipcRenderer.removeListener('terminal:command', listener) + } + }, + }, +} + +contextBridge.exposeInMainWorld('simDesktop', api) diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts new file mode 100644 index 0000000000..385aba2910 --- /dev/null +++ b/apps/desktop/src/test/electron-mock.ts @@ -0,0 +1,242 @@ +import { vi } from 'vitest' + +/** + * Shared electron module mock for unit tests. The real electron package + * cannot be imported under Node (it resolves to a binary path), so every test + * file that touches an electron-importing module mocks it with: + * + * vi.mock('electron', () => import('@/test/electron-mock')) + */ + +export const app = { + name: 'Sim', + isPackaged: false, + getVersion: vi.fn(() => '1.0.0'), + getName: vi.fn(() => 'Sim'), + setName: vi.fn(), + getPath: vi.fn(() => '/tmp/sim-desktop-test'), + getAppPath: vi.fn(() => '/tmp/sim-desktop-test/app'), + isReady: vi.fn(() => true), + on: vi.fn(), + once: vi.fn(), + quit: vi.fn(), + focus: vi.fn(), + enableSandbox: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + whenReady: vi.fn(() => Promise.resolve()), + startAccessingSecurityScopedResource: vi.fn(() => vi.fn()), + getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), + setLoginItemSettings: vi.fn(), + dock: { downloadFinished: vi.fn() }, +} + +export const crashReporter = { + start: vi.fn(), +} + +export const shell = { + openExternal: vi.fn(() => Promise.resolve()), + showItemInFolder: vi.fn(), +} + +export const dialog = { + showMessageBox: vi.fn(() => Promise.resolve({ response: 0, checkboxChecked: false })), + showMessageBoxSync: vi.fn(() => 0), + showOpenDialog: vi.fn(() => Promise.resolve({ canceled: true, filePaths: [] })), +} + +export const safeStorage = { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')), +} + +export const clipboard = { + writeText: vi.fn(), +} + +export const nativeTheme = { + shouldUseDarkColors: false, + on: vi.fn(), +} + +export const Menu = { + buildFromTemplate: vi.fn((template: unknown[]) => ({ popup: vi.fn(), items: template })), + setApplicationMenu: vi.fn(), +} + +export const net = { + isOnline: vi.fn(() => true), + fetch: vi.fn(), +} + +export const session = { + fromPartition: vi.fn(), +} + +export const ipcMain = { + on: vi.fn(), + handle: vi.fn(), +} + +export const nativeImage = { + createFromPath: vi.fn(() => ({ + isEmpty: vi.fn(() => false), + setTemplateImage: vi.fn(), + getSize: vi.fn(() => ({ width: 32, height: 16 })), + toBitmap: vi.fn(() => Buffer.alloc(64 * 32 * 4)), + })), + createEmpty: vi.fn(() => ({ + isEmpty: vi.fn(() => true), + setTemplateImage: vi.fn(), + })), + createFromBitmap: vi.fn((_buffer: unknown, options: { width: number; height: number }) => ({ + isEmpty: vi.fn(() => false), + setTemplateImage: vi.fn(), + getSize: vi.fn(() => ({ width: options.width, height: options.height })), + })), +} + +export class Tray { + static instances: Tray[] = [] + constructor(public image: unknown) { + Tray.instances.push(this) + } + setToolTip = vi.fn() + setContextMenu = vi.fn() + popUpContextMenu = vi.fn() + on = vi.fn() + destroy = vi.fn() + isDestroyed = vi.fn(() => false) +} + +export class Notification { + static instances: Notification[] = [] + static isSupported = vi.fn(() => true) + constructor(public options: Record) { + Notification.instances.push(this) + } + on = vi.fn() + show = vi.fn() + close = vi.fn() +} + +function createWebContentsMock() { + return { + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + getURL: vi.fn(() => 'https://example.com/'), + getTitle: vi.fn(() => 'Example'), + loadURL: vi.fn(() => Promise.resolve()), + reload: vi.fn(), + focus: vi.fn(), + isFocused: vi.fn(() => false), + close: vi.fn(), + isDestroyed: vi.fn(() => false), + isLoading: vi.fn(() => false), + findInPage: vi.fn(() => 1), + stopFindInPage: vi.fn(), + setBackgroundThrottling: vi.fn(), + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + copy: vi.fn(), + paste: vi.fn(), + capturePage: vi.fn(() => { + const image = { + isEmpty: vi.fn(() => false), + toDataURL: vi.fn(() => 'data:image/png;base64,c2lt'), + getSize: vi.fn(() => ({ width: 1600, height: 1000 })), + resize: vi.fn(() => image), + toJPEG: vi.fn(() => Buffer.from('sim')), + } + return Promise.resolve(image) + }), + executeJavaScript: vi.fn(() => Promise.resolve(undefined)), + setWindowOpenHandler: vi.fn(), + navigationHistory: { + canGoBack: vi.fn(() => false), + canGoForward: vi.fn(() => false), + goBack: vi.fn(), + goForward: vi.fn(), + }, + debugger: { + attach: vi.fn(), + detach: vi.fn(), + isAttached: vi.fn(() => false), + sendCommand: vi.fn(() => Promise.resolve({})), + on: vi.fn(), + }, + session: { + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + webRequest: { onBeforeRequest: vi.fn() }, + on: vi.fn(), + }, + } +} + +export class WebContentsView { + webContents = createWebContentsMock() + setBackgroundColor = vi.fn() + setVisible = vi.fn() + setBounds = vi.fn() +} + +export class BrowserWindow { + static fromWebContents = vi.fn(() => null) + static getFocusedWindow = vi.fn(() => null) + /** Constructor tracking for tests (the class itself is not a vi.fn mock). */ + static instances: BrowserWindow[] = [] + static lastOptions: Record | undefined + constructor(options?: Record) { + BrowserWindow.instances.push(this) + BrowserWindow.lastOptions = options + } + webContents = { + on: vi.fn(), + getURL: vi.fn(() => ''), + loadURL: vi.fn(() => Promise.resolve()), + reload: vi.fn(), + setZoomLevel: vi.fn(), + getZoomLevel: vi.fn(() => 0), + getZoomFactor: vi.fn(() => 1), + executeJavaScript: vi.fn(() => Promise.resolve(true)), + focus: vi.fn(), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + isDevToolsOpened: vi.fn(() => false), + session: { addWordToSpellCheckerDictionary: vi.fn() }, + } + on = vi.fn() + once = vi.fn() + removeListener = vi.fn() + isDestroyed = vi.fn(() => false) + isMinimized = vi.fn(() => false) + isFullScreen = vi.fn(() => false) + isMaximized = vi.fn(() => false) + isVisible = vi.fn(() => false) + isFocused = vi.fn(() => false) + getNormalBounds = vi.fn(() => ({ x: 0, y: 0, width: 1360, height: 860 })) + getBounds = vi.fn(() => ({ x: 1292, y: 41, width: 420, height: 150 })) + setBounds = vi.fn() + loadURL = vi.fn(() => Promise.resolve()) + loadFile = vi.fn(() => Promise.resolve()) + focus = vi.fn() + show = vi.fn() + showInactive = vi.fn() + hide = vi.fn() + close = vi.fn() + destroy = vi.fn() + restore = vi.fn() + setPosition = vi.fn() + setTitle = vi.fn() + setVisibleOnAllWorkspaces = vi.fn() + setAlwaysOnTop = vi.fn() + getSize = vi.fn(() => [1180, 850]) + getContentSize = vi.fn(() => [1180, 850]) + contentView = { + addChildView: vi.fn(), + removeChildView: vi.fn(), + } +} diff --git a/apps/desktop/static/dock-icon-dev.png b/apps/desktop/static/dock-icon-dev.png new file mode 100644 index 0000000000000000000000000000000000000000..43d743599fd79514a56b838a51f0b03f4e64fb40 GIT binary patch literal 15211 zcmeHuhgXx$x9&?3M5+~~BTWTFLFr9F0qI>jC@3ANp(m(-iU@*$^xmW?2uKZzN>fmZ zbV4?raPrPOvuE#TmwEPMJsma1Lu`j22x8PwS2chj zO7M{qI!FV4;r#pY;1}IJbu(`Wq8cLqqi|QdeHnsyAPv()~bn!8p)IF84Z)XITuj{LNvK}+) zcVXc>cd*?jJ;S{_WMW=(n$s=&RS6!4!G~s`Lzb1c;M~1d-t%cTrs)ph$H7|u_x-dp zjQ5u{v5mdc=yn+sL(4pPfqr@@80|M#2k+VG*6f7cIglYIRw{^QpKD0TDRNC1+tu8q zz#RS1{N9^nJujT5FViEVfMwrIXjWxj8SeH$(qu7)__gVH#nWU2HLr~ay+C.e1U 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 E0fwc%3IG5A literal 0 HcmV?d00001 diff --git a/apps/desktop/static/dock-icon-local.png b/apps/desktop/static/dock-icon-local.png new file mode 100644 index 0000000000000000000000000000000000000000..13bab7cbeee8a84280e4ce16be90050071760942 GIT binary patch literal 15053 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/static/dock-icon-staging.png b/apps/desktop/static/dock-icon-staging.png new file mode 100644 index 0000000000000000000000000000000000000000..e7f451fb34de68f8c551bbc675bc74d2b2551901 GIT binary patch literal 14730 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 We%G27rAr1uJrxBF`GV_akN*#Z$#TyC literal 0 HcmV?d00001 diff --git a/apps/desktop/static/dock-icon.png b/apps/desktop/static/dock-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd20d6de3f22b22e7b941078eb57f69159c10885 GIT binary patch literal 18721 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�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 literal 0 HcmV?d00001 diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html new file mode 100644 index 0000000000..c0a5a93be8 --- /dev/null +++ b/apps/desktop/static/offline.html @@ -0,0 +1,175 @@ + + + + + + Sim — Can’t connect + + + + + + + diff --git a/apps/desktop/static/tray/simTemplate.png b/apps/desktop/static/tray/simTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..8793c33a081981966d84d8cc8c8946293af59ad2 GIT binary patch literal 504 zcmV#M98V65zB*o2*%w$#FRdf-~?6?!h`}q%M=aU;{4iEVY1DHApVqJcS1i_GP#Oe-c0^CpDP{r=4uGmXp~Ez(?aI zD5lM(3|^rvYXMaAu@K>oZ@@YGx4a*Ai+3QdaWY0Ns?mbf7R-W+4@ZJ8))6mY9d)Vk u5Klv@rWLG$ag7rzRL=O?&peKcb?BY`yRA6RO5d|T1k+h)IiV6&R z3G9aSQuI=EeJI7Wuqe9GMN?z7|G@u)=`i1Zvs+7iyAJ%o^_z3%JO7-aLV9ehHz8KViCh!A9iTb+{RC6Fx zQv$8x%S-`lgb?}QC^-y*{J(nPa_|^@=Xk$3lNvw)=x-P=49Ghxkl!1Q2hYIKe=mMSW8`7R%(tJc2_+0SZV$3=)Li1I^puOy_gK=KLs{_kb zgK;+l`sCt#ukfQ@uua(KYR_x6XX@Ks3f@`-;c%ILw#Q-PoJWSo5}-&Jd}H&$no|u{iF~mAA z9G^%~0G$d$(o{RjX3*|pquz7BmPhnNx{=FN@BdmXp+AnV@3fAs9@#!D96!+#R3ZX+ zofNma2yn>5uGnC+(A06Pm{Khsml(B{wt*dF0~nB?0BZe@_6TGc-${O(L5pqtrLf!N zmH->Y)GA7F3yoKRiu_(FOAQGnga5Wq<|Y74my zzIq7JZgA*(0rWNjtmJmn!1J|9h{9AAK(=fikX{I zhglYmuL;MC{4BtcIFFdB-PQW{Xgh*01y~_Esq?^$7*5yn7U=YUrJW6YDS%2~PesQz z9vkPdEH)J^;l-sRM$yx^NpOM7zTd-Am|Nwgo|lf4*ap>A@tI|XMFn=!P-O`ksz+`m zv0mQiZCXu&cY$hQS88*aQ8&RG{3hF3oa|&^k5~W+$0&+cu(lZCB}V^gtWbLvVg9jc z>xJENOS^nww=AChKFG>$E~~t%ES3@IYpKzG1Ie9gbyRA+kpKVy07*qoM6N<$f?ATc AJ^%m! literal 0 HcmV?d00001 diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000000..77d5b0963e --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@sim/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "scripts/**/*", "e2e/**/*", "playwright.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist", "release"] +} diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 0000000000..9718634dfc --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,21 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'], + pool: 'threads', + testTimeout: 10000, + }, + resolve: { + alias: { + '@sim/logger': resolve(__dirname, '../../packages/logger/src'), + '@sim/security': resolve(__dirname, '../../packages/security/src'), + '@sim/utils': resolve(__dirname, '../../packages/utils/src'), + '@': resolve(__dirname, 'src'), + }, + }, +}) diff --git a/apps/docs/package.json b/apps/docs/package.json index da9850a483..bf756cdcfc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,7 +8,7 @@ "build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", "postinstall": "fumadocs-mdx", - "type-check": "tsc --noEmit", + "type-check": "fumadocs-mdx && tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx index 82dbf7ef7c..57c83fa152 100644 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -1,5 +1,16 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { DesktopTitleBarController } from '@/app/_shell/desktop-title-bar' import { AuthShell } from '@/app/(auth)/components' export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { - return {children} + const isLogin = usePathname() === '/login' + + return ( + <> + {isLogin && } + {children} + + ) } diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index 91f4d324d6..d4dd4f0652 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' import Link from 'next/link' import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' @@ -7,6 +8,8 @@ interface AuthShellProps { children: ReactNode /** Optional element pinned to the bottom of the shell (e.g. the support footer). */ footer?: ReactNode + /** Reserve the native macOS title-bar lane for the desktop login route. */ + reserveDesktopTitleBar?: boolean } /** @@ -19,9 +22,17 @@ interface AuthShellProps { * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The * single content column is centered and capped for a calm single-form layout. */ -export function AuthShell({ children, footer }: AuthShellProps) { +export function AuthShell({ children, footer, reserveDesktopTitleBar = false }: AuthShellProps) { return ( -
+
+ {reserveDesktopTitleBar && ( +
+ )}
+ {children} + + ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ + children, + label, + action, + }: { + children: ReactNode + label: string + action?: ReactNode + }) => ( +
+ {action} + {children} +
+ ), + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view', + () => ({ + PasswordsView: ({ + credentials, + onBack, + }: { + credentials: BrowserCredentialMetadata[] + onBack: () => void + }) => ( +
+ {`${credentials.length} saved`} + +
+ ), + }) +) + +// The modal's own picker logic is covered by import-modal.test.tsx; here it is +// reduced to "open?" plus a way to confirm the chosen profile. +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal', + () => ({ + ImportModal: ({ + open, + profiles, + pending, + onImport, + }: { + open: boolean + profiles: BrowserImportProfile[] + pending: boolean + onImport: (profile: BrowserImportProfile) => void + }) => + open ? ( +
+ {`${profiles.length} profiles`} + +
+ ) : null, + }) +) + +import { Browser } from '@/app/workspace/[workspaceId]/settings/components/browser/browser' + +const PROFILES: BrowserImportProfile[] = [ + { + id: 'chrome:Default', + label: 'Chrome', + browserId: 'chrome', + browserLabel: 'Chrome', + profileLabel: 'Default', + }, + { + id: 'arc:Profile 2', + label: 'Arc · Microtrades', + browserId: 'arc', + browserLabel: 'Arc', + profileLabel: 'Microtrades', + }, +] + +const CREDENTIALS: BrowserCredentialMetadata[] = [ + { + id: 'c1', + origin: 'https://example.com', + username: 'ada@example.com', + createdAt: '', + updatedAt: '', + source: 'chrome', + }, +] + +const IMPORTED_BOTH: BrowserChromeImportResult = { + cookies: { cookiesImported: 12, cookiesSkipped: 3 }, + passwords: { passwordsAdded: 4, passwordsUpdated: 1, passwordsSkipped: 2 }, +} + +interface BridgeOverrides { + browserEnabled?: boolean + profiles?: BrowserImportProfile[] + importResult?: BrowserChromeImportResult + listProfilesFails?: boolean + vaultAvailable?: boolean +} + +function createBridge({ + browserEnabled = true, + profiles = PROFILES, + importResult = IMPORTED_BOTH, + listProfilesFails = false, + vaultAvailable = true, +}: BridgeOverrides = {}) { + return { + settings: { + getPreferences: vi.fn(async () => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled, + })), + setBrowserEnabled: vi.fn(), + }, + browserAgent: { + getKnownSessions: vi.fn(async () => ({ sessions: [] })), + clearBrowsingData: vi.fn(async () => ({ sessions: [{ hostname: 'left.test' }] })), + }, + browserImport: { + listChromeProfiles: vi.fn(async (): Promise => { + if (listProfilesFails) throw new Error('unreadable') + return profiles + }), + importFromChrome: vi.fn(async (): Promise => importResult), + }, + browserCredentials: { + isAvailable: vi.fn(async () => vaultAvailable), + list: vi.fn(async () => CREDENTIALS), + forget: vi.fn(async () => []), + }, + } +} + +let container: HTMLDivElement +let root: Root + +async function render() { + await act(async () => { + root.render() + }) +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +const importDialog = () => container.querySelector('[aria-label="Import from your browser"]') + +describe('Browser settings', () => { + it('keeps only the agent-browser toggle on the page itself', async () => { + // Passwords and browsing data each own a page; this one is just the switch. + await render() + + expect(container.querySelector('section[aria-label="Agent browser"]')).not.toBeNull() + expect(container.querySelector('section[aria-label="Saved passwords"]')).toBeNull() + }) + + it('reaches both sub-pages from the header', async () => { + await render() + + expect([...container.querySelectorAll('header button')].map((b) => b.textContent)).toEqual([ + 'Passwords', + 'Clear all', + ]) + }) + + it('lists each data type inline as a standard settings row', async () => { + await render() + + for (const label of ['Cookies', 'Site data', 'Cached images and files']) { + expect(container.querySelector(`section[aria-label="${label}"]`)).not.toBeNull() + } + }) + + it.each([ + ['Delete cookies', ['cookies']], + ['Delete site data', ['site-data']], + ['Delete cached images and files', ['cache']], + ])('%s clears only that kind, after confirmation', async (action, kinds) => { + const bridge = createBridge() + mockBridge.current = bridge + await render() + + await click(buttonLabelled(action)) + expect(bridge.browserAgent.clearBrowsingData).not.toHaveBeenCalled() + + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(bridge.browserAgent.clearBrowsingData).toHaveBeenCalledWith(kinds) + }) + + it('clears every kind from the header action', async () => { + const bridge = createBridge() + mockBridge.current = bridge + await render() + + await click(buttonLabelled('Clear all')) + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(bridge.browserAgent.clearBrowsingData).toHaveBeenCalledWith([ + 'cookies', + 'site-data', + 'cache', + ]) + }) + + it('spells out the consequence and spares saved passwords in the confirmation', async () => { + await render() + + await click(buttonLabelled('Delete cookies')) + + const dialog = container.querySelector('[role="dialog"]')?.textContent ?? '' + expect(dialog).toContain('sign the browser out of every site') + expect(dialog).toContain('saved passwords are not affected') + }) + + it('surfaces a failure instead of implying data was deleted', async () => { + const bridge = createBridge() + bridge.browserAgent.clearBrowsingData = vi.fn(async () => { + throw new Error('locked') + }) as typeof bridge.browserAgent.clearBrowsingData + mockBridge.current = bridge + await render() + + await click(buttonLabelled('Delete cookies')) + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(mockToast.error).toHaveBeenCalledWith('Could not delete browsing data') + }) + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockBridge.current = createBridge() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('hides the Passwords action on shells without the credential surface', async () => { + mockBridge.current = { ...createBridge(), browserCredentials: undefined } + await render() + + expect( + [...container.querySelectorAll('header button')].map((b) => b.textContent) + ).not.toContain('Passwords') + }) + + it('opens the manager from the header rather than inlining it', async () => { + await render() + expect(container.querySelector('[aria-label="Passwords view"]')).toBeNull() + + await click(buttonLabelled('Passwords')) + + expect(container.querySelector('[aria-label="Passwords view"]')?.textContent).toContain( + '1 saved' + ) + }) + + it('returns to the browser page from the manager', async () => { + await render() + await click(buttonLabelled('Passwords')) + + await click(buttonLabelled('Back')) + + expect(container.querySelector('[aria-label="Passwords view"]')).toBeNull() + expect(container.querySelector('section[aria-label="Agent browser"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx new file mode 100644 index 0000000000..1327c3fdc5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { BROWSER_DATA_KINDS, type BrowserDataKind } from '@sim/browser-protocol' +import type { BrowserCredentialMetadata, DesktopPreferences } from '@sim/desktop-bridge' +import { Chip, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn' +import { useParams, useRouter } from 'next/navigation' +import { getDesktopBridge, setDesktopPreferencesSnapshot } from '@/lib/desktop' +import { PasswordsView } from '@/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +interface DataRow { + kind: BrowserDataKind + label: string + action: string + /** + * What the user actually loses. Not shown in the row — the action names + * itself — but spelled out in the confirmation, where it matters. + */ + consequence: string +} + +/** + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear. + */ +const DATA_ROWS: DataRow[] = [ + { + kind: 'cookies', + label: 'Cookies', + action: 'Delete cookies', + consequence: 'sign the browser out of every site it is currently signed into', + }, + { + kind: 'site-data', + label: 'Site data', + action: 'Delete site data', + consequence: 'erase the data sites have stored locally, such as drafts and preferences', + }, + { + kind: 'cache', + label: 'Cached images and files', + action: 'Delete cached images and files', + consequence: 'free up space and make sites load more slowly the first time', + }, +] + +export function Browser() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + const [preferences, setPreferences] = useState(null) + const [siteCount, setSiteCount] = useState(0) + const [togglePending, setTogglePending] = useState(false) + const [credentials, setCredentials] = useState([]) + const [showPasswords, setShowPasswords] = useState(false) + const [confirming, setConfirming] = useState(null) + const [clearPending, setClearPending] = useState(false) + + const refreshSiteCount = useCallback(async () => { + const known = await getDesktopBridge()?.browserAgent?.getKnownSessions?.() + setSiteCount(known?.sessions.length ?? 0) + }, []) + + const refreshCredentials = useCallback(async () => { + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge) return + const available = await bridge.isAvailable().catch(() => false) + setCredentials(available ? await bridge.list().catch(() => []) : []) + }, []) + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.browserAgent || !bridge.settings) { + router.replace(`/workspace/${workspaceId}/settings/general`) + return + } + void Promise.all([bridge.settings.getPreferences(), refreshSiteCount(), refreshCredentials()]) + .then(([next]) => setPreferences(next)) + .catch(() => toast.error('Could not load browser settings')) + }, [refreshCredentials, refreshSiteCount, router, workspaceId]) + + const setEnabled = useCallback(async (enabled: boolean) => { + const setBrowserEnabled = getDesktopBridge()?.settings?.setBrowserEnabled + if (!setBrowserEnabled) return + setTogglePending(true) + try { + const next = await setBrowserEnabled(enabled) + setPreferences(next) + setDesktopPreferencesSnapshot(next) + } catch { + toast.error('Could not update browser settings') + } finally { + setTogglePending(false) + } + }, []) + + const clear = useCallback(async (kinds: readonly BrowserDataKind[]) => { + const clearBrowsingData = getDesktopBridge()?.browserAgent?.clearBrowsingData + if (!clearBrowsingData) return + setClearPending(true) + try { + setSiteCount((await clearBrowsingData(kinds)).sessions.length) + setConfirming(null) + } catch { + toast.error('Could not delete browsing data') + } finally { + setClearPending(false) + } + }, []) + + if (!preferences) { + return null + } + + if (showPasswords) { + return ( + setShowPasswords(false)} + onImported={() => Promise.all([refreshSiteCount(), refreshCredentials()])} + /> + ) + } + + const enabled = preferences.browserEnabled ?? true + const canClearData = typeof getDesktopBridge()?.browserAgent?.clearBrowsingData === 'function' + const canManagePasswords = Boolean(getDesktopBridge()?.browserCredentials) + const target = confirming === 'all' ? null : confirming + + return ( + <> + setShowPasswords(true) }] + : []), + ...(canClearData + ? [ + { + text: 'Clear all', + variant: 'destructive' as const, + onSelect: () => setConfirming('all'), + disabled: clearPending, + }, + ] + : []), + ]} + > + +
+
+ +

+ Pages open in a browser built into Sim, signed in separately from your own. +

+
+ void setEnabled(checked)} + /> +
+
+ + {canClearData && + DATA_ROWS.map((row) => ( + setConfirming(row)}> + {row.action} + + } + > + {null} + + ))} + + {canClearData && ( +

+ {siteCount === 0 + ? 'Nothing saved. Sites you sign into in the browser stay on this device.' + : `${siteCount} ${siteCount === 1 ? 'site is' : 'sites are'} signed in or holding cookies, saved on this device only.`}{' '} + Saved passwords are never deleted here. +

+ )} +
+ + !open && setConfirming(null)} + title={target ? target.action : 'Clear all browsing data'} + text={[ + 'This will ', + { + text: target + ? target.consequence + : 'sign the browser out of every site and erase its cookies, site data, and cache', + bold: true, + }, + '. Your Sim account and saved passwords are not affected.', + ]} + confirm={{ + label: target ? target.action : 'Clear all', + pending: clearPending, + pendingLabel: 'Deleting...', + onClick: () => void clear(target ? [target.kind] : BROWSER_DATA_KINDS), + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx new file mode 100644 index 0000000000..05fea55f6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx @@ -0,0 +1,211 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import type { BrowserImportProfile } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +/** ChipSelect stands in as a native select so options are inspectable. */ +vi.mock('@sim/emcn', () => ({ + ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalFooter: ({ + onCancel, + primaryAction, + }: { + onCancel: () => void + primaryAction: { label: ReactNode; onClick: () => void; disabled?: boolean } + }) => ( +
+ + +
+ ), + ChipModalField: ({ + title, + options, + value, + onChange, + disabled, + }: { + title: string + options: Array<{ value: string; label: string }> + value: string + onChange: (value: string) => void + disabled?: boolean + }) => ( + + ), +})) + +import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' + +function profile( + id: string, + browserId: string, + browserLabel: string, + profileLabel: string +): BrowserImportProfile { + return { id, label: `${browserLabel} · ${profileLabel}`, browserId, browserLabel, profileLabel } +} + +const PROFILES = [ + profile('chrome:Default', 'chrome', 'Chrome', 'Default'), + profile('chrome:Profile 1', 'chrome', 'Chrome', 'sim.ai'), + profile('arc:Default', 'arc', 'Arc', 'Default'), + profile('arc:Profile 2', 'arc', 'Arc', 'Microtrades'), + profile('dia:Default', 'dia', 'Dia', 'Default'), +] + +let container: HTMLDivElement +let root: Root +let onImport: ReturnType +let onOpenChange: ReturnType + +async function render(profiles = PROFILES, pending = false) { + await act(async () => { + root.render( + + ) + }) +} + +function select(label: 'Browser' | 'Profile'): HTMLSelectElement { + const element = container.querySelector(`select[aria-label="${label}"]`) + if (!element) throw new Error(`No select labelled "${label}"`) + return element +} + +function optionsOf(label: 'Browser' | 'Profile'): string[] { + return [...select(label).options].map((option) => option.textContent ?? '') +} + +async function choose(label: 'Browser' | 'Profile', value: string) { + const element = select(label) + await act(async () => { + element.value = value + element.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +describe('ImportModal', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onImport = vi.fn() + onOpenChange = vi.fn() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('lists each browser once, not once per profile', async () => { + await render() + + expect(optionsOf('Browser')).toEqual(['Chrome', 'Arc', 'Dia']) + }) + + it('shows only the selected browser\u2019s profiles', async () => { + await render() + + expect(optionsOf('Profile')).toEqual(['Default', 'sim.ai']) + + await choose('Browser', 'arc') + + expect(optionsOf('Profile')).toEqual(['Default', 'Microtrades']) + }) + + it('never leaves a profile selected that belongs to another browser', async () => { + await render() + await choose('Profile', 'chrome:Profile 1') + + await choose('Browser', 'dia') + + expect(select('Profile').value).toBe('dia:Default') + }) + + it('imports the browser and profile the user chose', async () => { + await render() + await choose('Browser', 'arc') + await choose('Profile', 'arc:Profile 2') + + await act(async () => { + buttonLabelled('Import').dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onImport).toHaveBeenCalledWith( + expect.objectContaining({ id: 'arc:Profile 2', label: 'Arc · Microtrades' }) + ) + }) + + it('defaults to the first browser and its first profile', async () => { + await render() + + expect(select('Browser').value).toBe('chrome') + expect(select('Profile').value).toBe('chrome:Default') + }) + + it('locks the controls while an import is running', async () => { + await render(PROFILES, true) + + expect(select('Browser').disabled).toBe(true) + expect(select('Profile').disabled).toBe(true) + expect(buttonLabelled('Importing...').disabled).toBe(true) + }) + + it('cannot import when nothing was discovered', async () => { + await render([]) + + expect(buttonLabelled('Import').disabled).toBe(true) + expect(onImport).not.toHaveBeenCalled() + }) + + it('dismisses without importing', async () => { + await render() + + await act(async () => { + buttonLabelled('Cancel').dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onImport).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx new file mode 100644 index 0000000000..a04b92e961 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx @@ -0,0 +1,115 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import type { BrowserImportProfile } from '@sim/desktop-bridge' +import { + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' + +interface ImportModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Every importable profile across every detected browser. */ + profiles: BrowserImportProfile[] + pending: boolean + onImport: (profile: BrowserImportProfile) => void +} + +/** One entry per browser, in the order profiles were discovered. */ +function browserOptions(profiles: BrowserImportProfile[]) { + const seen = new Map() + for (const { browserId, browserLabel } of profiles) { + const id = browserId ?? 'chrome' + if (!seen.has(id)) seen.set(id, browserLabel ?? 'Chrome') + } + return [...seen].map(([value, label]) => ({ value, label })) +} + +/** + * Chooses what to bring into the built-in browser. + * + * Browser and profile are separate fields because they are separate + * decisions: which application, then which identity inside it. Both are + * required — Sim's browser has one profile, so importing is choosing which + * single identity it takes on, and there is no coherent "all of them" (two + * profiles' cookies for the same site would just overwrite each other). + */ +export function ImportModal({ open, onOpenChange, profiles, pending, onImport }: ImportModalProps) { + const browsers = useMemo(() => browserOptions(profiles), [profiles]) + const [browserId, setBrowserId] = useState(browsers[0]?.value ?? '') + + const profilesForBrowser = useMemo( + () => profiles.filter((profile) => (profile.browserId ?? 'chrome') === browserId), + [browserId, profiles] + ) + const [profileId, setProfileId] = useState(profilesForBrowser[0]?.id ?? '') + + // Keep the selection valid as the browser changes or the list reloads, + // rather than leaving a profile selected that belongs to another browser. + useEffect(() => { + if (!profilesForBrowser.some((profile) => profile.id === profileId)) { + setProfileId(profilesForBrowser[0]?.id ?? '') + } + }, [profileId, profilesForBrowser]) + + useEffect(() => { + if (!browsers.some((browser) => browser.value === browserId)) { + setBrowserId(browsers[0]?.value ?? '') + } + }, [browserId, browsers]) + + const selected = profiles.find((profile) => profile.id === profileId) ?? null + + return ( + + onOpenChange(false)}> + Import from your browser + + +

+ Copies cookies and saved passwords into Sim’s browser, and reads which sites you use there + so the address bar can suggest them. The other browser is only read, never changed, and + nothing is uploaded. +

+ + ({ + value: profile.id, + label: profile.profileLabel ?? profile.label, + }))} + value={profileId} + onChange={setProfileId} + placeholder='Select a profile' + align='start' + disabled={pending || profilesForBrowser.length === 0} + /> +
+ onOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: pending ? 'Importing...' : 'Import', + disabled: pending || selected === null, + onClick: () => { + if (selected) onImport(selected) + }, + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx new file mode 100644 index 0000000000..e154e12b59 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx @@ -0,0 +1,291 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const { mockBridge, mockToast } = vi.hoisted(() => ({ + mockBridge: { current: null as unknown }, + mockToast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@sim/emcn', () => ({ + ArrowLeft: () => , + Button: ({ + children, + disabled, + onClick, + 'aria-label': ariaLabel, + }: { + children: ReactNode + disabled?: boolean + onClick?: () => void + 'aria-label'?: string + }) => ( + + ), + Duplicate: () => , + Eye: () => , + EyeOff: () => , + Tooltip: { + Root: ({ children }: { children: ReactNode }) => <>{children}, + Trigger: ({ children }: { children: ReactNode }) => <>{children}, + Content: ({ children }: { children: ReactNode }) => <>{children}, + }, + ChipConfirmModal: ({ + open, + title, + confirm, + }: { + open: boolean + title: string + confirm: { label: string; onClick: () => void } + }) => + open ? ( +
+ +
+ ) : null, + ChipCopyInput: ({ value }: { value: string }) => , + Key: () => , + ChipInput: ({ value, endAdornment, ...props }: { value: string; endAdornment?: ReactNode }) => ( + <> + + {endAdornment} + + ), + toast: mockToast, +})) + +vi.mock('@/lib/desktop', () => ({ getDesktopBridge: () => mockBridge.current })) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ + children, + back, + actions, + }: { + children: ReactNode + back?: { text: string; onSelect: () => void } + actions?: Array<{ text: string; onSelect: () => void; disabled?: boolean }> + }) => ( +
+
+ {back ? ( + + ) : null} + {(actions ?? []).map((action) => ( + + ))} +
+ {children} +
+ ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children, label }: { children: ReactNode; label: string }) => ( +
{children}
+ ), + }) +) + +import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' + +const CREDENTIAL: BrowserCredentialMetadata = { + id: 'c1', + origin: 'https://example.com', + username: 'ada@example.com', + createdAt: '', + updatedAt: '', + source: 'chrome', +} + +function createBridge({ revealed = 'hunter2' as string | null } = {}) { + return { + browserCredentials: { + reveal: vi.fn(async () => revealed), + copy: vi.fn(async () => true), + forget: vi.fn(async () => []), + }, + } +} + +let container: HTMLDivElement +let root: Root +let onBack: ReturnType +let onForgotten: ReturnType + +async function render(credential = CREDENTIAL) { + await act(async () => { + root.render( + + ) + }) +} + +/** Icon buttons carry no text, so they are found by accessible name. */ +function buttonWithLabel(label: string): HTMLButtonElement { + const button = container.querySelector(`button[aria-label="${label}"]`) + if (!button) throw new Error(`No button labelled "${label}"`) + return button +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +const passwordField = () => + container.querySelector('input[aria-label="Password"]')?.value + +const bridge = () => mockBridge.current as ReturnType + +describe('PasswordDetail', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onBack = vi.fn() + onForgotten = vi.fn() + mockBridge.current = createBridge() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('shows the site and username, but never the password up front', async () => { + await render() + + const values = [...container.querySelectorAll('input')].map((input) => input.value) + expect(values).toContain('https://example.com') + expect(values).toContain('ada@example.com') + expect(passwordField()).toBe('••••••••••••') + expect(values).not.toContain('hunter2') + }) + + it('reveals the password only after the shell authorizes it', async () => { + await render() + + await click(buttonWithLabel('Show password')) + + expect(bridge().browserCredentials.reveal).toHaveBeenCalledWith('c1') + expect(passwordField()).toBe('hunter2') + }) + + it('stays masked when the user declines the Touch ID prompt', async () => { + mockBridge.current = createBridge({ revealed: null }) + await render() + + await click(buttonWithLabel('Show password')) + + expect(passwordField()).toBe('••••••••••••') + }) + + it('hides a revealed password again when asked', async () => { + await render() + await click(buttonWithLabel('Show password')) + + await click(buttonWithLabel('Hide password')) + + expect(passwordField()).toBe('••••••••••••') + }) + + it('re-hides a revealed password on its own', async () => { + // Walking away from the screen must not leave a password on it. + vi.useFakeTimers({ shouldAdvanceTime: true }) + await render() + await click(buttonWithLabel('Show password')) + expect(passwordField()).toBe('hunter2') + + await act(async () => { + vi.advanceTimersByTime(30_000) + }) + + expect(passwordField()).toBe('••••••••••••') + }) + + it('copies through the shell so the password never enters the page', async () => { + await render() + + await click(buttonWithLabel('Copy password')) + + expect(bridge().browserCredentials.copy).toHaveBeenCalledWith('c1') + expect(container.textContent).not.toContain('hunter2') + // Copying is silent — the Touch ID prompt was the feedback. + expect(mockToast.success).not.toHaveBeenCalled() + }) + + it('forgets the credential only after confirmation, then returns to the list', async () => { + await render() + + await click(buttonLabelled('Forget')) + expect(bridge().browserCredentials.forget).not.toHaveBeenCalled() + + await click(buttonLabelled('Confirm Forget')) + + expect(bridge().browserCredentials.forget).toHaveBeenCalledWith('c1') + expect(onForgotten).toHaveBeenCalledWith([]) + expect(onBack).toHaveBeenCalled() + }) + + it('disables reveal and copy on shells that predate them', async () => { + const stale = createBridge() + ;(stale.browserCredentials as { reveal?: unknown }).reveal = undefined + mockBridge.current = stale + await render() + + expect(buttonWithLabel('Show password').disabled).toBe(true) + expect(buttonWithLabel('Copy password').disabled).toBe(true) + }) + + it('shows the site\u2019s own icon when the import captured one', async () => { + await render({ ...CREDENTIAL, icon: 'data:image/png;base64,AA' }) + + expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AA') + }) + + it('falls back to a glyph when the source browser had no icon', async () => { + await render() + + expect(container.querySelector('img')).toBeNull() + }) + + it('returns to the list', async () => { + await render() + + await click(buttonLabelled('Passwords')) + + expect(onBack).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx new file mode 100644 index 0000000000..0c30113626 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -0,0 +1,236 @@ +'use client' + +import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { + ArrowLeft, + Button, + ChipConfirmModal, + ChipCopyInput, + ChipInput, + Duplicate, + Eye, + EyeOff, + Key, + Tooltip, + toast, +} from '@sim/emcn' +import { getDesktopBridge } from '@/lib/desktop' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +/** The same focus-independent mask the Secrets page uses for values. */ +const MASKED_STYLE = { WebkitTextSecurity: 'disc' } as CSSProperties +const MASK_PLACEHOLDER = '\u2022'.repeat(12) + +/** + * A revealed password re-hides itself rather than staying on screen for the + * rest of the session. Long enough to read or type, short enough that walking + * away does not leave it visible. + */ +const REVEAL_TIMEOUT_MS = 30_000 + +function siteLabel(origin: string): string { + return origin.replace(/^https?:\/\//, '') +} + +interface PasswordDetailProps { + credential: BrowserCredentialMetadata + onBack: () => void + onForgotten: (credentials: BrowserCredentialMetadata[]) => void +} + +/** + * One saved login. + * + * The password is masked like any other secret field, but unlike the Secrets + * page it does not reveal on focus — Sim does not hold the plaintext until the + * shell hands it over, and the shell asks for Touch ID first. Copying never + * brings the password into this page at all. + */ +export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDetailProps) { + const [revealed, setRevealed] = useState(null) + const [busy, setBusy] = useState(false) + const [confirmingForget, setConfirmingForget] = useState(false) + const hideTimer = useRef | null>(null) + + const hide = useCallback(() => { + if (hideTimer.current) clearTimeout(hideTimer.current) + hideTimer.current = null + setRevealed(null) + }, []) + + // Nothing revealed may outlive this view, including a switch to another + // credential without unmounting. + useEffect(() => hide, [hide]) + useEffect(() => { + hide() + }, [hide]) + + const toggleReveal = useCallback(async () => { + if (revealed !== null) { + hide() + return + } + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge?.reveal) return + setBusy(true) + try { + // The shell prompts for Touch ID here; null means the user declined. + const password = await bridge.reveal(credential.id) + if (password === null) return + setRevealed(password) + hideTimer.current = setTimeout(hide, REVEAL_TIMEOUT_MS) + } catch { + toast.error('Could not show that password') + } finally { + setBusy(false) + } + }, [credential.id, hide, revealed]) + + const copy = useCallback(async () => { + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge?.copy) return + setBusy(true) + try { + await bridge.copy(credential.id) + } catch { + toast.error('Could not copy that password') + } finally { + setBusy(false) + } + }, [credential.id]) + + const forget = useCallback(async () => { + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge) return + setBusy(true) + try { + hide() + onForgotten(await bridge.forget(credential.id)) + onBack() + } catch { + toast.error('Could not forget that password') + } finally { + setBusy(false) + } + }, [credential.id, hide, onBack, onForgotten]) + + const site = siteLabel(credential.origin) + const canReveal = typeof getDesktopBridge()?.browserCredentials?.reveal === 'function' + + return ( + <> + setConfirmingForget(true), + disabled: busy, + }, + ]} + > + +
+
+ Site +
+
+
+ {credential.icon ? ( + // A `data:` URL copied from the source browser at import + // time — never a network request, which would disclose + // which sites the user has passwords for. + + ) : ( + + )} +
+
+ +
+
+ +
+ Username + +
+ +
+ Password + + + + + + + {revealed ? 'Hide password' : 'Show password'} + + + + + + + Copy password + + + } + /> +
+
+
+
+ + !open && setConfirmingForget(false)} + title='Forget password' + text={[ + 'Sim will delete the saved password for ', + { text: site, bold: true }, + '. Your account on that site is not affected.', + ]} + confirm={{ + label: 'Forget', + pending: busy, + pendingLabel: 'Forgetting...', + onClick: () => void forget(), + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx new file mode 100644 index 0000000000..f43411802b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -0,0 +1,302 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const { mockBridge, mockSearch, mockToast } = vi.hoisted(() => ({ + mockBridge: { current: null as unknown }, + mockSearch: { value: '' }, + mockToast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@sim/emcn', () => ({ + ArrowLeft: () => , + ArrowRight: () => , + ChipConfirmModal: ({ + open, + title, + confirm, + }: { + open: boolean + title: string + confirm: { label: string; onClick: () => void } + }) => + open ? ( +
+ +
+ ) : null, + Key: () => , + Plus: () => , + toast: mockToast, +})) + +vi.mock('@/lib/desktop', () => ({ getDesktopBridge: () => mockBridge.current })) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/use-settings-search', () => ({ + useSettingsSearch: () => [mockSearch.value, vi.fn()], +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ + children, + back, + actions, + }: { + children: ReactNode + back?: { text: string; onSelect: () => void } + actions?: Array<{ text: string; onSelect: () => void; disabled?: boolean }> + }) => ( +
+
+ {back ? ( + + ) : null} + {(actions ?? []).map((action) => ( + + ))} +
+ {children} +
+ ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state', + () => ({ SettingsEmptyState: ({ children }: { children: ReactNode }) =>

{children}

}) +) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal', + () => ({ + ImportModal: ({ + open, + profiles, + onImport, + }: { + open: boolean + profiles: Array<{ id: string; label: string }> + onImport: (profile: { id: string; label: string }) => void + }) => + open ? ( +
+ +
+ ) : null, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail', + () => ({ + PasswordDetail: ({ credential }: { credential: BrowserCredentialMetadata }) => ( +
{credential.origin}
+ ), + }) +) + +import { PasswordsView } from '@/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view' + +function credential(id: string, origin: string, username: string): BrowserCredentialMetadata { + return { id, origin, username, createdAt: '', updatedAt: '', source: 'chrome' } +} + +const CREDENTIALS = [ + credential('c1', 'https://example.com', 'ada@example.com'), + credential('c2', 'https://fubo.tv', 'grace'), +] + +function createBridge({ profiles = [{ id: 'chrome:Default', label: 'Chrome' }] } = {}) { + return { + browserCredentials: { + forgetAll: vi.fn(async () => []), + forget: vi.fn(async () => []), + reveal: vi.fn(async () => 'hunter2'), + copy: vi.fn(async () => true), + }, + browserImport: { + listChromeProfiles: vi.fn(async () => profiles), + importFromChrome: vi.fn(async () => ({ + cookies: { cookiesImported: 4, cookiesSkipped: 0 }, + passwords: { passwordsAdded: 2, passwordsUpdated: 1, passwordsSkipped: 0 }, + })), + }, + } +} + +let container: HTMLDivElement +let root: Root +let onChange: ReturnType +let onBack: ReturnType +let onImported: ReturnType + +async function render(credentials = CREDENTIALS) { + await act(async () => { + root.render( + + ) + }) +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +/** Cards are buttons outside the header; each shows a site and a username. */ +const cards = () => + [...container.querySelectorAll('main > button, main div button')].filter( + (node) => !node.closest('header') + ) + +const bridge = () => mockBridge.current as ReturnType + +describe('PasswordsView', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onChange = vi.fn() + onBack = vi.fn() + onImported = vi.fn(async () => {}) + mockSearch.value = '' + mockBridge.current = createBridge() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('lists one card per login, showing site and username', async () => { + await render() + + expect(cards()).toHaveLength(2) + expect(cards()[0].textContent).toContain('example.com') + expect(cards()[0].textContent).toContain('ada@example.com') + }) + + it('never shows a password in the list', async () => { + // Reading one happens on the detail page, behind Touch ID. + await render() + + expect(container.textContent).not.toContain('hunter2') + expect(bridge().browserCredentials.reveal).not.toHaveBeenCalled() + }) + + it('opens the detail page for the card that was clicked', async () => { + await render() + + await click(cards()[1] as HTMLButtonElement) + + expect(container.querySelector('[aria-label="Password detail"]')?.textContent).toBe( + 'https://fubo.tv' + ) + }) + + it('filters by site and username', async () => { + mockSearch.value = 'fubo' + await render() + expect(cards()).toHaveLength(1) + + mockSearch.value = 'ada@' + await render() + expect(cards()[0].textContent).toContain('example.com') + }) + + it('says so when a search matches nothing', async () => { + mockSearch.value = 'nothing-here' + await render() + + expect(container.textContent).toContain('No passwords found matching') + }) + + it('explains an empty vault', async () => { + await render([]) + + expect(container.textContent).toContain('No saved passwords yet') + }) + + it('deletes every password only after confirmation', async () => { + await render() + + await click(buttonLabelled('Delete all')) + expect(bridge().browserCredentials.forgetAll).not.toHaveBeenCalled() + + await click(buttonLabelled('Confirm Delete all')) + + expect(bridge().browserCredentials.forgetAll).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('offers no delete action when there is nothing to delete', async () => { + await render([]) + + expect( + [...container.querySelectorAll('header button')].map((b) => b.textContent) + ).not.toContain('Delete all') + }) + + it('imports the chosen profile and tells the browser page to refresh', async () => { + await render() + + await click(buttonLabelled('Import')) + await click(buttonLabelled('Confirm import')) + + // 'replace' so a password rotated in the other browser actually lands here. + expect(bridge().browserImport.importFromChrome).toHaveBeenCalledWith( + 'chrome:Default', + 'replace' + ) + expect(mockToast.success).toHaveBeenCalledWith('Imported 4 cookies and 3 passwords from Chrome') + expect(onImported).toHaveBeenCalled() + }) + + it('hides import when no other browser was found', async () => { + mockBridge.current = createBridge({ profiles: [] }) + await render() + + expect( + [...container.querySelectorAll('header button')].map((b) => b.textContent) + ).not.toContain('Import') + }) + + it('returns to the browser page', async () => { + await render() + + await click(buttonLabelled('Browser')) + + expect(onBack).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx new file mode 100644 index 0000000000..44eefc4b1c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { + BrowserChromeImportResult, + BrowserCredentialMetadata, + BrowserImportError, + BrowserImportProfile, +} from '@sim/desktop-bridge' +import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' +import { getDesktopBridge } from '@/lib/desktop' +import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' +import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' + +/** The integrations page's responsive card grid and row chrome. */ +const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' +const CARD_CLASSES = + 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' +const CARD_TILE_CLASSES = + 'flex size-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)]' +const CARD_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' +const CARD_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' +const CARD_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' + +const IMPORT_ERROR_MESSAGES: Record = { + 'unsupported-platform': 'Importing from another browser is only supported on macOS.', + 'chrome-not-found': 'Could not find that browser profile.', + 'keychain-unavailable': + 'Sim needs your permission to read that browser’s saved data. Allow the Keychain prompt and try again.', + 'profile-unreadable': + 'Could not read that browser’s data. Try quitting the other browser, then import again.', + 'unsupported-schema': 'That browser stores its data in a format Sim cannot read yet.', + 'nothing-imported': 'Nothing from that profile could be imported.', + 'vault-unavailable': + 'This device cannot store passwords securely, so saved passwords were not imported.', + unknown: 'Could not import from that browser.', +} + +function siteLabel(origin: string): string { + return origin.replace(/^https?:\/\//, '') +} + +function pluralize(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}` +} + +/** Describes what actually landed, without over-claiming that sites are signed in. */ +function summarize({ cookies, passwords }: BrowserChromeImportResult): string | null { + const parts: string[] = [] + if (cookies.cookiesImported > 0) parts.push(pluralize(cookies.cookiesImported, 'cookie')) + const saved = passwords.passwordsAdded + passwords.passwordsUpdated + if (saved > 0) parts.push(pluralize(saved, 'password')) + return parts.length > 0 ? `Imported ${parts.join(' and ')}` : null +} + +interface PasswordsViewProps { + credentials: BrowserCredentialMetadata[] + onChange: (credentials: BrowserCredentialMetadata[]) => void + onBack: () => void + /** Lets the Browser page refresh its own counts after an import. */ + onImported: () => Promise +} + +/** + * The saved-password list, laid out like the integrations page: one card per + * login, each opening its own detail page. Nothing secret is shown here — the + * password only exists on the detail page, and only after Touch ID. + */ +export function PasswordsView({ credentials, onChange, onBack, onImported }: PasswordsViewProps) { + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedId, setSelectedId] = useState(null) + const [confirmingDeleteAll, setConfirmingDeleteAll] = useState(false) + const [deleteAllPending, setDeleteAllPending] = useState(false) + const [profiles, setProfiles] = useState([]) + const [importOpen, setImportOpen] = useState(false) + const [importPending, setImportPending] = useState(false) + + useEffect(() => { + // Absent on shells without the importer and on platforms where one cannot + // run, so the action simply does not render there. + const listProfiles = getDesktopBridge()?.browserImport?.listChromeProfiles + if (!listProfiles) return + void listProfiles() + .then(setProfiles) + .catch(() => setProfiles([])) + }, []) + + /** + * Runs straight off the modal's Import click: the shell only accepts an + * import while the page has an active user gesture, so this must not be + * deferred behind another await first. + */ + const importFromBrowser = useCallback( + async (profile: BrowserImportProfile) => { + const runImport = getDesktopBridge()?.browserImport?.importFromChrome + if (!runImport) return + setImportPending(true) + try { + // 'replace' so a password rotated in the other browser actually lands + // here. Sim cannot edit passwords itself, so the browser being + // imported from is always the more current source. + const result = await runImport(profile.id, 'replace') + const summary = summarize(result) + if (summary) { + toast.success(`${summary} from ${profile.label}`) + setImportOpen(false) + } else { + const error = result.cookies.error ?? result.passwords.error + toast.error(error ? IMPORT_ERROR_MESSAGES[error] : 'Nothing new to import') + } + await onImported() + } catch { + toast.error('Could not import from that browser') + } finally { + setImportPending(false) + } + }, + [onImported] + ) + + const forgetAll = useCallback(async () => { + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge?.forgetAll) return + setDeleteAllPending(true) + try { + onChange(await bridge.forgetAll()) + setConfirmingDeleteAll(false) + toast.success('Deleted every saved password') + } catch { + toast.error('Could not delete saved passwords') + } finally { + setDeleteAllPending(false) + } + }, [onChange]) + + const filtered = useMemo(() => { + const needle = searchTerm.trim().toLowerCase() + if (!needle) return credentials + return credentials.filter( + ({ origin, username }) => + origin.toLowerCase().includes(needle) || username.toLowerCase().includes(needle) + ) + }, [credentials, searchTerm]) + + const selected = credentials.find(({ id }) => id === selectedId) ?? null + if (selected) { + return ( + setSelectedId(null)} + onForgotten={onChange} + /> + ) + } + + const canForgetAll = typeof getDesktopBridge()?.browserCredentials?.forgetAll === 'function' + const canImport = profiles.length > 0 + + return ( + <> + 0 + ? [ + { + text: 'Delete all', + variant: 'destructive' as const, + onSelect: () => setConfirmingDeleteAll(true), + disabled: deleteAllPending, + }, + ] + : []), + ...(canImport + ? [ + { + text: 'Import', + icon: Plus, + variant: 'primary' as const, + onSelect: () => setImportOpen(true), + disabled: importPending, + }, + ] + : []), + ]} + > + {credentials.length === 0 ? ( + + No saved passwords yet. Import them from another browser to bring them over. + + ) : ( + <> +
+ {filtered.map((credential) => ( + + ))} +
+ + {filtered.length === 0 && ( + + No passwords found matching “{searchTerm}” + + )} + + )} +
+ + void importFromBrowser(profile)} + /> + + !open && setConfirmingDeleteAll(false)} + title='Delete all passwords' + text={[ + 'This permanently deletes ', + { text: `all ${pluralize(credentials.length, 'saved password')}`, bold: true }, + ' from this device. Your accounts on those sites are not affected, and you can import again from another browser.', + ]} + confirm={{ + label: 'Delete all', + pending: deleteAllPending, + pendingLabel: 'Deleting...', + onClick: () => void forgetAll(), + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/index.ts new file mode 100644 index 0000000000..754694a61f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/index.ts @@ -0,0 +1 @@ +export { Browser } from './browser' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx new file mode 100644 index 0000000000..d0c0615236 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -0,0 +1,353 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { + DesktopPreferenceKey, + DesktopPreferences, + DesktopUpdateState, + LocalFilesystemMount, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { Chip, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn' +import { Folder } from '@sim/emcn/icons' +import { useParams, useRouter } from 'next/navigation' +import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null { + return response.ok && 'mounts' in response.data ? response.data.mounts : null +} + +interface PreferenceRowProps { + id: string + label: string + checked: boolean + disabled?: boolean + onCheckedChange: (checked: boolean) => void +} + +function PreferenceRow({ id, label, checked, disabled, onCheckedChange }: PreferenceRowProps) { + return ( +
+ + +
+ ) +} + +interface UpdateChip { + label: string + disabled?: boolean + onClick: () => void +} + +/** The Updates section's single action, driven by the shell update pipeline. */ +function updateChipFor(state: DesktopUpdateState): UpdateChip { + const updates = getDesktopUpdates() + const check = () => updates?.check() + switch (state.status) { + case 'checking': + return { label: 'Checking...', disabled: true, onClick: () => {} } + case 'available': + return { label: 'Download update', onClick: check } + case 'downloading': + return { + label: state.percent !== undefined ? `Downloading ${state.percent}%` : 'Downloading...', + disabled: true, + onClick: () => {}, + } + case 'ready': + return { label: 'Restart to update', onClick: () => updates?.install() } + case 'error': + return { label: 'Try again', onClick: check } + default: + return { label: 'Check for updates', onClick: check } + } +} + +export function Desktop() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + const [preferences, setPreferences] = useState(null) + const [mounts, setMounts] = useState([]) + const [pendingPreference, setPendingPreference] = useState< + DesktopPreferenceKey | 'trayEnabled' | null + >(null) + const [mountToForget, setMountToForget] = useState(null) + const [mountMutationPending, setMountMutationPending] = useState(false) + const [updateState, setUpdateState] = useState({ status: 'idle' }) + const [hasUpdatesSurface, setHasUpdatesSurface] = useState(false) + const [shellVersion, setShellVersion] = useState(undefined) + + const refreshMounts = useCallback(async () => { + const bridge = getDesktopBridge() + if (!bridge) return + const response = await bridge.localFilesystem({ operation: 'list_mounts' }) + const nextMounts = getMounts(response) + if (nextMounts) { + setMounts(nextMounts) + return + } + toast.error('Could not load folder access') + }, []) + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.settings) { + router.replace(`/workspace/${workspaceId}/settings/general`) + return + } + void Promise.all([bridge.settings.getPreferences(), refreshMounts()]) + .then(([nextPreferences]) => setPreferences(nextPreferences)) + .catch(() => toast.error('Could not load desktop settings')) + }, [refreshMounts, router, workspaceId]) + + useEffect(() => { + setShellVersion(getDesktopShellVersion()) + const updates = getDesktopUpdates() + if (!updates) return + setHasUpdatesSurface(true) + const unsubscribe = updates.onState(setUpdateState) + void updates + .getState() + .then(setUpdateState) + .catch(() => {}) + return unsubscribe + }, []) + + const updatePreference = useCallback(async (key: DesktopPreferenceKey, value: boolean) => { + const settings = getDesktopBridge()?.settings + if (!settings) return + setPendingPreference(key) + try { + setPreferences(await settings.setPreference(key, value)) + } catch { + toast.error('Could not update desktop settings') + } finally { + setPendingPreference(null) + } + }, []) + + const updateTrayEnabled = useCallback(async (enabled: boolean) => { + const settings = getDesktopBridge()?.settings + if (!settings?.setTrayEnabled) return + setPendingPreference('trayEnabled') + try { + setPreferences(await settings.setTrayEnabled(enabled)) + } catch { + toast.error('Could not update desktop settings') + } finally { + setPendingPreference(null) + } + }, []) + + const addFolder = useCallback(async () => { + const bridge = getDesktopBridge() + if (!bridge) return + setMountMutationPending(true) + try { + const response = await bridge.localFilesystem({ operation: 'mount_directory' }) + if (!response.ok) { + toast.error('Could not add folder access') + return + } + await refreshMounts() + } finally { + setMountMutationPending(false) + } + }, [refreshMounts]) + + const revealFolder = useCallback(async (mount: LocalFilesystemMount) => { + const bridge = getDesktopBridge() + if (!bridge) return + const response = await bridge.localFilesystem({ operation: 'reveal_mount', uri: mount.uri }) + if (!response.ok) toast.error(`Could not open folder: ${response.error}`) + }, []) + + const forgetFolder = useCallback(async () => { + const bridge = getDesktopBridge() + if (!bridge || !mountToForget) return + setMountMutationPending(true) + try { + const response = await bridge.localFilesystem({ + operation: 'forget_mount', + uri: mountToForget.uri, + }) + if (!response.ok) { + toast.error('Could not revoke folder access') + return + } + setMountToForget(null) + await refreshMounts() + } finally { + setMountMutationPending(false) + } + }, [mountToForget, refreshMounts]) + + if (!preferences) { + return null + } + + const notificationsDisabled = + !preferences.notificationsEnabled || pendingPreference === 'notificationsEnabled' + + return ( + <> + + +
+ void updatePreference('notificationsEnabled', checked)} + /> + void updatePreference('notificationSounds', checked)} + /> + + void updatePreference('notificationsOnlyWhenUnfocused', checked) + } + /> +
+
+ + +
+ void updatePreference('launchAtLogin', checked)} + /> + {getDesktopBridge()?.settings?.setTrayEnabled && ( + void updateTrayEnabled(checked)} + /> + )} +
+
+ + { + const chip = updateChipFor(updateState) + return ( + + {chip.label} + + ) + })() + : undefined + } + > +
+ void updatePreference('autoDownloadUpdates', checked)} + /> + {shellVersion && ( +
+ + + {updateState.status === 'ready' && updateState.version + ? `${shellVersion} → ${updateState.version} on restart` + : shellVersion} + +
+ )} +
+
+ + void addFolder()} disabled={mountMutationPending}> + Add folder + + } + > + {mounts.length === 0 ? ( + + No folder access granted. Chat can only read folders you add here. + + ) : ( +
+ {mounts.map((mount) => ( + } + iconVariant='plain' + title={mount.name} + onClick={() => void revealFolder(mount)} + clickLabel={`Show ${mount.name} in the file manager`} + trailing={ +
+ {!mount.remembered && ( + + Until app restarts + + )} + setMountToForget(mount), + }, + ]} + /> +
+ } + /> + ))} +
+ )} +
+
+ + !open && setMountToForget(null)} + title='Revoke folder access' + text={[ + 'Sim will no longer be able to read ', + { text: mountToForget?.name ?? 'this folder', bold: true }, + '. You can grant access again at any time.', + ]} + confirm={{ + label: 'Revoke access', + pending: mountMutationPending, + pendingLabel: 'Revoking...', + onClick: () => void forgetFolder(), + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/index.ts new file mode 100644 index 0000000000..12fd9da432 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/index.ts @@ -0,0 +1 @@ +export { Desktop } from './desktop' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index f5e9ea6c73..d548141675 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -18,10 +18,17 @@ import { interface SettingsResourceRowProps { /** Icon node centered in the tile; a `` is normalized to 20px, an `` to 20px (or the full tile when `iconFill`). */ icon: ReactNode + /** + * Icon chrome. `tile` (default) is the bordered 36px tile for brand/logo and + * resource icons; `plain` drops the tile for a bare 14px glyph in + * `--text-icon`, for rows whose icon is a type marker rather than an identity + * (e.g. a folder on disk). + */ + iconVariant?: 'tile' | 'plain' /** * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still - * normalize to 20px so a fallback icon doesn't balloon. + * normalize to 20px so a fallback icon doesn't balloon. Tile variant only. */ iconFill?: boolean /** @@ -38,35 +45,73 @@ interface SettingsResourceRowProps { * keeps it at its natural size — callers never need their own `flex-shrink-0`. */ trailing?: ReactNode + /** + * Makes the icon + text cluster activatable. `trailing` stays a sibling, so + * its own controls keep working — never nest an interactive `trailing` inside + * the row's own hit area. + */ + onClick?: () => void + /** Accessible name for the activatable cluster. Required alongside `onClick`. */ + clickLabel?: string } +const PLAIN_BASE = + 'flex size-[14px] flex-shrink-0 items-center justify-center text-[var(--text-icon)] [&_svg]:size-[14px] [&_img]:size-[14px]' + export function SettingsResourceRow({ icon, + iconVariant = 'tile', iconFill = false, iconFilled = false, title, description, trailing, + onClick, + clickLabel, }: SettingsResourceRowProps) { + const isTile = iconVariant === 'tile' + const cluster = ( + <> +
+ {icon} +
+
+ {title} + {description != null && ( + {description} + )} +
+ + ) + const clusterClass = cn('flex min-w-0 items-center', isTile ? 'gap-2.5' : 'gap-2') + return (
-
-
- {icon} -
-
- {title} - {description != null && ( - {description} - )} -
-
+ {cluster} + + ) : ( +
{cluster}
+ )} {trailing ?
{trailing}
: null}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/index.ts new file mode 100644 index 0000000000..5dd47218ab --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/index.ts @@ -0,0 +1 @@ +export { Terminal } from './terminal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/terminal.tsx new file mode 100644 index 0000000000..dc4e399e0d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/terminal/terminal.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { DesktopPreferences } from '@sim/desktop-bridge' +import { Label, Switch, toast } from '@sim/emcn' +import { useParams, useRouter } from 'next/navigation' +import { getDesktopBridge, setDesktopPreferencesSnapshot } from '@/lib/desktop' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +export function Terminal() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + const [preferences, setPreferences] = useState(null) + const [togglePending, setTogglePending] = useState(false) + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.terminal || !bridge.settings) { + router.replace(`/workspace/${workspaceId}/settings/general`) + return + } + void bridge.settings + .getPreferences() + .then(setPreferences) + .catch(() => toast.error('Could not load terminal settings')) + }, [router, workspaceId]) + + const setEnabled = useCallback(async (enabled: boolean) => { + const setTerminalEnabled = getDesktopBridge()?.settings?.setTerminalEnabled + if (!setTerminalEnabled) return + setTogglePending(true) + try { + const next = await setTerminalEnabled(enabled) + setPreferences(next) + setDesktopPreferencesSnapshot(next) + } catch { + toast.error('Could not update terminal settings') + } finally { + setTogglePending(false) + } + }, []) + + if (!preferences) { + return null + } + + return ( + + +
+
+ +

+ Commands run on this machine with your own permissions. +

+
+ void setEnabled(checked)} + /> +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index b3ed6ee0d1..d24163419a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -15,6 +15,7 @@ describe('unified settings navigation', () => { { key: 'tools', title: 'Tools' }, { key: 'subscription', title: 'Subscription' }, { key: 'system', title: 'System' }, + { key: 'desktop', title: 'Desktop' }, { key: 'enterprise', title: 'Enterprise' }, { key: 'superuser', title: 'Superuser' }, ]) @@ -23,6 +24,9 @@ describe('unified settings navigation', () => { it('keeps account, workspace, organization, and platform settings in one catalog', () => { expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([ { id: 'general', label: 'General', section: 'account' }, + { id: 'desktop', label: 'Desktop', section: 'desktop' }, + { id: 'browser', label: 'Browser', section: 'desktop' }, + { id: 'terminal', label: 'Terminal', section: 'desktop' }, { id: 'access-control', label: 'Access control', section: 'enterprise' }, { id: 'audit-logs', label: 'Audit logs', section: 'enterprise' }, { id: 'forks', label: 'Workspace Forks', section: 'enterprise' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index cc16ba40b3..bcd36c0299 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -19,6 +19,7 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'tools', title: 'Tools' }, { key: 'subscription', title: 'Subscription' }, { key: 'system', title: 'System' }, + { key: 'desktop', title: 'Desktop' }, { key: 'enterprise', title: 'Enterprise' }, { key: 'superuser', title: 'Superuser' }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 3519b23a03..13a3a89f19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -4,11 +4,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' +import type { DesktopSettingsSurface } from '@/components/settings/navigation' import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isHosted } from '@/lib/core/config/env-flags' +import { hasBrowserAgent, hasDesktopSettings, hasTerminal } from '@/lib/desktop' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -57,6 +59,11 @@ export function SettingsSidebar({ const showDiscardDialog = pendingLeave !== null const [hasOverflowTop, setHasOverflowTop] = useState(false) + const [desktopSurfaces, setDesktopSurfaces] = useState>({ + settings: false, + browser: false, + terminal: false, + }) const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -89,6 +96,10 @@ export function SettingsSidebar({ const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.requiresDesktopSurface && !desktopSurfaces[item.requiresDesktopSurface]) { + return false + } + if (item.hideWhenBillingDisabled && !isBillingEnabled) { return false } @@ -186,6 +197,7 @@ export function SettingsSidebar({ generalSettings?.superUserModeEnabled, forkingAvailable, canAdminWorkspace, + desktopSurfaces, ]) const activeSection = useMemo(() => { @@ -211,6 +223,15 @@ export function SettingsSidebar({ case 'billing': void import('@/app/workspace/[workspaceId]/settings/components/billing/billing') break + case 'desktop': + void import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop') + break + case 'browser': + void import('@/app/workspace/[workspaceId]/settings/components/browser/browser') + break + case 'terminal': + void import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal') + break } }, [queryClient, workspaceId] @@ -232,6 +253,14 @@ export function SettingsSidebar({ cancelLeave() }, [cancelLeave]) + useEffect(() => { + setDesktopSurfaces({ + settings: hasDesktopSettings(), + browser: hasBrowserAgent(), + terminal: hasTerminal(), + }) + }, []) + useEffect(() => { const container = scrollContainerRef.current if (!container) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index bd35b5b261..87bb0ad375 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -23,6 +23,7 @@ import { Trash, Unlock, Workflow, + X, } from '@sim/emcn/icons' import { Pin, PinOff } from 'lucide-react' @@ -54,6 +55,12 @@ interface ContextMenuProps { onDuplicate?: () => void onExport?: () => void onDelete: () => void + /** + * Closes the item rather than deleting it — for tabs, where the destructive + * action is "close this one", not "delete it forever". Named for the item so + * it cannot be confused with `onClose`, which dismisses this menu. + */ + onCloseTab?: () => void showOpenInNewTab?: boolean showFindReferences?: boolean showMarkAsRead?: boolean @@ -81,6 +88,7 @@ interface ContextMenuProps { disableLock?: boolean isLocked?: boolean showDelete?: boolean + showCloseTab?: boolean onUploadLogo?: () => void showUploadLogo?: boolean disableUploadLogo?: boolean @@ -107,6 +115,7 @@ export function ContextMenu({ onDuplicate, onExport, onDelete, + onCloseTab, showOpenInNewTab = false, showFindReferences = false, showMarkAsRead = false, @@ -134,6 +143,7 @@ export function ContextMenu({ disableLock = false, isLocked = false, showDelete = true, + showCloseTab = false, onUploadLogo, showUploadLogo = false, disableUploadLogo = false, @@ -342,7 +352,7 @@ export function ContextMenu({ )} {(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) && - (showLeave || showDelete) && } + (showLeave || showDelete || (showCloseTab && onCloseTab)) && } {showLeave && onLeave && ( )} + {showCloseTab && onCloseTab && ( + { + onCloseTab() + onClose() + }} + > + + Close + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx new file mode 100644 index 0000000000..4fe2657eff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx @@ -0,0 +1,29 @@ +'use client' + +import { Chip } from '@sim/emcn' +import { Mail } from '@sim/emcn/icons' +import { useMyPendingInvitations } from '@/hooks/queries/invitations' + +interface ViewInvitationsMenuItemProps { + /** Close the workspace menu and open the invitations modal. */ + onOpen: () => void +} + +/** + * "View invitations" entry in the workspace switcher — rendered only when the + * signed-in account has pending invitations. Mounted inside the dropdown + * content, so the check runs when the menu opens (cached between opens). + */ +export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps) { + const { data: invitations } = useMyPendingInvitations() + + if (!invitations || invitations.length === 0) { + return null + } + + return ( + + View invitations + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx new file mode 100644 index 0000000000..53a6056ece --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -0,0 +1,131 @@ +'use client' + +import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import type { InvitationDetails } from '@/lib/api/contracts/invitations' +import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' +import { + useAcceptMyInvitation, + useDeclineMyInvitation, + useMyPendingInvitations, +} from '@/hooks/queries/invitations' + +const logger = createLogger('ViewInvitationsModal') + +/** + * Display name for an invitation, mirroring the /invite page: organization + * invites are labeled by the org (even when workspace grants ride along); + * workspace invites by their workspace(s). + */ +function invitationLabel(inv: InvitationDetails): string { + if (inv.kind === 'organization') { + return inv.organizationName ?? 'Organization' + } + const first = inv.grants[0]?.workspaceName + if (first) { + const extra = inv.grants.length - 1 + return extra > 0 ? `${first} +${extra}` : first + } + return 'Workspace' +} + +/** Secondary line: who invited, plus role (org) or permission (workspace). */ +function invitationSubLabel(inv: InvitationDetails): string { + const invitedBy = inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited' + const detail = inv.kind === 'organization' ? inv.role : inv.grants[0]?.permission + return detail ? `${invitedBy} · ${detail}` : invitedBy +} + +interface ViewInvitationsModalProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * The invitee-facing pending-invitations modal, opened from the workspace + * switcher's "View invitations" entry. Accepting is session-bound (no token), + * so it works regardless of which browser the invite email was opened in — + * including the desktop app. Accepting closes the modal and navigates into + * the joined workspace; declining keeps it open for the remaining rows. + */ +export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModalProps) { + const { data: invitations } = useMyPendingInvitations(open) + const acceptInvitation = useAcceptMyInvitation() + const declineInvitation = useDeclineMyInvitation() + const router = useRouter() + + const isBusy = acceptInvitation.isPending || declineInvitation.isPending + + const handleAccept = async (inv: InvitationDetails) => { + try { + const result = await acceptInvitation.mutateAsync({ invitationId: inv.id }) + toast.success(`Joined ${invitationLabel(inv)}`) + onOpenChange(false) + router.push(result.redirectPath) + } catch (error) { + logger.error('Failed to accept invitation', { error }) + toast.error( + getInvitationErrorMessage( + getErrorMessage(error, ''), + 'Could not accept the invitation. It may have expired.' + ) + ) + } + } + + const handleDecline = async (inv: InvitationDetails) => { + try { + await declineInvitation.mutateAsync({ invitationId: inv.id }) + } catch (error) { + logger.error('Failed to decline invitation', { error }) + toast.error( + getInvitationErrorMessage(getErrorMessage(error, ''), 'Could not decline the invitation.') + ) + } + } + + return ( + + onOpenChange(false)}>Invitations + + {!invitations || invitations.length === 0 ? ( +

No pending invitations.

+ ) : ( + invitations.map((inv) => ( +
+
+

{invitationLabel(inv)}

+

+ {invitationSubLabel(inv)} +

+
+ void handleAccept(inv)} + className='flex-shrink-0' + > + Accept + + void handleDecline(inv)} + aria-label={`Decline invitation to ${invitationLabel(inv)}`} + className='flex-shrink-0' + > + Decline + +
+ )) + )} +
+ onOpenChange(false)} + primaryAction={{ label: 'Done', onClick: () => onOpenChange(false) }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index e648bc997d..574953a523 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -20,6 +20,7 @@ import { } from '@sim/emcn' import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { MoreHorizontal, Search } from 'lucide-react' import { useActiveOrganization } from '@/lib/auth/auth-client' import { isBillingEnabled } from '@/lib/core/config/env-flags' @@ -30,7 +31,14 @@ import { type CreateWorkspaceTarget, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' -import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { ViewInvitationsMenuItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item' +import { ViewInvitationsModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal' +import { invitationKeys } from '@/hooks/queries/invitations' +import { + type Workspace, + type WorkspaceCreationPolicy, + workspaceKeys, +} from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -136,6 +144,7 @@ function WorkspaceHeaderImpl({ }: WorkspaceHeaderProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) + const [isViewInvitationsOpen, setIsViewInvitationsOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false) @@ -227,6 +236,7 @@ function WorkspaceHeaderImpl({ const { data: viewerActiveOrganization } = useActiveOrganization() const { navigateToSettings } = useSettingsNavigation() + const queryClient = useQueryClient() const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null @@ -429,6 +439,14 @@ function WorkspaceHeaderImpl({ ) { return } + if (open) { + // Opening the switcher is the "user is looking" moment: refetch + // stale server state so a workspace the user was auto-added to, + // or a fresh pending invitation, appears without a page refresh + // (these are app-wide queries with no focus refetch on the web). + void queryClient.refetchQueries({ queryKey: workspaceKeys.lists(), stale: true }) + void queryClient.refetchQueries({ queryKey: invitationKeys.mine(), stale: true }) + } setIsWorkspaceMenuOpen(open) if (open && showSearch) { requestAnimationFrame(() => searchInputRef.current?.focus()) @@ -739,6 +757,12 @@ function WorkspaceHeaderImpl({ Invite teammates + { + setIsWorkspaceMenuOpen(false) + setIsViewInvitationsOpen(true) + }} + /> + setIsDeleteModalOpen(false)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index e7a22926be..383851add3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1279,7 +1279,11 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { onClick={handleSidebarClick} >
-
+
+
- +
diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 65a8678ccf..b71ed1cdc3 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -27,6 +27,7 @@ import type { } from '@sim/realtime-protocol/events' import { generateId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' +import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import type { Socket } from 'socket.io-client' import { getSocketUrl } from '@/lib/core/utils/urls' @@ -38,6 +39,7 @@ import { isSocketWorkflowVisible, resolveSocketWorkflowTarget, } from '@/app/workspace/providers/socket-join-target' +import { refreshSessionQuery } from '@/hooks/queries/session' import { useOperationQueueStore } from '@/stores/operation-queue/store' import type { SubblockUpdateEmit, @@ -171,6 +173,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const joinRetryTimeoutRef = useRef | null>(null) const authRetryAttemptsRef = useRef(0) const authRetryTimeoutRef = useRef | null>(null) + const sessionRejectedRef = useRef(false) + const queryClient = useQueryClient() const params = useParams() const urlWorkflowId = params?.workflowId as string | undefined @@ -386,6 +390,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { reconnectionDelayMax: 30000, timeout: 10000, auth: async (cb) => { + // Reset per attempt so the flag describes THIS handshake only — + // otherwise one early 401 would still be set when a later, unrelated + // denial exhausts the retry budget. + sessionRejectedRef.current = false try { const freshToken = await generateSocketToken() cb({ token: freshToken }) @@ -393,6 +401,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { logger.error('Failed to generate fresh token for connection:', error) if (error instanceof Error && error.message === 'Authentication required') { // True auth failure - pass null token, server will reject with "Authentication required" + sessionRejectedRef.current = true cb({ token: null }) } // For server errors, don't call cb - connection will timeout and Socket.IO will retry @@ -467,12 +476,28 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.connect() }, delayMs) } else { - logger.error( - 'Socket connection denied after max retries - stopping. User may need to refresh/re-login.', - { message: error.message } - ) + logger.error('Socket connection denied after max retries - stopping.', { + message: error.message, + }) setAuthFailed(true) setIsReconnecting(false) + + // The handshake that exhausted the budget was refused because the + // token mint reported no session, yet the app is still rendering as + // signed in — the classic symptom of Better Auth's session cookie + // cache vouching for a row that no longer exists. The mint reads the + // database directly, so it is the one caller that sees the divergence. + // Settle the canonical session query from server truth (that read + // bypasses the cookie cache too): a genuinely dead session resolves + // to null and hands off to SessionExpired, while a transient + // failure just refreshes the cache and leaves the user alone. + if (sessionRejectedRef.current) { + void refreshSessionQuery(queryClient).catch((refreshError) => { + logger.error('Failed to re-read the session after socket auth failure', { + error: refreshError, + }) + }) + } } }) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index d7f0aae364..ed8dce6e25 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -11,7 +11,7 @@ import { describeError, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task } from '@trigger.dev/sdk' import { Cron } from 'croner' -import { and, eq, isNull, type SQL, sql } from 'drizzle-orm' +import { and, eq, isNull, ne, type SQL, sql } from 'drizzle-orm' import { assertBillingAttributionSnapshot, BILLING_ATTRIBUTION_HEADER, @@ -141,7 +141,7 @@ async function applyScheduleUpdate( updates: WorkflowScheduleUpdate, requestId: string, context: string, - options: { expectedLastQueuedAt?: Date | null } = {} + options: { expectedLastQueuedAt?: Date | null; allowCompleted?: boolean } = {} ): Promise { try { const claimGuard = @@ -151,11 +151,27 @@ async function applyScheduleUpdate( ? isNull(workflowSchedule.lastQueuedAt) : eq(workflowSchedule.lastQueuedAt, options.expectedLastQueuedAt) + // A run that completes itself mid-execution (complete_scheduled_task, or a + // manage_scheduled_task update) sets status='completed'. The post-run + // bookkeeping that follows would otherwise write status='active' and a + // fresh nextRunAt straight back over it — the claim guard does not catch + // this, because completing the job does not touch lastQueuedAt. Terminal + // means terminal: only callers that explicitly opt in may move a completed + // row. + const notCompletedGuard = options.allowCompleted + ? undefined + : ne(workflowSchedule.status, 'completed') + const updatedRows = await db .update(workflowSchedule) .set(updates) .where( - and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt), claimGuard) + and( + eq(workflowSchedule.id, scheduleId), + isNull(workflowSchedule.archivedAt), + claimGuard, + notCompletedGuard + ) ) .returning({ id: workflowSchedule.id }) @@ -1365,7 +1381,9 @@ export async function executeJobInline(payload: JobExecutionPayload) { }, requestId, `Error updating job ${payload.scheduleId} after completion`, - { expectedLastQueuedAt: now } + // The tool already set status='completed'; this is bookkeeping on a + // deliberately terminal row, so it opts past the not-completed guard. + { expectedLastQueuedAt: now, allowCompleted: true } ) return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0846cab14d..0dc9a894ff 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -27,6 +27,9 @@ describe('settings navigation boundaries', () => { it('preserves the order of all four settings catalogs', () => { expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([ 'general', + 'desktop', + 'browser', + 'terminal', 'access-control', 'audit-logs', 'forks', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index b5073b23ab..08f12d531b 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' import { ClipboardList, Clock, + Cursor, Database, HexSimple, Key, @@ -9,6 +10,7 @@ import { Lock, LogIn, Palette, + PanelLeft, Send, Server, Settings, @@ -89,6 +91,9 @@ export interface SettingsNavigationItem
{ export type UnifiedSettingsSection = | 'general' + | 'desktop' + | 'browser' + | 'terminal' | 'secrets' | 'access-control' | 'custom-blocks' @@ -117,9 +122,17 @@ export type UnifiedNavigationSection = | 'subscription' | 'tools' | 'system' + | 'desktop' | 'enterprise' | 'superuser' +/** + * A bridge surface the desktop shell must expose for a section to be worth + * showing. Gated on the surface, never on the user's device toggle — the + * Browser and Terminal pages are where that toggle is flipped back on. + */ +export type DesktopSettingsSurface = 'settings' | 'browser' | 'terminal' + export interface UnifiedSettingsNavigationItem { id: UnifiedSettingsSection label: string @@ -134,6 +147,7 @@ export interface UnifiedSettingsNavigationItem { selfHostedOverride?: boolean requiresSuperUser?: boolean requiresAdminRole?: boolean + requiresDesktopSurface?: DesktopSettingsSurface allowNonOrgAdmin?: boolean showWhenLocked?: boolean hideForEnterprise?: boolean @@ -353,6 +367,36 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfhost: { id: 'general', group: 'account', order: 0 }, }, }, + { + label: 'Desktop', + icon: PanelLeft, + unified: { + id: 'desktop', + description: 'Manage notifications, startup, local folders, and updates.', + group: 'desktop', + requiresDesktopSurface: 'settings', + }, + }, + { + label: 'Browser', + icon: Cursor, + unified: { + id: 'browser', + description: 'Control the browser Chat drives and the data it keeps.', + group: 'desktop', + requiresDesktopSurface: 'browser', + }, + }, + { + label: 'Terminal', + icon: TerminalWindow, + unified: { + id: 'terminal', + description: 'Control the shells Chat runs commands in.', + group: 'desktop', + requiresDesktopSurface: 'terminal', + }, + }, { label: 'Access control', icon: ShieldCheck, diff --git a/apps/sim/components/ui/select.tsx b/apps/sim/components/ui/select.tsx index a0c04317b1..3c22d4a297 100644 --- a/apps/sim/components/ui/select.tsx +++ b/apps/sim/components/ui/select.tsx @@ -80,6 +80,7 @@ const SelectContent = React.forwardRef< )} position={position} {...props} + data-native-surface-overlay='' > ): boolean { return allowedExtensions.has(getExtension(key)) } -/** - * Returns true when the host is a loopback address for which plain `http://` is - * tolerated (local MinIO development on a self-hosted deployment). Any other - * host must use `https://`. This is only an early check — the SSRF boundary is - * enforced at request time by {@link secureFetchWithRetry}, which blocks - * loopback/private targets on hosted Sim regardless of what this parser accepts. - */ -function isLoopbackHost(host: string): boolean { - const bare = host.replace(/^\[|\]$/g, '') - return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1' -} - /** * Parses and validates a custom S3-compatible endpoint string. * @@ -173,7 +162,9 @@ function parseEndpoint(raw: string): S3Endpoint { const host = url.hostname if (!host) throw new Error('Endpoint is missing a host') - if (scheme === 'http' && !(isLoopbackHost(host) && !isHosted)) { + // Plain http is tolerated only for loopback on self-host (local MinIO); the + // real SSRF boundary is enforced at request time by secureFetchWithRetry. + if (scheme === 'http' && !(isLoopbackHostname(host) && !isHosted)) { throw new Error( 'Plain http:// endpoints are only allowed for localhost on self-hosted deployments — use https:// otherwise' ) diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 67146168fc..0fee6c19a0 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -92,6 +92,10 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { }, enabled: Boolean(credentialId) && enabled, staleTime: WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME, + // The credential-detail form seeds editable name/description fields from + // this data, so a background focus refetch during an edit could clobber + // an unsaved draft. Off the desktop focus-refetch default; no-op on web. + refetchOnWindowFocus: false, }) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 7c25a09ccd..29e74661ab 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -33,6 +33,10 @@ export function usePersonalEnvironment() { queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, + // Pinned off (not inheriting the desktop QueryClient default): the secrets + // manager seeds an editable form from this data, so a background focus + // refetch during a concurrent edit would drop the user's unsaved rows. + refetchOnWindowFocus: false, }) } @@ -49,6 +53,9 @@ export function useWorkspaceEnvironment( enabled: !!workspaceId, staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, placeholderData: keepPreviousData, + // See usePersonalEnvironment: seeds an editable form, so a focus refetch + // during a concurrent workspace-env edit must not clobber unsaved rows. + refetchOnWindowFocus: false, ...options, }) } diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 26faf6ebfb..d6eeb3a89f 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -37,6 +37,8 @@ export interface GeneralSettings { errorNotificationsEnabled: boolean snapToGridSize: number showActionBar: boolean + /** Copilot tool ids the user picked "always allow" for. */ + copilotAutoAllowedTools: string[] /** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */ timezone: string | null } @@ -57,6 +59,7 @@ export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettin errorNotificationsEnabled: data.errorNotificationsEnabled, snapToGridSize: data.snapToGridSize, showActionBar: data.showActionBar, + copilotAutoAllowedTools: data.copilotAutoAllowedTools ?? [], timezone: data.timezone ?? null, } } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 410bf83ff0..472e82a644 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -2,16 +2,22 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { + acceptInvitationContract, type BatchInvitationResult as BatchInvitationResultContract, batchWorkspaceInvitationsContract, cancelInvitationContract, + type InvitationDetails, + listMyInvitationsContract, listWorkspaceInvitationsContract, type PendingInvitationRow, + rejectInvitationContract, removeWorkspaceMemberContract, resendInvitationContract, } from '@/lib/api/contracts/invitations' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { organizationKeys } from '@/hooks/queries/organization' +import { refreshSessionQuery } from '@/hooks/queries/session' +import { subscriptionKeys } from '@/hooks/queries/subscription' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { workspaceKeys } from '@/hooks/queries/workspace' @@ -19,6 +25,7 @@ export const invitationKeys = { all: ['invitations'] as const, lists: () => [...invitationKeys.all, 'list'] as const, list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, + mine: () => [...invitationKeys.all, 'mine'] as const, } export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 @@ -68,6 +75,75 @@ export function usePendingInvitations(workspaceId: string | undefined) { }) } +export const MY_INVITATIONS_STALE_TIME = 30 * 1000 + +async function fetchMyPendingInvitations(signal?: AbortSignal): Promise { + const data = await requestJson(listMyInvitationsContract, { signal }) + return data.invitations +} + +/** + * Pending invitations addressed to the signed-in account, for the workspace + * switcher's Invitations section. The switcher menu-item mounts this on + * dropdown open (so it fetches then); the modal passes `enabled: open` so it + * does not fetch on every app load for the majority of users who have none. + */ +export function useMyPendingInvitations(enabled = true) { + return useQuery({ + queryKey: invitationKeys.mine(), + queryFn: ({ signal }) => fetchMyPendingInvitations(signal), + enabled, + staleTime: MY_INVITATIONS_STALE_TIME, + }) +} + +/** + * Accepts one of the session user's pending invitations in-app. No token — + * acceptance is bound to the session email, which is exactly what makes this + * path immune to the wrong-browser-account problem of the email link. + * + * Invalidations mirror the email-link accept path (`use-oauth-return` / + * `invite.tsx`): accepting an org invite can convert the plan, reconcile + * seats, sync usage, and set the active organization server-side, so the + * workspace list, org, credentials, subscription/usage, AND the session must + * all refresh — otherwise billing widgets and the create-workspace target + * (which reads the active org) stay stale until a reload. + */ +export function useAcceptMyInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ invitationId }: { invitationId: string }) => + requestJson(acceptInvitationContract, { params: { id: invitationId }, body: {} }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + queryClient.invalidateQueries({ queryKey: organizationKeys.all }) + queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + void refreshSessionQuery(queryClient) + }, + // Refresh the list on failure too, so a row that failed terminally + // (expired / already-processed since the list loaded) drops instead of + // lingering as a re-clickable dead row. + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + }, + }) +} + +/** Declines one of the session user's pending invitations in-app. */ +export function useDeclineMyInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ invitationId }: { invitationId: string }) => + requestJson(rejectInvitationContract, { params: { id: invitationId }, body: {} }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + }, + }) +} + type BatchSendInvitationsParams = ContractBodyInput & { organizationId?: string | null } diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index f7fbffe917..f0be5614ba 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/hostnames' import { getErrorMessage } from '@sim/utils/errors' import { keepPreviousData, @@ -26,7 +27,6 @@ import { testMcpServerConnectionContract, updateMcpServerContract, } from '@/lib/api/contracts/mcp' -import { isLoopbackHostname } from '@/lib/core/utils/urls' import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared' import type { McpAuthType, diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index 7ec177502f..66124cf1e4 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -10,6 +10,7 @@ import { type OAuthConnection, } from '@/lib/api/contracts/oauth-connections' import { client } from '@/lib/auth/auth-client' +import { getDesktopBridge } from '@/lib/desktop' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsQuery') @@ -155,6 +156,21 @@ export function useConnectOAuthService() { return { success: true } } + // Desktop app: OAuth cannot run in the embedded window (Google/Microsoft + // block embedded user agents, and better-auth binds the flow's state to + // the initiating browser's cookies), so the whole flow is handed to the + // system browser and returns via the app's loopback. Completion arrives + // through onOAuthConnectComplete (see useDesktopOAuthConnectListener), + // which refreshes caches and shows the connected toast. + const desktopBridge = getDesktopBridge() + if (desktopBridge?.beginOAuthConnect) { + const opened = await desktopBridge.beginOAuthConnect(providerId) + if (!opened) { + throw new Error('Could not open your browser to connect this account.') + } + return { success: true } + } + await client.oauth2.link({ providerId, callbackURL, diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 58e6fbb2c4..def9e736bd 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -91,6 +91,11 @@ export function useWorkspaceSchedules(workspaceId?: string, options?: { enabled? enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: SCHEDULE_LIST_STALE_TIME, placeholderData: keepPreviousData, + // Pinned off (not inheriting the QueryClient default, which is on in the + // desktop app): a background refetch regenerates occurrence ids, which + // would close an open scheduled-task modal and drop its draft. See the + // taskById note in scheduled-tasks/hooks/use-scheduled-tasks.ts. + refetchOnWindowFocus: false, }) } diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index 686a2447c7..5cb0e57c23 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -53,15 +53,20 @@ export const IMPERSONATION_REFETCH_INTERVAL = 60 * 1000 * token, startup network partition) surfaces immediately rather than retrying a * request that won't succeed. * - * While the session is an impersonation session, the query polls and refetches - * on focus (overriding the global `refetchOnWindowFocus: false`) so an expiry — - * including one slept through with the laptop closed — settles the query to - * `null` and surfaces the impersonation-expired recovery screen. Those - * refetches also bypass Better Auth's cookie cache: it can otherwise keep - * vouching for a session that was expired or revoked server-side, and the - * expiry detection shouldn't depend on the cache's own TTL details. - * Impersonation sessions are short-lived and admin-only, so none of these - * overrides affect normal sessions. + * Every session refetches on focus (overriding the global + * `refetchOnWindowFocus: false`) so a session that expired or was revoked while + * the app sat mounted — the long-lived desktop window, a browser tab left open + * for weeks, a laptop slept through the session's 30-day lifetime — settles the + * query to `null` and surfaces {@link SessionExpired} instead of leaving an SPA + * that silently 401s every request. Returning to the window is exactly the + * moment that needs re-checking, and `SESSION_STALE_TIME` throttles it to at + * most one read per 5 minutes of focus changes. + * + * Impersonation sessions additionally poll, and bypass Better Auth's cookie + * cache: it can otherwise keep vouching for a session that was expired or + * revoked server-side, and these sessions are short-lived enough that the + * cache's own TTL would outlive them. Normal sessions accept that lag — a + * revocation surfaces once the cache expires. */ export function useSessionQuery() { const queryClient = useQueryClient() @@ -75,6 +80,6 @@ export function useSessionQuery() { retry: false, refetchInterval: (query) => query.state.data?.session?.impersonatedBy ? IMPERSONATION_REFETCH_INTERVAL : false, - refetchOnWindowFocus: (query) => Boolean(query.state.data?.session?.impersonatedBy), + refetchOnWindowFocus: true, }) } diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index db46900feb..3bf98b2dce 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -102,6 +102,11 @@ export function useWorkflowStates( queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal), select: mapWorkflowState, staleTime: WORKFLOW_STATE_STALE_TIME, + // Read-only preview consumer that fans out one full workflow envelope + // per id. Left off the desktop focus-refetch default so returning to a + // table with many workflow columns doesn't fire N heavy envelope + // fetches at once. No-op on the web (default is already false). + refetchOnWindowFocus: false as const, })), }) const map = new Map() diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 08680c83ec..ee0b1f6ff3 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { toast } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract } from '@/lib/api/contracts' @@ -11,6 +12,9 @@ import { type OAuthReturnContext, readOAuthReturnContext, } from '@/lib/credentials/client-state' +import { getDesktopBridge } from '@/lib/desktop' +import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' @@ -145,3 +149,49 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { })() }, [knowledgeBaseId]) } + +/** + * Desktop-app counterpart of the post-OAuth routers above. In the desktop + * app the whole OAuth flow runs in the system browser (see + * useConnectOAuthService), so the app never navigates: completion arrives as + * a bridge push when the browser bounces the desktop's loopback. The app is + * already refocused by then — this refreshes the credential caches and shows + * the same connected toast the web flow gets. Mounted once per workspace; a + * no-op outside the desktop app. + */ +export function useDesktopOAuthConnectListener() { + const queryClient = useQueryClient() + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.onOAuthConnectComplete) return + + return bridge.onOAuthConnectComplete((result) => { + void queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) + void queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + + // The app stays open across interleaved connect flows, so an abandoned + // modal-connect can leave a stale context that would attach to a later + // (e.g. chip) completion and show the wrong provider's message. Discard + // anything older than the same window the web routers use, mirroring + // their freshness check. + const rawCtx = readOAuthReturnContext() + if (rawCtx) consumeOAuthReturnContext() + const ctx = rawCtx && Date.now() - rawCtx.requestedAt <= CONTEXT_MAX_AGE_MS ? rawCtx : null + + if (!result.ok) { + toast.error('The account connection didn’t finish. Try connecting again.') + return + } + if (ctx) { + void (async () => { + const message = await resolveOAuthMessage(ctx) + toast.success(message) + dispatchCredentialUpdate(ctx) + })() + return + } + toast.success('Credential connected successfully.') + }) + }, [queryClient]) +} diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index a8a675daff..e7136c78dc 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -12,6 +12,7 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, COPILOT_BILLING_PROTOCOL_VALUES, } from '@/lib/copilot/generated/billing-protocol-v1' +import { PERSISTED_RESOURCE_TYPES } from '@/lib/copilot/resources/types' export const copilotApiKeySchema = z.object({ id: z.string(), @@ -58,6 +59,30 @@ export const copilotConfirmBodySchema = z.object({ }) export type CopilotConfirmBody = z.input +export const copilotToolPermissionDecisionSchema = z.enum([ + 'allow', + 'allow_chat', + 'always_allow', + 'skip', +]) + +/** + * Decisions arrive as a batch so "Allow all" on a turn that gated several + * tools at once is a single round trip rather than one request per card. + */ +export const copilotToolPermissionBodySchema = z.object({ + decisions: z + .array( + z.object({ + toolCallId: z.string().min(1, 'Tool call ID is required'), + decision: copilotToolPermissionDecisionSchema, + }) + ) + .min(1, 'At least one decision is required') + .max(50, 'Too many decisions in one request'), +}) +export type CopilotToolPermissionBody = z.input + export const createWorkflowCopilotChatBodySchema = z.object({ workspaceId: z.string().min(1), workflowId: z.string().min(1), @@ -93,15 +118,7 @@ export const renameCopilotChatBodySchema = z.object({ }) export type RenameCopilotChatBody = z.input -const copilotResourceTypeSchema = z.enum([ - 'table', - 'file', - 'workflow', - 'knowledgebase', - 'folder', - 'scheduledtask', - 'log', -]) +const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES) export const addCopilotChatResourceBodySchema = z.object({ chatId: z.string(), @@ -258,7 +275,6 @@ const copilotPersistedMessageSchema = z export const updateCopilotMessagesBodySchema = z.object({ chatId: z.string(), messages: z.array(copilotPersistedMessageSchema), - planArtifact: z.string().nullable().optional(), config: z .object({ mode: z.string().optional(), @@ -411,7 +427,6 @@ const copilotChatGetChatSchema = z model: z.string().nullable(), messages: z.array(z.unknown()), messageCount: z.number(), - planArtifact: z.unknown().nullable(), config: z.unknown().nullable(), activeStreamId: z.string().nullable().optional(), resources: z.array(z.unknown()).optional(), @@ -622,6 +637,27 @@ export const copilotConfirmContract = defineRouteContract({ }, }) +export const copilotToolPermissionContract = defineRouteContract({ + method: 'POST', + path: '/api/copilot/tool-permission', + body: copilotToolPermissionBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + // Echoes the decision that actually stuck per tool call, which can differ + // from what was sent when another tab answered the same prompt first. + results: z.array( + z.object({ + toolCallId: z.string(), + decision: copilotToolPermissionDecisionSchema, + applied: z.boolean(), + }) + ), + }), + }, +}) + export const copilotModelsContract = defineRouteContract({ method: 'GET', path: '/api/copilot/models', diff --git a/apps/sim/lib/api/contracts/desktop-auth.ts b/apps/sim/lib/api/contracts/desktop-auth.ts new file mode 100644 index 0000000000..13c0e61520 --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-auth.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const desktopHandoffTokenResponseSchema = z.object({ + token: z.string().min(1, 'token cannot be empty'), +}) + +/** + * Mints the one-time token the desktop app redeems to sign in. Takes no input: + * the caller is identified by its session cookie, and the handoff state/port + * are validated by the `/desktop/auth` page that renders the confirm gesture. + */ +export const createDesktopHandoffTokenContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/auth/handoff', + response: { + mode: 'json', + schema: desktopHandoffTokenResponseSchema, + }, +}) + +export type DesktopHandoffTokenResponse = ContractJsonResponse< + typeof createDesktopHandoffTokenContract +> diff --git a/apps/sim/lib/api/contracts/desktop-tool-authorization.ts b/apps/sim/lib/api/contracts/desktop-tool-authorization.ts new file mode 100644 index 0000000000..1995db242c --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-tool-authorization.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const authorizeDesktopToolBodySchema = z.object({ + toolCallId: z + .string() + .min(1, 'Tool call ID is required') + .max(256, 'Tool call ID is too long') + .regex(/^[^\x00-\x1f\x7f]+$/, 'Tool call ID contains invalid control characters'), +}) + +export type AuthorizeDesktopToolBody = z.input + +export const authorizeDesktopToolResponseSchema = z.object({ + toolName: z.string().min(1), + args: z.record(z.string(), z.unknown()), +}) + +export type AuthorizeDesktopToolResponse = z.output + +export const authorizeDesktopToolContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/tool/authorize', + body: authorizeDesktopToolBodySchema, + response: { + mode: 'json', + schema: authorizeDesktopToolResponseSchema, + }, + error: z.object({ error: z.string() }), +}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 5bdd2fb6fc..958a4ed21f 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -8,6 +8,8 @@ export * from './common' export * from './copilot' export * from './credentials' export * from './demo-requests' +export * from './desktop-auth' +export * from './desktop-tool-authorization' export * from './environment' export * from './execution-payloads' export * from './file-uploads' diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index b1fa887152..d280fb0b5e 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -155,6 +155,17 @@ export const getInvitationContract = defineRouteContract({ }, }) +export const listMyInvitationsContract = defineRouteContract({ + method: 'GET', + path: '/api/invitations', + response: { + mode: 'json', + schema: z.object({ + invitations: z.array(invitationDetailsSchema), + }), + }, +}) + export const acceptInvitationContract = defineRouteContract({ method: 'POST', path: '/api/invitations/[id]/accept', diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index bb50923b56..70a297a486 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -248,6 +248,25 @@ export const removeMothershipChatResourceContract = defineRouteContract({ }, }) +export const stageLocalFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/mothership/local-files/stage', + body: z.object({ + workspaceId: z.string().min(1), + chatId: z.string().min(1), + key: z.string().min(1).max(2048), + }), + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + displayName: z.string(), + fileName: z.string(), + uploadPath: z.string(), + }), + }, +}) + export const mothershipChatSchema = z.object({ id: z.string(), title: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/tools/sap.ts b/apps/sim/lib/api/contracts/tools/sap.ts index 38b10cbbec..dcfa27fcaa 100644 --- a/apps/sim/lib/api/contracts/tools/sap.ts +++ b/apps/sim/lib/api/contracts/tools/sap.ts @@ -1,3 +1,4 @@ +import { isPrivateIpHost } from '@sim/security/ssrf' import { z } from 'zod' import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -49,54 +50,6 @@ const FORBIDDEN_SAP_HOSTS = new Set([ '[fd00:ec2::254]', ]) -function isPrivateIPv4(host: string): boolean { - const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!match) return false - const octets = match.slice(1, 5).map(Number) as [number, number, number, number] - if (octets.some((octet) => octet < 0 || octet > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - -function extractIPv4MappedHost(host: string): string | null { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - for (const prefix of ['::ffff:', '::']) { - if (lower.startsWith(prefix)) { - const candidate = lower.slice(prefix.length) - if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate - } - } - const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) - if (hexMatch) { - const high = Number.parseInt(hexMatch[1] as string, 16) - const low = Number.parseInt(hexMatch[2] as string, 16) - if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) { - const a = (high >> 8) & 0xff - const b = high & 0xff - const c = (low >> 8) & 0xff - const d = low & 0xff - return `${a}.${b}.${c}.${d}` - } - } - return null -} - -function isPrivateOrLoopbackIPv6(host: string): boolean { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - if (lower === '::' || lower === '::1') return true - if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true - if (lower.startsWith('fe80:')) return true - return false -} - export function checkSapExternalUrlSafety( rawUrl: string, label: string @@ -114,16 +67,9 @@ export function checkSapExternalUrlSafety( if (FORBIDDEN_SAP_HOSTS.has(host) || FORBIDDEN_SAP_HOSTS.has(`[${host}]`)) { return { ok: false, message: `${label} host is not allowed` } } - if (isPrivateIPv4(host)) { + if (isPrivateIpHost(host)) { return { ok: false, message: `${label} host is not allowed (private/loopback range)` } } - const mapped = extractIPv4MappedHost(host) - if (mapped && isPrivateIPv4(mapped)) { - return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } - } - if (isPrivateOrLoopbackIPv6(host)) { - return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } - } return { ok: true, url: parsed } } diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 7da4c3597d..c799745bf1 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -90,6 +90,8 @@ export const userSettingsSchema = z.object({ errorNotificationsEnabled: z.boolean().default(true), snapToGridSize: z.number().min(0).max(50).default(0), showActionBar: z.boolean().default(true), + /** Copilot tool ids the user chose "always allow" for, so they are never prompted for them again. */ + copilotAutoAllowedTools: z.array(z.string()).default([]), /** IANA timezone for scheduling; `null` means the client falls back to the browser-detected zone. */ timezone: z.string().nullable().default(null), lastActiveWorkspaceId: z.string().nullable().optional(), @@ -109,6 +111,7 @@ export const updateUserSettingsBodySchema = z.object({ errorNotificationsEnabled: z.boolean().optional(), snapToGridSize: z.number().min(0).max(50).optional(), showActionBar: z.boolean().optional(), + copilotAutoAllowedTools: z.array(z.string()).optional(), /** IANA timezone; explicit `null` resets to the browser-detected zone. */ timezone: ianaTimezoneSchema.nullable().optional(), /** Mirrors `userSettingsSchema.lastActiveWorkspaceId` so explicit `null` is accepted to clear the active workspace. */ diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 39a0cf7da5..48d9097e10 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -209,6 +209,11 @@ const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '') export const auth = betterAuth({ baseURL: getBaseUrl(), + // Where Better Auth sends OAuth callbacks that fail before the flow state is + // parsed — most commonly a provider-side Cancel/Deny. Without this it + // defaults to a nonexistent `/error` (a 404 dead-end), which strands the + // desktop sign-in/connect handoffs since their loopback is never pinged. + onAPIError: { errorURL: `${getBaseUrl()}/oauth-error` }, trustedOrigins: [ getBaseUrl(), ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), @@ -224,12 +229,21 @@ export const auth = betterAuth({ session: { cookieCache: { enabled: true, - maxAge: 24 * 60 * 60, // 24 hours in seconds + // Better Auth's default, and deliberately short: the cached session is a + // signed cookie that `getSession` returns WITHOUT re-reading the database, + // so this is the window in which a revoked, expired, or signed-out session + // still authenticates. Anything longer is an un-revocable credential — at + // 24h a sign-out on one device left every other surface looking signed in + // for a day while every database-backed check (socket handshakes, the + // desktop handoff) failed against a row that no longer existed. The + // `version` below only covers org-wide invalidation, so this TTL remains + // the only bound on per-device sign-out latency. + maxAge: 5 * 60, // 5 minutes in seconds /** * Embeds the member org's security-policy version. Bumping the version * (policy change, org-wide revocation) invalidates every cached session * cookie in the org on its next request, forcing a DB session read — - * revocation latency becomes the policy cache TTL, not 24h. + * revocation latency becomes the policy cache TTL, not the full `maxAge`. */ version: async (session) => getSessionCookieCacheVersion(session as { userId?: string | null }), diff --git a/apps/sim/lib/auth/desktop-handoff.ts b/apps/sim/lib/auth/desktop-handoff.ts new file mode 100644 index 0000000000..059013bf0c --- /dev/null +++ b/apps/sim/lib/auth/desktop-handoff.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { auth } from '@/lib/auth' + +const logger = createLogger('DesktopHandoff') + +/** + * Identifier namespace Better Auth's one-time-token plugin reads in + * `/one-time-token/verify`. The row is written here rather than through + * `generateOneTimeToken` so the token resolves to a session minted for the + * desktop app — see {@link createDesktopHandoffToken}. This constant is the + * only coupling to the plugin's storage layout, and it holds because the + * plugin's `storeToken` option is left at its `'plain'` default, so the token + * is stored verbatim rather than hashed. + */ +const ONE_TIME_TOKEN_IDENTIFIER_PREFIX = 'one-time-token:' + +/** Matches the token length Better Auth generates for its own one-time tokens. */ +const HANDOFF_TOKEN_LENGTH = 32 + +/** + * The browser navigates straight to the desktop app's loopback listener once + * the token is minted, so a redeem lands within seconds. Deliberately far + * shorter than the plugin-wide 24h `expiresIn`: this token is a bearer + * credential that grants a session, and `/one-time-token/verify` enforces the + * expiry stored on the row, not the plugin option. + */ +const HANDOFF_TOKEN_TTL_MS = 3 * 60 * 1000 + +/** + * Recorded as the session's user agent. The row is created while handling the + * *browser's* request, so the real user agent would misattribute the desktop's + * session to the browser in session listings and audit trails. + */ +const DESKTOP_SESSION_USER_AGENT = 'Sim Desktop' + +/** + * Mints a one-time token that signs the desktop app in as `userId` on a session + * of its own. + * + * Better Auth's `generateOneTimeToken` binds the token to the *calling* session + * row, and `/one-time-token/verify` then hands that same row's cookie to + * whoever redeems it. Used as-is, the desktop app and the browser that + * authorized it share a single session: signing out of either deletes the row + * the other is still presenting, leaving that client holding a cookie for a + * session that no longer exists — and, because the session cookie cache is not + * revalidated against the database, still looking signed in until the cache + * expires. Every request it authorizes off that cache then fails anywhere the + * session is resolved from the database, notably socket handshakes. + * + * A session per device is what Better Auth's own device authorization grant + * does, and what RFC 8252 assumes of a native app: sign-out, revocation, and + * expiry apply to one surface at a time. + */ +export async function createDesktopHandoffToken(userId: string): Promise { + const ctx = await auth.$context + const desktopSession = await ctx.internalAdapter.createSession(userId, false, { + userAgent: DESKTOP_SESSION_USER_AGENT, + }) + + const token = generateShortId(HANDOFF_TOKEN_LENGTH) + await ctx.internalAdapter.createVerificationValue({ + value: desktopSession.token, + identifier: `${ONE_TIME_TOKEN_IDENTIFIER_PREFIX}${token}`, + expiresAt: new Date(Date.now() + HANDOFF_TOKEN_TTL_MS), + }) + + logger.info('Minted desktop handoff token', { userId, sessionId: desktopSession.id }) + return token +} diff --git a/apps/sim/lib/auth/stale-session-recovery.ts b/apps/sim/lib/auth/stale-session-recovery.ts index a3b9b38f6a..75f6d47f9f 100644 --- a/apps/sim/lib/auth/stale-session-recovery.ts +++ b/apps/sim/lib/auth/stale-session-recovery.ts @@ -13,7 +13,7 @@ const logger = createLogger('StaleSessionRecovery') * would only get bounced back to /workspace by the middleware. * * Shared by the /workspace loader (stale-cookie 401s and clean-null sessions) - * and the impersonation-expired screen, so every identity-recovery path clears + * and the session-expired screen, so every identity-recovery path clears * cookies and persisted client state the same way. */ export async function recoverFromStaleSession(): Promise { diff --git a/apps/sim/lib/browser-agent/attachments.test.ts b/apps/sim/lib/browser-agent/attachments.test.ts new file mode 100644 index 0000000000..a115bb7261 --- /dev/null +++ b/apps/sim/lib/browser-agent/attachments.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { buildResourceAttachments } from '@/lib/browser-agent/attachments' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const BROWSER_RESOURCE: MothershipResource = { + type: 'browser', + id: 'browser-session', + title: 'Browser', +} + +describe('buildResourceAttachments', () => { + beforeEach(() => { + useBrowserSessionStore.setState({ + pageState: null, + tabs: [], + activeTabId: null, + tabsSupported: false, + panelSnapshot: null, + sessionAlive: true, + }) + }) + + it('adds every live browser tab and marks only the selected tab active', () => { + useBrowserSessionStore.setState({ + tabsSupported: true, + activeTabId: '2', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: false, + }, + { + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: false, + active: true, + }, + ], + }) + + expect(buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id)).toEqual([ + { + type: 'browser', + id: 'browser-session:1', + title: 'Docs', + active: false, + url: 'https://docs.sim.ai', + }, + { + type: 'browser', + id: 'browser-session:2', + title: 'Dashboard', + active: true, + url: 'https://sim.ai/workspace', + }, + ]) + }) + + it('keeps all browser tabs open rather than active when another resource is selected', () => { + useBrowserSessionStore.setState({ + tabsSupported: true, + activeTabId: '1', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }, + ], + }) + + const attachments = buildResourceAttachments([BROWSER_RESOURCE], 'workflow-1') + + expect(attachments?.[0]).toMatchObject({ id: 'browser-session:1', active: false }) + }) + + it('falls back to the active page for older single-tab desktop versions', () => { + useBrowserSessionStore.setState({ + pageState: { + url: 'https://sim.ai', + title: 'Sim', + loading: false, + canGoBack: false, + canGoForward: false, + }, + }) + + expect(buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id)).toEqual([ + { + type: 'browser', + id: 'browser-session', + title: 'Sim', + active: true, + url: 'https://sim.ai', + }, + ]) + }) +}) diff --git a/apps/sim/lib/browser-agent/attachments.ts b/apps/sim/lib/browser-agent/attachments.ts new file mode 100644 index 0000000000..37850b9412 --- /dev/null +++ b/apps/sim/lib/browser-agent/attachments.ts @@ -0,0 +1,73 @@ +/** + * Maps the chat's open resources to request attachments. + * + * This is deliberately the ONLY place shared chat code reads the + * browser-session store: the live browser panel's page state is client-held + * (the desktop app's embedded browser), so its attachment is enriched here + * with the current URL and title for the server to inject as + * `@active_tab`/`@open_tab` context. A browser panel with no page loaded has + * nothing to say and is dropped. + */ +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +export interface ResourceAttachment { + type: MothershipResource['type'] + id: string + title: string + active: boolean + /** Live page URL, only on `browser` attachments. */ + url?: string +} + +export function buildResourceAttachments( + resources: readonly MothershipResource[], + activeResourceId: string | null +): ResourceAttachment[] | undefined { + const { pageState, tabs, tabsSupported } = useBrowserSessionStore.getState() + const attachments = resources.flatMap((resource) => { + // The terminal panel is not addressable context: unlike a browser tab it + // carries no URL to reference, and the shell's state reaches the model + // through the terminal tools instead. + if (resource.type === 'terminal') return [] + + if (resource.type !== 'browser') { + return [ + { + type: resource.type, + id: resource.id, + title: resource.title, + active: resource.id === activeResourceId, + }, + ] + } + + if (tabsSupported) { + return tabs + .filter((tab) => Boolean(tab.url)) + .map((tab) => ({ + type: resource.type, + id: `${resource.id}:${tab.tabId}`, + title: tab.title.trim() || resource.title, + active: resource.id === activeResourceId && tab.active, + url: tab.url, + })) + } + + if (!pageState?.url) return [] + return [ + { + type: resource.type, + id: resource.id, + title: pageState.title.trim() || resource.title, + active: resource.id === activeResourceId, + url: pageState.url, + }, + ] + }) + + if (attachments.length === 0) { + return undefined + } + return attachments +} diff --git a/apps/sim/lib/browser-agent/open-in-panel.ts b/apps/sim/lib/browser-agent/open-in-panel.ts new file mode 100644 index 0000000000..651cfcb82d --- /dev/null +++ b/apps/sim/lib/browser-agent/open-in-panel.ts @@ -0,0 +1,46 @@ +/** + * "Open this URL in the Sim browser panel" — the desktop-only affordance that + * routes chat links into the embedded agent browser instead of a new browser + * tab. + * + * Rendering components (markdown links, chips) can't reach the chat's + * resource state directly, so the request travels as a window CustomEvent; + * the chat hook owns the panel resource and subscribes via + * {@link onOpenInBrowserPanel}. + * + * OAuth/credential links must NOT go through here: the panel's browser runs + * on its own partition (not signed in to Sim) and is an embedded user agent + * that Google/Microsoft refuse — connect chips use the system-browser + * handoff (`beginOAuthConnect`) instead. + */ +import { isBrowserAgentEnabled } from '@/lib/desktop' + +const OPEN_IN_BROWSER_PANEL_EVENT = 'sim:open-in-browser-panel' + +interface OpenInBrowserPanelDetail { + url: string +} + +/** True when a click on this href should divert into the embedded panel. */ +export function shouldOpenInBrowserPanel(href: string | undefined): href is string { + return Boolean(href) && /^https?:\/\//i.test(href as string) && isBrowserAgentEnabled() +} + +/** Requests the chat surface to open the panel on this URL (fire-and-forget). */ +export function openInBrowserPanel(url: string): void { + window.dispatchEvent( + new CustomEvent(OPEN_IN_BROWSER_PANEL_EVENT, { detail: { url } }) + ) +} + +/** Subscribes the chat surface to panel-open requests; returns an unsubscribe. */ +export function onOpenInBrowserPanel(callback: (url: string) => void): () => void { + const listener = (event: Event) => { + const url = (event as CustomEvent).detail?.url + if (typeof url === 'string' && /^https?:\/\//i.test(url)) { + callback(url) + } + } + window.addEventListener(OPEN_IN_BROWSER_PANEL_EVENT, listener) + return () => window.removeEventListener(OPEN_IN_BROWSER_PANEL_EVENT, listener) +} diff --git a/apps/sim/lib/browser-agent/transport.test.ts b/apps/sim/lib/browser-agent/transport.test.ts new file mode 100644 index 0000000000..94ac637bfa --- /dev/null +++ b/apps/sim/lib/browser-agent/transport.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + onFocusOmnibox, + onPanelSnapshot, + reorderTab, + setPageState, + setPanelBounds, + setPanelFocused, + setPanelOccluded, + setPanelSnapshot, + setSessionAlive, + setTabPinned, + setTheme, + setTabsState, + setTabsSupported, +} = vi.hoisted(() => ({ + onFocusOmnibox: vi.fn(), + onPanelSnapshot: vi.fn(), + reorderTab: vi.fn(), + setPageState: vi.fn(), + setPanelBounds: vi.fn(), + setPanelFocused: vi.fn(), + setPanelOccluded: vi.fn(), + setPanelSnapshot: vi.fn(), + setSessionAlive: vi.fn(), + setTabPinned: vi.fn(), + setTheme: vi.fn(), + setTabsState: vi.fn(), + setTabsSupported: vi.fn(), +})) + +vi.mock('@/lib/desktop', () => ({ + getDesktopBridge: () => ({ + browserAgent: { + executeTool: vi.fn(), + getTabsState: vi.fn(async () => ({ tabs: [], activeTabId: null })), + onFocusOmnibox, + onPageState: vi.fn(), + onPanelSnapshot, + onSessionStatus: vi.fn(), + onTabsState: vi.fn(), + panelAction: vi.fn(), + reorderTab, + setPanelBounds, + setPanelFocused, + setPanelOccluded, + setTabPinned, + setTheme, + }, + }), +})) + +vi.mock('@/stores/browser-session/store', () => ({ + useBrowserSessionStore: { + getState: () => ({ + setPageState, + setPanelSnapshot, + setSessionAlive, + setTabsState, + setTabsSupported, + }), + }, +})) + +import { + initBrowserAgentTransport, + isBrowserTabPinningAvailable, + isBrowserTabReorderingAvailable, + onBrowserOmniboxFocus, + reorderBrowserTab, + reportBrowserPanelBounds, + reportBrowserPanelFocused, + reportBrowserPanelOcclusion, + reportBrowserTheme, + resetBrowserPanelOcclusion, + setBrowserTabPinned, +} from '@/lib/browser-agent/transport' + +describe('browser panel transport', () => { + beforeEach(() => { + resetBrowserPanelOcclusion() + setPanelBounds.mockClear() + setPanelFocused.mockClear() + setPanelOccluded.mockClear() + reorderTab.mockClear() + setTabPinned.mockClear() + setTheme.mockClear() + }) + + it('forwards panel bounds independently from native-view occlusion', () => { + const initialBounds = { x: 10, y: 20, width: 300, height: 200 } + const updatedBounds = { x: 20, y: 30, width: 320, height: 220 } + + reportBrowserPanelBounds(initialBounds) + reportBrowserPanelOcclusion(true) + reportBrowserPanelBounds(updatedBounds) + reportBrowserPanelOcclusion(false) + + // A caller with no anchor to declare keeps whatever was last retained — + // here there was never one, so the shell is told null both times. + expect(setPanelBounds.mock.calls).toEqual([ + [initialBounds, null], + [updatedBounds, null], + ]) + expect(setPanelOccluded.mock.calls).toEqual([[true], [false]]) + }) + + it('forwards renderer-owned browser chrome focus', () => { + reportBrowserPanelFocused(true) + reportBrowserPanelFocused(false) + + expect(setPanelFocused.mock.calls).toEqual([[true], [false]]) + }) + + it('forwards tab pinning only through shells that advertise support', () => { + expect(isBrowserTabPinningAvailable()).toBe(true) + + setBrowserTabPinned('tab-2', true) + setBrowserTabPinned('tab-2', false) + + expect(setTabPinned.mock.calls).toEqual([ + ['tab-2', true], + ['tab-2', false], + ]) + }) + + it('forwards tab reordering only through shells that advertise support', () => { + expect(isBrowserTabReorderingAvailable()).toBe(true) + + reorderBrowserTab('tab-3', 1) + + expect(reorderTab).toHaveBeenCalledWith('tab-3', 1) + }) + + it('wires captured browser frames into the browser-session store', () => { + initBrowserAgentTransport() + const listener = onPanelSnapshot.mock.calls[0][0] as (snapshot: { + dataUrl: string + tabId: string + }) => void + const snapshot = { dataUrl: 'data:image/png;base64,c2lt', tabId: 'tab-1' } + + listener(snapshot) + + expect(setPanelSnapshot).toHaveBeenCalledWith(snapshot) + }) + + it('forwards Sim theme preferences to the desktop browser', () => { + reportBrowserTheme('dark') + reportBrowserTheme('light') + reportBrowserTheme('system') + + expect(setTheme.mock.calls).toEqual([['dark'], ['light'], ['system']]) + }) + + it('subscribes to native omnibox focus requests', () => { + const unsubscribe = vi.fn() + const callback = vi.fn() + onFocusOmnibox.mockReturnValue(unsubscribe) + + expect(onBrowserOmniboxFocus(callback)).toBe(unsubscribe) + expect(onFocusOmnibox).toHaveBeenCalledWith(callback) + }) +}) diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts new file mode 100644 index 0000000000..3eab6799b8 --- /dev/null +++ b/apps/sim/lib/browser-agent/transport.ts @@ -0,0 +1,319 @@ +/** + * Transport for the browser agent: the agent browser built into the Sim + * desktop app, reached through the preload bridge + * (`window.simDesktop.browserAgent`). + * + * Tools execute in the Electron main process against a persistent-profile + * browser view embedded in the main Sim window. The renderer's job is + * geometry and chrome: it reports where the browser panel sits (the main + * process glues the real page over that rect, so the panel is natively + * interactive) and receives page-state pushes for the panel header. + * Availability of this bridge, plus the device switch on the Browser settings + * page, is what gates advertising `browserCapable` to the copilot — in a + * regular web browser there is no bridge and the browser subagent is never + * offered. + */ +import type { + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSession, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserTabsState, + BrowserTheme, + BrowserToolName, +} from '@sim/browser-protocol' +import type { + BrowserCredentialMetadata, + BrowserSiteInfo, + SimDesktopBrowserAgentApi, +} from '@sim/desktop-bridge' +import { getDesktopBridge, isBrowserAgentEnabled } from '@/lib/desktop' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +let initialized = false +let latestPanelBounds: BrowserPanelBounds | null = null +let panelOccluded = false + +function bridge(): SimDesktopBrowserAgentApi | null { + return getDesktopBridge()?.browserAgent ?? null +} + +/** + * Idempotently wires page-state and session-status pushes into the + * browser-session store. Safe to call repeatedly (e.g. per chat mount). + */ +export function initBrowserAgentTransport(): void { + if (initialized) return + const agent = bridge() + if (!agent) return + initialized = true + agent.onPageState((state: BrowserPageState) => { + useBrowserSessionStore.getState().setPageState(state) + }) + agent.onPanelSnapshot?.((snapshot) => { + useBrowserSessionStore.getState().setPanelSnapshot(snapshot) + }) + if (agent.onTabsState) { + useBrowserSessionStore.getState().setTabsSupported(true) + agent.onTabsState((state: BrowserTabsState) => { + useBrowserSessionStore.getState().setTabsState(state) + }) + if (agent.getTabsState) { + void agent + .getTabsState() + .then((state) => useBrowserSessionStore.getState().setTabsState(state)) + .catch(() => {}) + } + } + agent.onSessionStatus((alive) => { + useBrowserSessionStore.getState().setSessionAlive(alive) + }) +} + +/** True when browser tools can run (gates the copilot's browserCapable flag). */ +export function isBrowserAgentAvailable(): boolean { + return isBrowserAgentEnabled() +} + +/** + * Executes one browser tool in the desktop main process. Rejects on transport + * failure, tool failure, or when `timeoutMs` elapses first (null = no + * timeout, e.g. takeovers). + */ +export async function executeBrowserTool( + toolCallId: string, + tool: BrowserToolName, + params: Record, + timeoutMs: number | null +): Promise { + const agent = bridge() + if (!agent) { + throw new Error('The Sim desktop browser agent is unavailable.') + } + const invocation = agent.executeTool(toolCallId, tool, params) + const response = + timeoutMs === null + ? await invocation + : await Promise.race([ + invocation, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`The browser did not respond within ${timeoutMs}ms`)), + timeoutMs + ) + }), + ]) + if (!response.ok) { + throw new Error(response.error || 'The browser agent reported an error') + } + return response.result +} + +/** Browser-chrome commands from the panel header; fire-and-forget. */ +export function sendBrowserPanelAction( + action: BrowserPanelAction['action'], + payload: Omit = {} +): void { + bridge()?.panelAction({ action, ...payload }) +} + +/** Whether the installed desktop shell supports durable browser-tab pinning. */ +export function isBrowserTabPinningAvailable(): boolean { + return typeof bridge()?.setTabPinned === 'function' +} + +/** Pins or unpins a live browser tab when supported by the desktop shell. */ +export function setBrowserTabPinned(tabId: string, pinned: boolean): void { + bridge()?.setTabPinned?.(tabId, pinned) +} + +/** Whether the installed desktop shell supports user-driven browser-tab ordering. */ +export function isBrowserTabReorderingAvailable(): boolean { + return typeof bridge()?.reorderTab === 'function' +} + +/** Moves a live browser tab to its final list index when supported by the shell. */ +export function reorderBrowserTab(tabId: string, targetIndex: number): void { + bridge()?.reorderTab?.(tabId, targetIndex) +} + +/** Mirrors Sim's raw light/dark/system preference into embedded pages. */ +export function reportBrowserTheme(theme: BrowserTheme): void { + bridge()?.setTheme?.(theme) +} + +/** + * Subscribes to whether the active page can be filled with a saved password. + * A bare boolean by design — the page learns nothing about which accounts + * exist, and the chooser itself is a native shell surface. + */ +export function onBrowserFillAvailability(callback: (available: boolean) => void): () => void { + return ( + getDesktopBridge()?.browserCredentials?.onFillAvailability?.(({ available }) => + callback(available) + ) ?? (() => {}) + ) +} + +/** + * Asks the shell to open its native account chooser at a point in the window. + * Must be called straight from a click: the shell requires a live user gesture, + * and it performs the fill itself rather than handing anything back here. + */ +export function showBrowserCredentialChooser(anchor: { x: number; y: number }): void { + void getDesktopBridge()?.browserCredentials?.showChooser?.(anchor) +} + +/** + * Reads what the omnibox suggests from: hosts the browser holds cookies for, + * and hosts with a saved password. + * + * Both already exist for other reasons, and neither is a record of where the + * user has been — this browser keeps no history. Password metadata never + * includes the password itself. Either source failing yields an empty list, so + * a broken lookup costs suggestions rather than the omnibox. + */ +export async function loadBrowserSuggestionSources(): Promise<{ + sessions: BrowserKnownSession[] + credentials: BrowserCredentialMetadata[] + sites: BrowserSiteInfo[] +}> { + const desktop = getDesktopBridge() + const [known, credentials, sites] = await Promise.all([ + desktop?.browserAgent?.getKnownSessions?.().catch(() => null) ?? null, + desktop?.browserCredentials?.list().catch(() => []) ?? [], + desktop?.browserImport?.listSites?.().catch(() => []) ?? [], + ]) + return { sessions: known?.sessions ?? [], credentials, sites } +} + +/** Subscribes to native browser shortcuts that target the renderer omnibox. */ +export function onBrowserOmniboxFocus( + callback: (mode: BrowserOmniboxFocusMode) => void +): () => void { + return bridge()?.onFocusOmnibox?.(callback) ?? (() => {}) +} + +/** Whether the installed desktop shell supports find-in-page. */ +export function isBrowserFindAvailable(): boolean { + return typeof bridge()?.find === 'function' +} + +/** + * Runs Chromium's find against the active page. Counts arrive separately via + * {@link onBrowserFindResult} — Chromium resolves them asynchronously and + * streams several updates per request. + */ +export function findInBrowserPage(request: BrowserFindRequest): void { + bridge()?.find?.(request) +} + +/** + * Stops the running find and clears its highlights. Pass `focusPage` when the + * user dismissed the bar, so focus lands back on the page instead of being + * stranded on the removed input; leave it off when the bar is unmounting + * because the panel is going away. + */ +export function stopBrowserFind(focusPage = false): void { + bridge()?.stopFind?.(focusPage) +} + +/** Subscribes to Mod+F pressed while the embedded page held focus. */ +export function onBrowserFindOpen(callback: () => void): () => void { + return bridge()?.onOpenFind?.(callback) ?? (() => {}) +} + +/** Subscribes to the shell dismissing find (navigation, tab switch). */ +export function onBrowserFindClose(callback: () => void): () => void { + return bridge()?.onCloseFind?.(callback) ?? (() => {}) +} + +/** Subscribes to match counts for the running find. */ +export function onBrowserFindResult(callback: (result: BrowserFindResult) => void): () => void { + return bridge()?.onFindResult?.(callback) ?? (() => {}) +} + +/** + * Reports the panel's current rect (viewport CSS pixels), or null when the + * panel is hidden/unmounted. The embedded view tracks this rect. + */ +export function reportBrowserPanelBounds( + bounds: BrowserPanelBounds | null, + anchor: BrowserPanelAnchor | null = null +): void { + latestPanelBounds = bounds + const agent = bridge() + if (!agent?.setPanelOccluded && panelOccluded && bounds !== null) return + agent?.setPanelBounds(bounds, anchor) +} + +/** + * Fast path for live divider drags. The measured pipeline (renderer layout → + * ResizeObserver → report) can only start after the new panel width has laid + * out, so the native view learns each rect milliseconds after the divider + * moved and can miss the frame's composite deadline — the page visibly swims + * against the divider. During a divider drag only the panel's left edge + * moves, so the next rect is pure arithmetic: the rect measured at drag start + * with its left edge shifted by the divider's travel. Call at drag start with + * the divider position (the panel's left edge in viewport CSS pixels); the + * returned predictor reports a rect per pointer move, before layout runs. + * Measured reports remain authoritative and correct any drift. + * + * Both `startDividerX` and every `dividerX` must be the panel's REAL viewport + * left edge, clamps applied — never a width subtracted from `window.innerWidth`. + * The panel is inset from the viewport by the workspace chrome's padding and + * border, so that substitution reads as a constant offset in `dx`, which lands + * wholly on the predicted rect's left edge: the native view then composites + * beside the panel rather than on it, and alternates with the measured report + * every frame. Derive `dividerX` from the same clamped width the caller writes + * to the DOM and the two cannot disagree. + */ +export function beginBrowserPanelDividerDrag( + startDividerX: number +): ((dividerX: number) => void) | null { + const base = latestPanelBounds + if (!bridge() || !base) return null + return (dividerX: number) => { + const dx = Math.round(dividerX - startDividerX) + const width = base.width - dx + if (width <= 0) return + // The drag has pinned an inline px width, so the rate is flat and the + // viewport is whatever it already was — statable exactly, which keeps the + // rect and its anchor from ever describing different states. + reportBrowserPanelBounds( + { x: base.x + dx, y: base.y, width, height: base.height }, + { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, widthRatio: 0 } + ) + } +} + +/** Reports whether renderer-owned browser chrome owns the interaction context. */ +export function reportBrowserPanelFocused(focused: boolean): void { + bridge()?.setPanelFocused?.(focused) +} + +/** + * Reports whether renderer-owned UI currently overlaps the native browser + * surface. New desktop builds hide the still-attached view directly; older + * builds fall back to temporarily clearing and restoring panel bounds. + */ +export function reportBrowserPanelOcclusion(occluded: boolean): void { + if (panelOccluded === occluded) return + panelOccluded = occluded + const agent = bridge() + if (agent?.setPanelOccluded) { + agent.setPanelOccluded(occluded) + return + } + agent?.setPanelBounds(occluded ? null : latestPanelBounds) +} + +/** Resets occlusion before the panel unmounts or its host document changes. */ +export function resetBrowserPanelOcclusion(): void { + panelOccluded = false + bridge()?.setPanelOccluded?.(false) +} diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index a66fab1221..50e36eaecd 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -6,6 +6,7 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, + claimPendingAsyncToolCall, completeAsyncToolCall, markAsyncToolDelivered, } from './repository' @@ -77,4 +78,29 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('atomically marks one pending native tool claim as running', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'browser-tool', + status: 'running', + claimedBy: 'desktop-browser', + }, + ]) + + const result = await claimPendingAsyncToolCall('browser-tool', 'desktop-browser') + + expect(result).toMatchObject({ + toolCallId: 'browser-tool', + status: 'running', + claimedBy: 'desktop-browser', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + claimedBy: 'desktop-browser', + claimedAt: expect.any(Date), + }) + ) + }) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 4e8c3d236b..257bfdaec2 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -3,12 +3,14 @@ import { db } from '@sim/db' import { type CopilotAsyncToolStatus, type CopilotRunStatus, + type CopilotToolPermissionDecision, copilotAsyncToolCalls, copilotRunCheckpoints, copilotRuns, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' +import { sanitizeValueForJsonb } from '@sim/utils/string' import { and, desc, eq, inArray, isNull } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -187,7 +189,13 @@ export async function getRunSegment(runId: string) { { [TraceAttr.RunId]: runId }, async () => { const [run] = await db - .select({ id: copilotRuns.id, userId: copilotRuns.userId }) + .select({ + id: copilotRuns.id, + userId: copilotRuns.userId, + status: copilotRuns.status, + // Needed to scope an "allow for this chat" decision to its chat. + chatId: copilotRuns.chatId, + }) .from(copilotRuns) .where(eq(copilotRuns.id, runId)) .limit(1) @@ -273,6 +281,7 @@ export async function upsertAsyncToolCall(input: { } const now = new Date() + const args = sanitizeValueForJsonb(input.args ?? {}) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -280,7 +289,7 @@ export async function upsertAsyncToolCall(input: { checkpointId: input.checkpointId ?? null, toolCallId: input.toolCallId, toolName: input.toolName, - args: input.args ?? {}, + args, status: incomingStatus, updatedAt: now, }) @@ -290,7 +299,7 @@ export async function upsertAsyncToolCall(input: { runId: effectiveRunId, checkpointId: input.checkpointId ?? null, toolName: input.toolName, - args: input.args ?? {}, + args, status: incomingStatus, updatedAt: now, }, @@ -354,7 +363,9 @@ async function markAsyncToolStatus( status, claimedBy: updates.claimedBy, claimedAt, - result: updates.result, + // Results carry client/page-derived text; lone UTF-16 surrogates or + // NULs in it would make the jsonb write throw (invalid JSON input). + result: sanitizeValueForJsonb(updates.result), error: updates.error, completedAt: updates.completedAt, updatedAt: new Date(), @@ -371,6 +382,43 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +/** + * Atomically claims a pending client tool exactly once. Native browser actions + * use this before crossing the Electron boundary so a replayed renderer event + * cannot click, type, submit, or navigate twice. + */ +export async function claimPendingAsyncToolCall(toolCallId: string, claimedBy: string) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: ASYNC_TOOL_STATUS.running, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: ASYNC_TOOL_STATUS.running, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) + ) + ) + .returning() + return row ?? null + } + ) +} + export async function completeAsyncToolCall(input: { toolCallId: string status: Extract @@ -400,6 +448,47 @@ export async function completeAsyncToolCall(input: { }) } +/** + * Records the user's answer to a tool permission prompt, exactly once. + * + * The `IS NULL` guard is what makes a decision final: two tabs (or a click + * plus an "allow all") racing on the same prompt resolve to whichever write + * lands first, and the loser gets `null` back rather than overwriting an + * answer the orchestrator may already have acted on. + */ +export async function recordToolPermissionDecision( + toolCallId: string, + decision: CopilotToolPermissionDecision +) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolPermissionDecision]: decision, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + permissionDecision: decision, + permissionDecidedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.permissionDecision) + ) + ) + .returning() + return row ?? null + } + ) +} + export async function markAsyncToolDelivered(toolCallId: string) { return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { claimedBy: null, diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index c7c4fcea85..e4fc38e8e3 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -30,6 +30,7 @@ const STATE_TO_STATUS: Record = { interrupted: ToolCallStatus.interrupted, pending: ToolCallStatus.executing, executing: ToolCallStatus.executing, + awaiting_approval: ToolCallStatus.awaiting_approval, } function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined { diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 4e214ef22f..46e5c63dc3 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -127,9 +127,7 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { }) it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([ - { ...chatRow, model: 'm', planArtifact: null, config: null }, - ]) + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, model: 'm', config: null }]) dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index f6584f731d..69b577a31e 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -23,8 +23,8 @@ export interface ChatLoadResult { /** * Minimal column set needed to perform workflow/workspace authorization for a - * copilot chat. Heavy TOAST-able columns (messages, planArtifact, previewYaml, - * config, resources) are intentionally excluded — callers that only need to + * copilot chat. Heavy TOAST-able columns (messages, previewYaml, config, + * resources) are intentionally excluded — callers that only need to * verify ownership should not pay the detoast cost for those fields. */ const copilotChatAuthColumns = { @@ -40,9 +40,8 @@ const copilotChatAuthColumns = { * transcript is no longer selected from `copilot_chats.messages` (JSONB) — * reads now source it from the normalized `copilot_messages` table via * `loadCopilotChatMessages`, which avoids detoasting the large messages blob on - * every load. The copilot-only TOAST-able fields (`previewYaml`, - * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, - * `lastSeenAt`) remain excluded. + * every load. The copilot-only TOAST-able fields (`previewYaml`, `config`) + * and unused metadata (`model`, `pinned`, `lastSeenAt`) remain excluded. */ const copilotChatDetailColumns = { ...copilotChatAuthColumns, @@ -55,14 +54,13 @@ const copilotChatDetailColumns = { /** * Column set for the legacy copilot chat detail endpoint. Extends - * `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the + * `copilotChatDetailColumns` with `model` and `config` — the * fields the legacy `transformChat` response shape includes. Still drops * `previewYaml` (JSONB), `pinned`, and `lastSeenAt`. */ const copilotChatLegacyDetailColumns = { ...copilotChatDetailColumns, model: copilotChats.model, - planArtifact: copilotChats.planArtifact, config: copilotChats.config, } as const @@ -123,7 +121,7 @@ export type CopilotChatDetailRow = Pick< } export type CopilotChatLegacyDetailRow = CopilotChatDetailRow & - Pick + Pick async function authorizeCopilotChatRow( chat: T | undefined, @@ -185,7 +183,7 @@ export async function getAccessibleCopilotChatAuth( /** * Load a copilot chat row for the legacy chat detail endpoint, including the - * transcript plus `model`, `planArtifact`, and `config`. Drops `previewYaml` + * transcript plus `model` and `config`. Drops `previewYaml` * (JSONB), `pinned`, and `lastSeenAt` — none of which the endpoint returns. */ export async function getAccessibleCopilotChat( @@ -208,8 +206,7 @@ export async function getAccessibleCopilotChat( /** * Load a copilot chat with the conversation transcript and resources after * authorization, omitting copilot-only TOAST-able fields (`previewYaml`, - * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, - * `lastSeenAt`). Use this for the mothership chat detail endpoint and the + * `config`) and unused metadata (`model`, `pinned`, `lastSeenAt`). Use this for the mothership chat detail endpoint and the * shared `resolveOrCreateChat` path — every column read here is consumed * downstream, and dropping the others avoids per-request detoast overhead. */ diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 00cdaa0ff1..d0a23aa970 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -233,6 +233,39 @@ describe('buildCopilotRequestPayload', () => { ) }) + it('advertises desktop capabilities without adding parallel local_* tool schemas', async () => { + const capablePayload = await buildCopilotRequestPayload( + { + message: 'inspect my local project', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: '', + workspaceId: 'ws-1', + desktopLocalFilesystem: true, + }, + { selectedModel: '' } + ) + expect(capablePayload).toMatchObject({ + desktopCapabilities: { localFilesystem: true }, + }) + expect(capablePayload).not.toHaveProperty('mothershipTools') + + const browserPayload = await buildCopilotRequestPayload( + { + message: 'inspect my local project', + userId: 'user-1', + userMessageId: 'msg-2', + mode: 'agent', + model: '', + workspaceId: 'ws-1', + }, + { selectedModel: '' } + ) + expect(browserPayload).not.toHaveProperty('mothershipTools') + expect(browserPayload).not.toHaveProperty('desktopCapabilities') + }) + it('passes user metadata through to the Go request payload', async () => { const payload = await buildCopilotRequestPayload( { diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 6893af3d85..cac3f97f28 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -1,3 +1,4 @@ +import type { BrowserKnownSession } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' @@ -55,6 +56,17 @@ interface BuildPayloadParams { email?: string timezone?: string } + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Array<{ + id: string + cwd?: string + running?: string + interactive?: boolean + active?: boolean + }> + browserSessions?: BrowserKnownSession[] } export interface ToolSchema { @@ -435,6 +447,24 @@ export async function buildCopilotRequestPayload( // Tell the copilot file subagent which document toolchain to write. Emitted // only in Python mode so the JS path sends no new field (Go defaults to js). ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), + ...(params.desktopLocalFilesystem || params.browserCapable || params.terminalCapable + ? { + desktopCapabilities: { + ...(params.desktopLocalFilesystem ? { localFilesystem: true } : {}), + ...(params.browserCapable ? { browser: true } : {}), + ...(params.terminalCapable ? { terminal: true } : {}), + ...(params.terminalCapable && params.terminals?.length + ? { terminals: params.terminals } + : {}), + ...(params.browserCapable && params.browserSessions?.length + ? { browserSessions: params.browserSessions } + : {}), + }, + } + : {}), + // Compatibility with mothership deployments that predate the unified + // desktop capability object. + ...(params.browserCapable ? { browserCapable: true } : {}), isHosted, } } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 41fe97ea31..43529a9f19 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -424,6 +424,7 @@ const OUTCOME_NORMALIZATION: Record = { interrupted: 'interrupted', pending: 'pending', executing: 'executing', + awaiting_approval: 'awaiting_approval', } function normalizeToolState(state: string | undefined): PersistedToolState { diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 660b0941ce..1de5ea17dc 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -244,6 +244,26 @@ describe('handleUnifiedChatPost', () => { ) }) + it('forwards the desktop local filesystem capability into payload construction', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Inspect my local project', + workspaceId: 'ws-1', + createNewChat: true, + desktopCapabilities: { localFilesystem: true }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ desktopLocalFilesystem: true }), + { selectedModel: '' } + ) + }) + it('accepts tagged skill contexts and forwards them to context resolution', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0a92012e76..96878919b8 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -45,6 +45,7 @@ import { } from '@/lib/copilot/request/session' import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types' import { persistChatResources } from '@/lib/copilot/resources/persistence' +import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' @@ -82,10 +83,27 @@ const ResourceAttachmentSchema = z.object({ 'log', 'scheduledtask', 'generic', + 'browser', + // Filtered out client-side rather than sent, but accepted here so a stray + // terminal attachment degrades to a no-op instead of rejecting the whole + // chat request. + 'terminal', ]), id: z.string().min(1), title: z.string().optional(), active: z.boolean().optional(), + /** + * Live page URL for `browser` attachments. The agent browser lives in the + * desktop app, so the client supplies its state — the server has nothing + * to resolve it from. Web-only: this string is interpolated into LLM + * context, and rejecting other schemes (file://, chrome://…) keeps local + * host paths from ever entering the copilot payload. + */ + url: z + .string() + .max(2048) + .regex(/^https?:\/\//, 'Must be an http(s) URL') + .optional(), }) const GENERIC_RESOURCE_TITLE: Record['type'], string> = { @@ -99,6 +117,21 @@ const GENERIC_RESOURCE_TITLE: Record['t log: 'Log', scheduledtask: 'Scheduled Task', generic: 'Resource', + browser: 'Browser', + terminal: 'Terminal', +} + +/** + * Synthetic client-side panels are context-only: never persisted to the chat. + * Browser tab metadata is persistable even though its live page is client-held. + * Shares the client's rule so the two layers cannot drift. + */ +function isPersistableAttachment(resource: z.infer): boolean { + return !isEphemeralResource({ + type: resource.type, + id: resource.id, + title: resource.title ?? '', + }) } const ChatContextSchema = z.object({ @@ -119,6 +152,8 @@ const ChatContextSchema = z.object({ 'integration', 'skill', 'mcp', + 'browser_tab', + 'terminal_tab', ]), label: z.string(), chatId: z.string().optional(), @@ -134,6 +169,8 @@ const ChatContextSchema = z.object({ skillId: z.string().optional(), serverId: z.string().optional(), scheduleId: z.string().optional(), + tabId: z.string().optional(), + terminalId: z.string().optional(), }) const ChatMessageSchema = z.object({ @@ -154,9 +191,44 @@ const ChatMessageSchema = z.object({ contexts: z.array(ChatContextSchema).optional(), commands: z.array(z.string()).optional(), userTimezone: z.string().optional(), + desktopCapabilities: z + .object({ + localFilesystem: z.boolean().optional(), + browser: z.boolean().optional(), + terminal: z.boolean().optional(), + terminals: z + .array( + z.object({ + id: z.string().max(64), + cwd: z.string().max(1024).optional(), + running: z.string().max(1024).optional(), + interactive: z.boolean().optional(), + active: z.boolean().optional(), + }) + ) + .max(8) + .optional(), + browserSessions: z + .array( + z.object({ + hostname: z + .string() + .max(253) + .regex(/^[a-z0-9.-]+$/), + evidence: z.enum(['sign-in-completed', 'cookies']), + lastObservedAt: z.string().datetime(), + }) + ) + .max(20) + .optional(), + }) + .optional(), + browserCapable: z.boolean().optional(), }) type UnifiedChatRequest = z.infer +type BrowserSessions = NonNullable['browserSessions'] +type Terminals = NonNullable['terminals'] type UnifiedChatBranch = | { kind: 'workflow' @@ -193,6 +265,11 @@ type UnifiedChatBranch = implicitFeedback?: string workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Terminals + browserSessions?: BrowserSessions }) => Promise> buildExecutionContext: (params: { userId: string @@ -224,6 +301,11 @@ type UnifiedChatBranch = userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Terminals + browserSessions?: BrowserSessions }) => Promise> buildExecutionContext: (params: { userId: string @@ -314,6 +396,22 @@ async function resolveAgentContexts(params: { if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0 && workspaceId) { const results = await Promise.allSettled( resourceAttachments.map(async (resource) => { + // The live browser panel resolves from the attachment itself: its + // page state is client-held (the desktop app's embedded browser), + // not a workspace entity the server could look up. + if (resource.type === 'browser') { + if (!resource.url) return null + const title = resource.title?.trim() + return { + type: 'active_resource', + tag: resource.active ? '@active_tab' : '@open_tab', + content: `The user's ${ + resource.active ? 'currently visible browser tab' : 'other open browser tab' + } (driven by the browser subagent) is open on: ${ + title ? `"${title}" — ` : '' + }${resource.url}`, + } + } const ctx = await resolveActiveResourceContext( resource.type, resource.id, @@ -680,6 +778,11 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, + terminalCapable: payloadParams.terminalCapable, + terminals: payloadParams.terminals, + browserSessions: payloadParams.browserSessions, }, { selectedModel } ), @@ -737,6 +840,11 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, + terminalCapable: payloadParams.terminalCapable, + terminals: payloadParams.terminals, + browserSessions: payloadParams.browserSessions, }, { selectedModel: '' } ), @@ -883,14 +991,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { } if (chatIsNew && actualChatId && body.resourceAttachments?.length) { - await persistChatResources( - actualChatId, - body.resourceAttachments.map((r) => ({ - type: r.type, - id: r.id, - title: r.title ?? GENERIC_RESOURCE_TITLE[r.type], - })) - ) + const persistable = body.resourceAttachments.filter(isPersistableAttachment) + if (persistable.length > 0) { + await persistChatResources( + actualChatId, + persistable.map((r) => ({ + type: r.type, + id: r.id, + title: r.title ?? GENERIC_RESOURCE_TITLE[r.type], + })) + ) + } } let pendingStreamWaitMs = 0 @@ -1065,6 +1176,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { implicitFeedback: body.implicitFeedback, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: + body.desktopCapabilities?.browser === true || body.browserCapable === true, + terminalCapable: body.desktopCapabilities?.terminal === true, + terminals: body.desktopCapabilities?.terminals, + browserSessions: body.desktopCapabilities?.browserSessions, }) : branch.buildPayload({ message: body.message, @@ -1080,6 +1197,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMetadata, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: + body.desktopCapabilities?.browser === true || body.browserCapable === true, + terminalCapable: body.desktopCapabilities?.terminal === true, + terminals: body.desktopCapabilities?.terminals, + browserSessions: body.desktopCapabilities?.browserSessions, }) }, activeOtelRoot.context diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 17c9aa98c1..54fce5d321 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -143,6 +143,24 @@ export async function processContextsServer( currentWorkspaceId ) } + // Tabs resolve to a pointer, not their contents. The agent has tools + // that read a live tab, and by the time it acts the page may have + // navigated or the shell scrolled on — so naming the tab it should look + // at beats pasting a snapshot that was true when the message was sent. + if (ctx.kind === 'browser_tab' && ctx.tabId) { + return { + type: 'browser_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). Act on THIS tab — switch to it with browser_switch_tab and read it with browser_snapshot rather than assuming which tab they meant.`, + } + } + if (ctx.kind === 'terminal_tab' && ctx.terminalId) { + return { + type: 'terminal_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). Act on THIS terminal — pass that terminalId to the terminal tool, and read its screen before assuming what is in it.`, + } + } if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { return await processWorkflowBlockFromDb( ctx.workflowId, diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 3051019eda..c6e0da22b0 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -298,10 +298,12 @@ describe('custom blocks', () => { expect(buildWorkspaceMd(baseData())).not.toContain('## Custom Blocks') }) - it('never leaks custom blocks into the typed snapshot Go diffs (diff-safety)', () => { + it('carries custom blocks in the typed snapshot keyed by type (Go diffs the customBlocks kind)', () => { const withBlocks = buildVfsSnapshot(baseData({ customBlocks })) + expect(withBlocks.customBlocks).toEqual([ + { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, + ]) const without = buildVfsSnapshot(baseData()) - expect('customBlocks' in withBlocks).toBe(false) - expect(JSON.stringify(withBlocks)).toBe(JSON.stringify(without)) + expect(without.customBlocks).toEqual([]) }) }) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index f06cc1ec17..faf2b3a217 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -78,7 +78,6 @@ export interface WorkspaceMdData { role?: string | null }> envVariables: string[] - tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> customBlocks?: Array<{ type: string; name: string; description?: string }> mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> @@ -607,7 +606,9 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { .map((j) => ({ id: j.id, ...(j.title ? { title: j.title } : {}), - ...(j.prompt ? { prompt: j.prompt } : {}), + // Match WORKSPACE.md's preview truncation — full prompts are large, + // volatile-ish, and readable on demand at jobs/{title}/meta.json. + ...(j.prompt ? { prompt: j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt } : {}), ...(j.cronExpression ? { cronExpression: j.cronExpression } : {}), ...(j.status ? { status: j.status } : {}), ...(j.lifecycle ? { lifecycle: j.lifecycle } : {}), @@ -658,6 +659,11 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { })), envVars: data.envVariables, customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })), + customBlocks: (data.customBlocks ?? []).map((b) => ({ + type: b.type, + name: b.name, + ...(b.description ? { description: b.description } : {}), + })), mcpServers: (data.mcpServers ?? []).map((s) => ({ id: s.id, name: s.name, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 6081954a20..76322f7b64 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -1292,7 +1292,16 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { type: 'object', }, MothershipStreamV1ToolStatus: { - enum: ['generating', 'executing', 'success', 'error', 'cancelled', 'skipped', 'rejected'], + enum: [ + 'generating', + 'awaiting_approval', + 'executing', + 'success', + 'error', + 'cancelled', + 'skipped', + 'rejected', + ], type: 'string', }, MothershipStreamV1ToolUI: { diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 81e98257dd..5cdf7801bf 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -30,6 +30,7 @@ export type MothershipStreamV1ToolExecutor = 'go' | 'sim' | 'client' export type MothershipStreamV1ToolMode = 'sync' | 'async' export type MothershipStreamV1ToolStatus = | 'generating' + | 'awaiting_approval' | 'executing' | 'success' | 'error' @@ -546,6 +547,7 @@ export const MothershipStreamV1ToolPhase = { export const MothershipStreamV1ToolStatus = { generating: 'generating', + awaiting_approval: 'awaiting_approval', executing: 'executing', success: 'success', error: 'error', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index ab599c3cf6..88159f0d49 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,6 +9,28 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' @@ -17,9 +39,6 @@ export interface ToolCatalogEntry { | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -56,9 +75,9 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -76,6 +95,7 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' @@ -96,10 +116,12 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' internal?: boolean @@ -107,6 +129,28 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' @@ -115,9 +159,6 @@ export interface ToolCatalogEntry { | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -154,9 +195,9 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -174,6 +215,7 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' @@ -194,19 +236,23 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' + requiresApproval?: boolean resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: | 'agent' | 'auth' + | 'browser' | 'deploy' | 'file' | 'knowledge' @@ -251,6 +297,364 @@ export const Auth: ToolCatalogEntry = { internal: true, } +export const Browser: ToolCatalogEntry = { + id: 'browser', + name: 'browser', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'browser', + internal: true, +} + +export const BrowserClick: ToolCatalogEntry = { + id: 'browser_click', + name: 'browser_click', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + clientExecutable: true, +} + +export const BrowserCloseTab: ToolCatalogEntry = { + id: 'browser_close_tab', + name: 'browser_close_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserExtract: ToolCatalogEntry = { + id: 'browser_extract', + name: 'browser_extract', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + clientExecutable: true, +} + +export const BrowserGoBack: ToolCatalogEntry = { + id: 'browser_go_back', + name: 'browser_go_back', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserGoForward: ToolCatalogEntry = { + id: 'browser_go_forward', + name: 'browser_go_forward', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserHover: ToolCatalogEntry = { + id: 'browser_hover', + name: 'browser_hover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + clientExecutable: true, +} + +export const BrowserListSessions: ToolCatalogEntry = { + id: 'browser_list_sessions', + name: 'browser_list_sessions', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserListTabs: ToolCatalogEntry = { + id: 'browser_list_tabs', + name: 'browser_list_tabs', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserNavigate: ToolCatalogEntry = { + id: 'browser_navigate', + name: 'browser_navigate', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserOpenTab: ToolCatalogEntry = { + id: 'browser_open_tab', + name: 'browser_open_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { url: { type: 'string', description: 'Optional URL to open the new tab at.' } }, + }, + clientExecutable: true, +} + +export const BrowserOpenUrl: ToolCatalogEntry = { + id: 'browser_open_url', + name: 'browser_open_url', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserPressKey: ToolCatalogEntry = { + id: 'browser_press_key', + name: 'browser_press_key', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + }, + }, + required: ['key'], + }, + clientExecutable: true, +} + +export const BrowserReadText: ToolCatalogEntry = { + id: 'browser_read_text', + name: 'browser_read_text', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + }, + }, + }, + clientExecutable: true, +} + +export const BrowserRequestTakeover: ToolCatalogEntry = { + id: 'browser_request_takeover', + name: 'browser_request_takeover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + clientExecutable: true, +} + +export const BrowserScreenshot: ToolCatalogEntry = { + id: 'browser_screenshot', + name: 'browser_screenshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserScroll: ToolCatalogEntry = { + id: 'browser_scroll', + name: 'browser_scroll', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { type: 'string', description: 'Scroll direction.', enum: ['up', 'down'] }, + }, + required: ['direction'], + }, + clientExecutable: true, +} + +export const BrowserSelectOption: ToolCatalogEntry = { + id: 'browser_select_option', + name: 'browser_select_option', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + value: { type: 'string', description: "The option's visible label or its value." }, + }, + required: ['elementId', 'value'], + }, + clientExecutable: true, +} + +export const BrowserSnapshot: ToolCatalogEntry = { + id: 'browser_snapshot', + name: 'browser_snapshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserSwitchTab: ToolCatalogEntry = { + id: 'browser_switch_tab', + name: 'browser_switch_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserType: ToolCatalogEntry = { + id: 'browser_type', + name: 'browser_type', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + submit: { type: 'boolean', description: 'Press Enter after typing. Default false.' }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + clientExecutable: true, +} + +export const BrowserWaitFor: ToolCatalogEntry = { + id: 'browser_wait_for', + name: 'browser_wait_for', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Optional visible text to wait for.' }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + clientExecutable: true, +} + export const CallIntegrationTool: ToolCatalogEntry = { id: 'call_integration_tool', name: 'call_integration_tool', @@ -278,6 +682,7 @@ export const CallIntegrationTool: ToolCatalogEntry = { required: ['toolId', 'description', 'arguments'], type: 'object', }, + requiresApproval: true, } export const CheckDeploymentStatus: ToolCatalogEntry = { @@ -484,72 +889,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const DeleteFile: ToolCatalogEntry = { - id: 'delete_file', - name: 'delete_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the delete succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const DeleteFileFolder: ToolCatalogEntry = { - id: 'delete_file_folder', - name: 'delete_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const DeleteWorkflow: ToolCatalogEntry = { - id: 'delete_workflow', - name: 'delete_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { type: 'string' }, - }, - }, - required: ['workflowIds'], - }, - requiredPermission: 'write', -} - export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { id: 'delete_workspace_mcp_server', name: 'delete_workspace_mcp_server', @@ -563,6 +902,7 @@ export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { required: ['serverId'], }, requiredPermission: 'admin', + requiresApproval: true, } export const Deploy: ToolCatalogEntry = { @@ -663,6 +1003,7 @@ export const DeployApi: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployChat: ToolCatalogEntry = { @@ -808,6 +1149,7 @@ export const DeployChat: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployCustomBlock: ToolCatalogEntry = { @@ -870,11 +1212,10 @@ export const DeployCustomBlock: ToolCatalogEntry = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -931,6 +1272,12 @@ export const DeployMcp: ToolCatalogEntry = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1013,6 +1360,7 @@ export const DeployMcp: ToolCatalogEntry = { required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', + requiresApproval: true, } export const DiffWorkflows: ToolCatalogEntry = { @@ -1209,7 +1557,7 @@ export const EnrichmentRun: ToolCatalogEntry = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1539,7 +1887,7 @@ export const FunctionExecute: ToolCatalogEntry = { timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -1551,6 +1899,7 @@ export const FunctionExecute: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } @@ -2209,7 +2558,7 @@ export const Glob: ToolCatalogEntry = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2227,7 +2576,7 @@ export const Grep: ToolCatalogEntry = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', description: 'Case insensitive search (default false).' }, lineNumbers: { @@ -2253,12 +2602,12 @@ export const Grep: ToolCatalogEntry = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2430,7 +2779,6 @@ export const KnowledgeBase: ToolCatalogEntry = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2450,7 +2798,11 @@ export const KnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -2461,8 +2813,8 @@ export const KnowledgeBase: ToolCatalogEntry = { export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { integration: { @@ -2527,8 +2879,8 @@ export const LoadDeployment: ToolCatalogEntry = { export const LoadIntegrationTool: ToolCatalogEntry = { id: 'load_integration_tool', name: 'load_integration_tool', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { tool_ids: { @@ -2543,6 +2895,24 @@ export const LoadIntegrationTool: ToolCatalogEntry = { }, } +export const LoadSkill: ToolCatalogEntry = { + id: 'load_skill', + name: 'load_skill', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", + }, + }, + required: ['name'], + }, +} + export const ManageCredential: ToolCatalogEntry = { id: 'manage_credential', name: 'manage_credential', @@ -2638,31 +3008,6 @@ export const ManageCustomTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageFolder: ToolCatalogEntry = { - id: 'manage_folder', - name: 'manage_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - requiredPermission: 'write', -} - export const ManageMcpTool: ToolCatalogEntry = { id: 'manage_mcp_tool', name: 'manage_mcp_tool', @@ -2730,7 +3075,7 @@ export const ManageScheduledTask: ToolCatalogEntry = { cron: { type: 'string', description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", }, jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, jobIds: { @@ -3024,6 +3369,7 @@ export const PromoteToLive: ToolCatalogEntry = { required: ['version'], }, requiredPermission: 'admin', + requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -3278,6 +3624,7 @@ export const Redeploy: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const Respond: ToolCatalogEntry = { @@ -3329,6 +3676,31 @@ export const RestoreResource: ToolCatalogEntry = { requiredPermission: 'admin', } +export const Rm: ToolCatalogEntry = { + id: 'rm', + name: 'rm', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const Run: ToolCatalogEntry = { id: 'run', name: 'run', @@ -3473,6 +3845,7 @@ export const RunCode: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'table_input'], } @@ -3551,6 +3924,7 @@ export const RunWorkflow: ToolCatalogEntry = { }, }, clientExecutable: true, + requiresApproval: true, } export const RunWorkflowUntilBlock: ToolCatalogEntry = { @@ -3599,6 +3973,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { required: ['stopAfterBlockId'], }, clientExecutable: true, + requiresApproval: true, } export const ScheduledTask: ToolCatalogEntry = { @@ -3668,7 +4043,12 @@ export const SearchDocumentation: ToolCatalogEntry = { type: 'object', properties: { query: { type: 'string', description: 'The search query' }, - topK: { type: 'number', description: 'Number of results (max 10)' }, + topK: { + type: 'number', + description: + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, + }, }, required: ['query'], }, @@ -3737,7 +4117,11 @@ export const SearchKnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -3761,7 +4145,11 @@ export const SearchLibraryDocs: ToolCatalogEntry = { type: 'string', description: 'The question or topic to find documentation for - be specific', }, - version: { type: 'string', description: "Specific version (optional, e.g., '14', 'v2')" }, + version: { + type: 'string', + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", + }, }, required: ['library_name', 'query'], }, @@ -3812,7 +4200,7 @@ export const SearchPatterns: ToolCatalogEntry = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3906,12 +4294,14 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { operation: { type: 'string', enum: ['add', 'delete', 'edit'] }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3995,6 +4385,126 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const Terminal: ToolCatalogEntry = { + id: 'terminal', + name: 'terminal', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + clientExecutable: true, + requiresApproval: true, +} + export const UpdateDeploymentVersion: ToolCatalogEntry = { id: 'update_deployment_version', name: 'update_deployment_version', @@ -4116,6 +4626,12 @@ export const UserTable: ToolCatalogEntry = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')" }, enrichmentId: { type: 'string', @@ -4243,13 +4759,13 @@ export const UserTable: ToolCatalogEntry = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4283,12 +4799,6 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { type: 'integer' }, - }, rowId: { type: 'string', description: @@ -4366,7 +4876,6 @@ export const UserTable: ToolCatalogEntry = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4408,6 +4917,25 @@ export const UserTable: ToolCatalogEntry = { }, } +export const Wait: ToolCatalogEntry = { + id: 'wait', + name: 'wait', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, + }, + required: ['seconds'], + }, +} + export const Workflow: ToolCatalogEntry = { id: 'workflow', name: 'workflow', @@ -4601,7 +5129,6 @@ export const KnowledgeBaseOperation = { query: 'query', addFile: 'add_file', update: 'update', - delete: 'delete', deleteDocument: 'delete_document', updateDocument: 'update_document', listTags: 'list_tags', @@ -4624,7 +5151,6 @@ export const KnowledgeBaseOperationValues = [ KnowledgeBaseOperation.query, KnowledgeBaseOperation.addFile, KnowledgeBaseOperation.update, - KnowledgeBaseOperation.delete, KnowledgeBaseOperation.deleteDocument, KnowledgeBaseOperation.updateDocument, KnowledgeBaseOperation.listTags, @@ -4668,15 +5194,6 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageFolderOperation = { - delete: 'delete', -} as const - -export type ManageFolderOperation = - (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] - -export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const - export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -4776,13 +5293,42 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TerminalOperation = { + run: 'run', + read: 'read', + input: 'input', + kill: 'kill', + cwd: 'cwd', + list: 'list', + new: 'new', + switch: 'switch', + close: 'close', + panes: 'panes', + handoff: 'handoff', +} as const + +export type TerminalOperation = (typeof TerminalOperation)[keyof typeof TerminalOperation] + +export const TerminalOperationValues = [ + TerminalOperation.run, + TerminalOperation.read, + TerminalOperation.input, + TerminalOperation.kill, + TerminalOperation.cwd, + TerminalOperation.list, + TerminalOperation.new, + TerminalOperation.switch, + TerminalOperation.close, + TerminalOperation.panes, + TerminalOperation.handoff, +] as const + export const UserTableOperation = { create: 'create', createFromFile: 'create_from_file', importFile: 'import_file', get: 'get', getSchema: 'get_schema', - delete: 'delete', rename: 'rename', insertRow: 'insert_row', batchInsertRows: 'batch_insert_rows', @@ -4818,7 +5364,6 @@ export const UserTableOperationValues = [ UserTableOperation.importFile, UserTableOperation.get, UserTableOperation.getSchema, - UserTableOperation.delete, UserTableOperation.rename, UserTableOperation.insertRow, UserTableOperation.batchInsertRows, @@ -4864,6 +5409,28 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, + [Browser.id]: Browser, + [BrowserClick.id]: BrowserClick, + [BrowserCloseTab.id]: BrowserCloseTab, + [BrowserExtract.id]: BrowserExtract, + [BrowserGoBack.id]: BrowserGoBack, + [BrowserGoForward.id]: BrowserGoForward, + [BrowserHover.id]: BrowserHover, + [BrowserListSessions.id]: BrowserListSessions, + [BrowserListTabs.id]: BrowserListTabs, + [BrowserNavigate.id]: BrowserNavigate, + [BrowserOpenTab.id]: BrowserOpenTab, + [BrowserOpenUrl.id]: BrowserOpenUrl, + [BrowserPressKey.id]: BrowserPressKey, + [BrowserReadText.id]: BrowserReadText, + [BrowserRequestTakeover.id]: BrowserRequestTakeover, + [BrowserScreenshot.id]: BrowserScreenshot, + [BrowserScroll.id]: BrowserScroll, + [BrowserSelectOption.id]: BrowserSelectOption, + [BrowserSnapshot.id]: BrowserSnapshot, + [BrowserSwitchTab.id]: BrowserSwitchTab, + [BrowserType.id]: BrowserType, + [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteScheduledTask.id]: CompleteScheduledTask, @@ -4872,9 +5439,6 @@ export const TOOL_CATALOG: Record = { [CreateFile.id]: CreateFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, - [DeleteFile.id]: DeleteFile, - [DeleteFileFolder.id]: DeleteFileFolder, - [DeleteWorkflow.id]: DeleteWorkflow, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, @@ -4911,9 +5475,9 @@ export const TOOL_CATALOG: Record = { [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, + [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageFolder.id]: ManageFolder, [ManageMcpTool.id]: ManageMcpTool, [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, @@ -4931,6 +5495,7 @@ export const TOOL_CATALOG: Record = { [Redeploy.id]: Redeploy, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, + [Rm.id]: Rm, [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, @@ -4951,10 +5516,12 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, + [Wait.id]: Wait, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c7a5d93aac..e527c57b54 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,6 +36,289 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + browser: { + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + browser_click: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_close_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_extract: { + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + resultSchema: undefined, + }, + browser_go_back: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_go_forward: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_hover: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_list_sessions: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_list_tabs: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_navigate: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_open_tab: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Optional URL to open the new tab at.', + }, + }, + }, + resultSchema: undefined, + }, + browser_open_url: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_press_key: { + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + }, + }, + required: ['key'], + }, + resultSchema: undefined, + }, + browser_read_text: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + }, + }, + }, + resultSchema: undefined, + }, + browser_request_takeover: { + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + resultSchema: undefined, + }, + browser_screenshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_scroll: { + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { + type: 'string', + description: 'Scroll direction.', + enum: ['up', 'down'], + }, + }, + required: ['direction'], + }, + resultSchema: undefined, + }, + browser_select_option: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + value: { + type: 'string', + description: "The option's visible label or its value.", + }, + }, + required: ['elementId', 'value'], + }, + resultSchema: undefined, + }, + browser_snapshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_switch_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_type: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + submit: { + type: 'boolean', + description: 'Press Enter after typing. Default false.', + }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + resultSchema: undefined, + }, + browser_wait_for: { + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'Optional visible text to wait for.', + }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + resultSchema: undefined, + }, call_integration_tool: { parameters: { properties: { @@ -272,68 +555,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the delete succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - delete_file_folder: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - delete_workflow: { - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { - type: 'string', - }, - }, - }, - required: ['workflowIds'], - }, - resultSchema: undefined, - }, delete_workspace_mcp_server: { parameters: { type: 'object', @@ -669,14 +890,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)', }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -736,6 +956,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1024,7 +1250,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1363,7 +1589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -2017,7 +2243,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2031,7 +2257,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', @@ -2060,12 +2286,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2242,7 +2468,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2263,8 +2488,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', }, message: { type: 'string', @@ -2348,6 +2574,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + load_skill: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", + }, + }, + required: ['name'], + }, + resultSchema: undefined, + }, manage_credential: { parameters: { type: 'object', @@ -2457,30 +2697,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['delete'], - }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, manage_mcp_tool: { parameters: { type: 'object', @@ -2545,7 +2761,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { cron: { type: 'string', description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", }, jobId: { type: 'string', @@ -3141,6 +3357,28 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + rm: { + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + resultSchema: undefined, + }, run: { parameters: { properties: { @@ -3454,7 +3692,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, topK: { type: 'number', - description: 'Number of results (max 10)', + description: + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, required: ['query'], @@ -3520,8 +3760,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', }, message: { type: 'string', @@ -3549,7 +3790,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, version: { type: 'string', - description: "Specific version (optional, e.g., '14', 'v2')", + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", }, }, required: ['library_name', 'query'], @@ -3602,7 +3844,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3694,12 +3936,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3784,6 +4028,120 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + terminal: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, update_deployment_version: { parameters: { type: 'object', @@ -3908,6 +4266,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')", @@ -4048,13 +4412,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4094,14 +4458,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { - type: 'integer', - }, - }, rowId: { type: 'string', description: @@ -4183,7 +4539,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4233,6 +4588,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + wait: { + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { + type: 'number', + description: 'How long to pause, in seconds. Capped at 120.', + }, + }, + required: ['seconds'], + }, + resultSchema: undefined, + }, workflow: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 430921c1f7..6db17a6329 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -180,6 +180,7 @@ export const TraceAttr = { CopilotAsyncToolClaimedBy: 'copilot.async_tool.claimed_by', CopilotAsyncToolHasError: 'copilot.async_tool.has_error', CopilotAsyncToolIdsCount: 'copilot.async_tool.ids_count', + CopilotAsyncToolPermissionDecision: 'copilot.async_tool.permission_decision', CopilotAsyncToolStatus: 'copilot.async_tool.status', CopilotAsyncToolWorkerId: 'copilot.async_tool.worker_id', CopilotBranchKind: 'copilot.branch.kind', @@ -823,6 +824,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.async_tool.claimed_by', 'copilot.async_tool.has_error', 'copilot.async_tool.ids_count', + 'copilot.async_tool.permission_decision', 'copilot.async_tool.status', 'copilot.async_tool.worker_id', 'copilot.branch.kind', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index 9ade1eabbf..5048cb2fbf 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -9,7 +9,6 @@ // single source of truth and typos become compile errors. export const TraceSpan = { - AnthropicCountTokens: 'anthropic.count_tokens', AsyncToolStoreSet: 'async_tool_store.set', AuthRateLimitRecord: 'auth.rate_limit.record', AuthValidateKey: 'auth.validate_key', @@ -62,6 +61,8 @@ export const TraceSpan = { CopilotSseReadLoop: 'copilot.sse.read_loop', CopilotSubagentExecute: 'copilot.subagent.execute', CopilotToolWaitForClientResult: 'copilot.tool.wait_for_client_result', + CopilotToolWaitForPermission: 'copilot.tool.wait_for_permission', + CopilotToolPermissionDecide: 'copilot.tool_permission.decide', CopilotToolsHandleResourceSideEffects: 'copilot.tools.handle_resource_side_effects', CopilotToolsWriteCsvToTable: 'copilot.tools.write_csv_to_table', CopilotToolsWriteOutputFile: 'copilot.tools.write_output_file', @@ -71,7 +72,6 @@ export const TraceSpan = { CopilotVfsReadFile: 'copilot.vfs.read_file', GenAiAgentExecute: 'gen_ai.agent.execute', LlmStream: 'llm.stream', - ProviderRouterCountTokens: 'provider.router.count_tokens', ProviderRouterRoute: 'provider.router.route', SimUpdateCost: 'sim.update_cost', SimValidateApiKey: 'sim.validate_api_key', @@ -84,7 +84,6 @@ export type TraceSpanValue = (typeof TraceSpan)[TraceSpanKey] /** Readonly sorted list of every canonical span name. */ export const TraceSpanValues: readonly TraceSpanValue[] = [ - 'anthropic.count_tokens', 'async_tool_store.set', 'auth.rate_limit.record', 'auth.validate_key', @@ -137,6 +136,8 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.sse.read_loop', 'copilot.subagent.execute', 'copilot.tool.wait_for_client_result', + 'copilot.tool.wait_for_permission', + 'copilot.tool_permission.decide', 'copilot.tools.handle_resource_side_effects', 'copilot.tools.write_csv_to_table', 'copilot.tools.write_output_file', @@ -146,7 +147,6 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.vfs.read_file', 'gen_ai.agent.execute', 'llm.stream', - 'provider.router.count_tokens', 'provider.router.route', 'sim.update_cost', 'sim.validate_api_key', diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index 57cb552726..37486a7507 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -5,6 +5,7 @@ * Structured workspace inventory snapshot Sim sends to Go; Go diffs successive snapshots into baseline+delta messages. */ export interface VfsSnapshotV1 { + customBlocks?: VfsSnapshotV1CustomBlock[] customTools?: VfsSnapshotV1NamedResource[] envVars?: string[] files?: VfsSnapshotV1File[] @@ -18,6 +19,15 @@ export interface VfsSnapshotV1 { workflows?: VfsSnapshotV1Workflow[] workspace?: VfsSnapshotV1Workspace } +/** + * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema + * via the `definition` "VfsSnapshotV1CustomBlock". + */ +export interface VfsSnapshotV1CustomBlock { + description?: string + name: string + type: string +} /** * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema * via the `definition` "VfsSnapshotV1NamedResource". diff --git a/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts b/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts new file mode 100644 index 0000000000..5aeff194a0 --- /dev/null +++ b/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts @@ -0,0 +1,93 @@ +import { db } from '@sim/db' +import { copilotChats, settings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' + +const logger = createLogger('CopilotToolAutoAllow') + +function toToolNameSet(value: unknown): Set { + if (!Array.isArray(value)) return new Set() + return new Set(value.filter((entry): entry is string => typeof entry === 'string')) +} + +/** + * The tool ids this request may run unprompted: the user's account-wide + * always-allow list, plus anything allowed for the rest of this chat. + * + * Read once per chat request and carried on the streaming context, so a turn + * that calls twenty gated tools does not issue twenty settings lookups. + */ +export async function getAutoAllowedTools( + userId: string | null | undefined, + chatId?: string | null +): Promise> { + if (!userId) return new Set() + try { + const [userRow, chatRow] = await Promise.all([ + db + .select({ tools: settings.copilotAutoAllowedTools }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) + .then((rows) => rows[0]), + chatId + ? db + .select({ tools: copilotChats.autoAllowedTools }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) + .then((rows) => rows[0]) + : Promise.resolve(undefined), + ]) + return new Set([...toToolNameSet(userRow?.tools), ...toToolNameSet(chatRow?.tools)]) + } catch (error) { + // Fail closed: an unreadable preference list means we prompt, never that we + // silently run a gated tool. + logger.warn('Failed to read auto-allowed tools; treating as empty', { + userId, + chatId, + error: toError(error).message, + }) + return new Set() + } +} + +/** + * Adds a tool to the user's account-wide always-allow list. + * + * Written as a single containment-guarded append so two prompts answered with + * "always allow" at the same moment cannot clobber each other the way a + * read-modify-write would. + */ +export async function addAutoAllowedTool(userId: string, toolName: string): Promise { + const entry = JSON.stringify([toolName]) + await db + .insert(settings) + .values({ + id: generateShortId(), + userId, + copilotAutoAllowedTools: [toolName], + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: settings.userId, + set: { + copilotAutoAllowedTools: sql`CASE WHEN ${settings.copilotAutoAllowedTools} @> ${entry}::jsonb THEN ${settings.copilotAutoAllowedTools} ELSE ${settings.copilotAutoAllowedTools} || ${entry}::jsonb END`, + updatedAt: new Date(), + }, + }) +} + +/** Adds a tool to one chat's allow list, leaving the user's other chats alone. */ +export async function addChatAutoAllowedTool(chatId: string, toolName: string): Promise { + const entry = JSON.stringify([toolName]) + await db + .update(copilotChats) + .set({ + autoAllowedTools: sql`CASE WHEN ${copilotChats.autoAllowedTools} @> ${entry}::jsonb THEN ${copilotChats.autoAllowedTools} ELSE ${copilotChats.autoAllowedTools} || ${entry}::jsonb END`, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) +} diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts new file mode 100644 index 0000000000..718b6e9e3e --- /dev/null +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -0,0 +1,156 @@ +import type { CopilotToolPermissionDecision } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' + +const logger = createLogger('CopilotToolPermission') + +export const TOOL_PERMISSION_DECISION = { + allow: 'allow', + /** Allowed for the rest of this chat only. */ + allow_chat: 'allow_chat', + /** Allowed in every chat, from now on. */ + always_allow: 'always_allow', + skip: 'skip', +} as const satisfies Record + +export type ToolPermissionDecision = CopilotToolPermissionDecision + +export interface ToolPermissionEnvelope { + toolCallId: string + decision: ToolPermissionDecision + toolName?: string + decidedAt?: string +} + +/** Every allow variant runs the tool; they differ only in what gets remembered. */ +export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { + return decision !== TOOL_PERMISSION_DECISION.skip +} + +/** True for the decisions that suppress future prompts for the same tool. */ +export function decisionSuppressesFuturePrompts(decision: ToolPermissionDecision): boolean { + return ( + decision === TOOL_PERMISSION_DECISION.allow_chat || + decision === TOOL_PERMISSION_DECISION.always_allow + ) +} + +export function isToolPermissionDecision(value: unknown): value is ToolPermissionDecision { + return ( + value === TOOL_PERMISSION_DECISION.allow || + value === TOOL_PERMISSION_DECISION.allow_chat || + value === TOOL_PERMISSION_DECISION.always_allow || + value === TOOL_PERMISSION_DECISION.skip + ) +} + +type ToolPermissionGlobal = typeof globalThis & { + _toolPermissionChannel?: PubSubChannel +} + +const _g = globalThis as ToolPermissionGlobal +if (!_g._toolPermissionChannel) { + _g._toolPermissionChannel = createPubSubChannel({ + channel: 'copilot:tool-permission', + label: 'CopilotToolPermission', + }) +} +const toolPermissionChannel = _g._toolPermissionChannel + +export function publishToolPermissionDecision(event: ToolPermissionEnvelope): void { + logger.info('Publishing tool permission decision', { + toolCallId: event.toolCallId, + decision: event.decision, + }) + toolPermissionChannel.publish(event) +} + +/** + * Read a decision straight from the durable async tool row. + * + * This is the reload path: the prompt outlives the browser tab, so the answer + * has to be recoverable from Postgres rather than only from a live pubsub + * message. + */ +export async function getToolPermissionDecision( + toolCallId: string +): Promise { + const row = await getAsyncToolCall(toolCallId).catch((err) => { + logger.warn('Failed to read tool permission decision', { + toolCallId, + error: toError(err).message, + }) + return null + }) + if (!row?.permissionDecision) return null + return { + toolCallId, + decision: row.permissionDecision, + toolName: row.toolName, + decidedAt: row.permissionDecidedAt?.toISOString(), + } +} + +/** + * Block until the user answers a permission prompt. + * + * Mirrors `waitForToolConfirmation`: subscribe first, then read the durable + * row, so a decision that lands between those two steps — or that was made + * against a different server instance, or before this waiter existed at all — + * is still picked up. Resolves `null` on timeout or abort; callers treat that + * as "no decision" and fail the tool rather than running it. + */ +export async function waitForToolPermissionDecision( + toolCallId: string, + timeoutMs: number, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve) => { + let settled = false + let timeoutId: ReturnType | null = null + let unsubscribe: (() => void) | null = null + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId) + if (unsubscribe) unsubscribe() + abortSignal?.removeEventListener('abort', onAbort) + } + + const settle = (value: ToolPermissionEnvelope | null) => { + if (settled) return + settled = true + cleanup() + resolve(value) + } + + const onAbort = () => settle(null) + + unsubscribe = toolPermissionChannel.subscribe((event) => { + if (event.toolCallId !== toolCallId) return + if (!isToolPermissionDecision(event.decision)) return + logger.info('Resolved tool permission from pubsub', { + toolCallId, + decision: event.decision, + }) + settle(event) + }) + + timeoutId = setTimeout(() => settle(null), timeoutMs) + if (abortSignal?.aborted) { + settle(null) + return + } + abortSignal?.addEventListener('abort', onAbort, { once: true }) + + void getToolPermissionDecision(toolCallId).then((existing) => { + if (!existing) return + logger.info('Resolved tool permission from durable row', { + toolCallId, + decision: existing.decision, + }) + settle(existing) + }) + }) +} diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index a38e68a35d..1fd556a76b 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,6 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 3ea2b984ad..ebc2ce9f1b 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,6 +32,7 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 396615728c..eecd40cde8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -43,6 +43,8 @@ import { decodeJsonStringPrefix, extractEditContent, runStreamLoop, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' import { AbortReason, createEvent, hasAbortMarker } from '@/lib/copilot/request/session' import { RequestTraceV1Outcome, TraceCollector } from '@/lib/copilot/request/trace' @@ -104,6 +106,7 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, } } @@ -567,16 +570,30 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', } - await expect( - runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { - timeout: 1000, - }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') - expect( - context.errors.some((message) => - message.includes('Copilot backend stream ended before a terminal event') - ) - ).toBe(true) + const failure = await runStreamLoop( + 'https://example.com/mothership/stream', + {}, + context, + execContext, + { timeout: 1000 } + ).then( + () => undefined, + (error: unknown) => error + ) + + // The backend answered 200 and ran the leg, so the failure must not + // masquerade as an HTTP status the resume loop treats as transient. + expect(failure).toBeInstanceOf(StreamEndedWithoutTerminalError) + expect(failure).not.toHaveProperty('status') + expect(failure).toMatchObject({ path: '/mothership/stream' }) + expect((failure as Error).message).toBe(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + expect(context.errors).toEqual([STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE]) + }) + + it('tells the user what happened without promising that a retry helps', () => { + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).not.toMatch(/try again/i) + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).not.toMatch(/\/api\//) + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).toMatch(/saved/i) }) it('reclassifies as aborted when the body closes without terminal but the abort marker is set', async () => { @@ -607,11 +624,7 @@ describe('copilot go stream helpers', () => { expect(hasAbortMarker).toHaveBeenCalledWith(context.messageId) expect(context.wasAborted).toBe(true) - expect( - context.errors.some((message) => - message.includes('Copilot backend stream ended before a terminal event') - ) - ).toBe(false) + expect(context.errors).toEqual([]) }) it('invokes onAbortObserved with MarkerObservedAtBodyClose when reclassifying via the abort marker', async () => { @@ -675,7 +688,7 @@ describe('copilot go stream helpers', () => { timeout: 1000, onAbortObserved, }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') + ).rejects.toThrow(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) expect(onAbortObserved).not.toHaveBeenCalled() }) @@ -706,7 +719,7 @@ describe('copilot go stream helpers', () => { runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { timeout: 1000, }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') + ).rejects.toThrow(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) expect(context.wasAborted).toBe(false) }) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 880e6483aa..5ebe3be2c4 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -96,6 +96,31 @@ export class BillingLimitError extends Error { } } +/** + * Shown to the user when a leg ends early. It must not promise that retrying + * helps: the backend has already produced its outcome for this leg, and the + * turn's completed work is persisted by the finalizer. + */ +export const STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE = + 'The assistant stopped before finishing this turn. The work it already completed has been saved — send a message to continue from there.' + +/** + * The SSE body closed after a `200` response without a terminal event: the + * backend accepted the leg, ran it, and ended it on whatever outcome it reached + * in-band. Distinct from {@link CopilotBackendError} because there is no HTTP + * failure here — the leg is already claimed on the backend, so the outcome is + * deterministic and re-posting it cannot change anything. + */ +export class StreamEndedWithoutTerminalError extends Error { + readonly path: string + + constructor(path: string) { + super(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + this.name = 'StreamEndedWithoutTerminalError' + this.path = path + } +} + /** * Options for the shared stream processing loop. */ @@ -352,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context) + await prePersistClientExecutableToolCall(streamEvent, context, options) try { await options.onEvent?.(streamEvent) @@ -486,15 +511,14 @@ export async function runStreamLoop( endedOn = CopilotSseCloseReason.Aborted } else { const streamPath = new URL(fetchUrl).pathname - const message = `Copilot backend stream ended before a terminal event on ${streamPath}` - context.errors.push(message) + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) logger.error('Copilot backend stream ended before a terminal event', { path: streamPath, requestId: context.requestId, messageId: context.messageId, }) endedOn = CopilotSseCloseReason.ClosedNoTerminal - throw new CopilotBackendError(message, { status: 503 }) + throw new StreamEndedWithoutTerminalError(streamPath) } } } catch (error) { diff --git a/apps/sim/lib/copilot/request/handlers/complete.ts b/apps/sim/lib/copilot/request/handlers/complete.ts index c2ea991ea1..ff826aaadf 100644 --- a/apps/sim/lib/copilot/request/handlers/complete.ts +++ b/apps/sim/lib/copilot/request/handlers/complete.ts @@ -24,5 +24,6 @@ export const handleCompleteEvent: StreamHandler = (event, context) => { } } + context.completionStatus = event.payload.status context.streamComplete = true } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index da5991703e..fb59579146 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -6,11 +6,14 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TraceCollector } from '@/lib/copilot/request/trace' -const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() => ({ - isSimExecuted: vi.fn().mockReturnValue(true), - executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }), - ensureHandlersRegistered: vi.fn(), -})) +const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( + () => ({ + isSimExecuted: vi.fn().mockReturnValue(true), + executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }), + ensureHandlersRegistered: vi.fn(), + toolRequiresApproval: vi.fn().mockReturnValue(false), + }) +) const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = vi.hoisted(() => ({ @@ -29,6 +32,7 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ executeTool, ensureHandlersRegistered, getToolEntry: vi.fn().mockReturnValue(undefined), + toolRequiresApproval, })) vi.mock('@/lib/copilot/async-runs/repository', () => ({ @@ -56,6 +60,7 @@ vi.mock('@/lib/copilot/request/tools/client', () => ({ import { MothershipStreamV1AsyncToolRecordStatus, + MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1ResourceOp, MothershipStreamV1RunKind, @@ -66,7 +71,11 @@ import { MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { sseHandlers, subAgentHandlers } from '@/lib/copilot/request/handlers' +import { + prePersistClientExecutableToolCall, + sseHandlers, + subAgentHandlers, +} from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' describe('sse-handlers tool lifecycle', () => { @@ -100,6 +109,7 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], + toolPermissions: { enabled: false, autoAllowed: new Set() }, } execContext = { userId: 'user-1', @@ -107,6 +117,169 @@ describe('sse-handlers tool lifecycle', () => { } }) + it('pre-persists browser tools as pending for the desktop authorization claim', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + + await prePersistClientExecutableToolCall( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'browser-tool-1', + toolName: 'browser_list_tabs', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context + ) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'browser-tool-1', + toolName: 'browser_list_tabs', + args: {}, + status: MothershipStreamV1AsyncToolRecordStatus.pending, + }) + }) + + it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'deploy-1', + toolName: 'deploy_api', + arguments: { versionName: 'v2' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + // A sim-routed tool normally gets no durable row at all; a gated one must, + // because the decision is posted against it after a reload. + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'deploy-1', + toolName: 'deploy_api', + args: { versionName: 'v2' }, + status: MothershipStreamV1AsyncToolRecordStatus.pending, + }) + expect(event.payload.status).toBe('awaiting_approval') + }) + + it('clears a Go-stamped approval frame when the gate is off', async () => { + // Go stamps integration calls regardless of Sim's feature flag. Forwarding + // that stamp with nothing gating behind it would draw a card whose buttons + // answer into a disabled endpoint. + toolRequiresApproval.mockReturnValue(false) + context.runId = 'run-1' + context.toolPermissions = { enabled: false, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gmail-1', + toolName: 'gmail_read_v2', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'awaiting_approval', + }, + } as unknown as StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + expect((event.payload as { status?: string }).status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('clears a Go-stamped approval frame on an internal tool', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'internal-1', + toolName: 'deploy', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'awaiting_approval', + ui: { internal: true }, + }, + } as unknown as StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + // An internal tool draws no row at all, so it can never host a prompt. + expect((event.payload as { status?: string }).status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('leaves an already always-allowed tool ungated', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'deploy-2', + toolName: 'deploy_api', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + expect(event.payload.status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('keeps non-browser client tools in the established running state', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + + await prePersistClientExecutableToolCall( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'client-tool-1', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context + ) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'client-tool-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: MothershipStreamV1AsyncToolRecordStatus.running, + }) + }) + it('keeps only the latest post-tool assistant text for headless final content', async () => { await sseHandlers.text( { @@ -303,6 +476,59 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { + waitForToolCompletion.mockResolvedValueOnce({ + status: 'success', + data: { content: 'hello', totalLines: 1 }, + }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-user-local-read', + toolName: 'read', + arguments: { path: 'user-local/Project--mount/README.md' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(executeTool).not.toHaveBeenCalled() + }) + + it('keeps an ordinary static VFS read on the Sim executor', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-workspace-read', + toolName: 'read', + arguments: { path: 'WORKSPACE.md' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(executeTool).toHaveBeenCalled() + expect(waitForToolCompletion).not.toHaveBeenCalled() + }) + it('does not add hidden tool calls to content blocks', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } }) @@ -1175,6 +1401,24 @@ describe('sse-handlers tool lifecycle', () => { expect(context.streamComplete).toBe(false) }) + it('records the terminal completion status so a finished turn can outrank an in-band failure', async () => { + context.errors.push('subagent build failed') + + await sseHandlers.complete( + { + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) + expect(context.streamComplete).toBe(true) + expect(context.errors).toEqual(['subagent build failed']) + }) + it('routes resource events through an explicit main-lane handler', async () => { expect(() => sseHandlers.resource( diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 0e30aaee0b..880edae743 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -1,11 +1,17 @@ +import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncCompletionSignal, +} from '@/lib/copilot/async-runs/lifecycle' import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, type MothershipStreamV1ToolCallDescriptor, + MothershipStreamV1ToolExecutor, MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' @@ -21,6 +27,11 @@ import { import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + runGatedToolExecution, + TOOL_AWAITING_APPROVAL_STATUS, + toolCallNeedsApproval, +} from '@/lib/copilot/request/tools/permission' import type { ExecutionContext, OrchestratorOptions, @@ -30,6 +41,7 @@ import type { } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' @@ -142,14 +154,22 @@ function rebindResolvedIntegrationCall( /** * Upsert the durable `async_tool_calls` row before the authoritative tool-call - * SSE frame is forwarded to the client, so `/api/copilot/confirm` can never - * race ahead of the row that identifies the call. This is the sole - * persistence point for client-executable tools; gating mirrors the - * client-wait branch in `dispatchToolExecution`. + * SSE frame is forwarded to the client, so `/api/copilot/confirm` and + * `/api/copilot/tool-permission` can never race ahead of the row that + * identifies the call. This is the sole persistence point for client-executable + * tools; gating mirrors the client-wait branch in `dispatchToolExecution`. + * + * A tool awaiting user approval is also persisted here whatever its route, + * because the prompt has to outlive the page: the row is what a reloaded tab's + * decision posts against. + * + * Also stamps `awaiting_approval` onto the outgoing frame so the browser and + * the persisted content block both record that the call is gated. */ export async function prePersistClientExecutableToolCall( event: StreamEvent, - context: StreamingContext + context: StreamingContext, + options?: OrchestratorOptions ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -160,14 +180,44 @@ export async function prePersistClientExecutableToolCall( if (isPartial) return const ui = getToolCallUI(data) - if (!ui.clientExecutable) return - const catalogEntry = getToolEntry(data.toolName) const isInternal = ui.internal === true || catalogEntry?.internal === true + + // Go stamps this for resolved integration operations; Sim stamps it below for + // catalog-declared tools. Normalizing here means the dispatch path only ever + // has to read the frame. + // + // Resolved before the internal short-circuit on purpose: a stamp that + // survives to the client with nothing gating it behind renders a card whose + // buttons answer into the void. + const frameRequestsApproval = data.status === TOOL_AWAITING_APPROVAL_STATUS + const gated = + !isInternal && + toolCallNeedsApproval( + data.toolName, + context, + options ?? {}, + frameRequestsApproval, + data.arguments + ) + if (gated) { + data.status = TOOL_AWAITING_APPROVAL_STATUS + } else if (frameRequestsApproval) { + // Go asked for a prompt this surface will not hold — the feature is off, + // the tool is internal, or the user already allowed it for good. Clear the + // stamp so the row renders as an ordinary call. + data.status = undefined + } + if (isInternal) return - const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName) - if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient) return + if (!gated) { + if (!ui.clientExecutable) return + + const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName) + const userLocalVfsCall = isUserLocalVfsToolCall(data.toolName, data.arguments) + if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient && !userLocalVfsCall) return + } if (!context.runId) return @@ -176,7 +226,16 @@ export async function prePersistClientExecutableToolCall( toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, - status: MothershipStreamV1AsyncToolRecordStatus.running, + // Browser and terminal actions cross a second, native authorization + // boundary. Leave those rows pending until Electron atomically claims + // them — the authorize endpoint only hands over a pending call, so a row + // that arrives already running can never be executed natively. All other + // client tools retain the established "already dispatched" running state. + // A gated tool is likewise pending: nothing has been dispatched yet. + status: + gated || isBrowserToolName(data.toolName) || isTerminalToolName(data.toolName) + ? MothershipStreamV1AsyncToolRecordStatus.pending + : MothershipStreamV1AsyncToolRecordStatus.running, }).catch((err) => { logger.warn('Failed to pre-persist async tool row before forwarding call frame', { toolCallId: data.toolCallId, @@ -426,7 +485,9 @@ async function handleCallPhase( execContext, options, clientExecutable, - scope + scope, + isToolHiddenInUi(toolName) || ui.hidden === true, + data.status === TOOL_AWAITING_APPROVAL_STATUS ) } @@ -551,12 +612,14 @@ async function dispatchToolExecution( execContext: ExecutionContext, options: OrchestratorOptions, clientExecutable: boolean, - scope: ToolScope + scope: ToolScope, + hiddenInUi = false, + frameRequestsApproval = false ): Promise { const scopeLabel = scope === 'subagent' ? 'subagent ' : '' - const fireToolExecution = () => { - const pendingPromise = (async () => { + const fireToolExecution = (): Promise => { + return (async () => { return executeToolAndReport(toolCallId, context, execContext, options) })().catch((err) => { logger.error(`Parallel ${scopeLabel}tool execution failed`, { @@ -570,83 +633,111 @@ async function dispatchToolExecution( data: { error: 'Tool execution failed' }, } }) - registerPendingToolPromise(context, toolCallId, pendingPromise) } - if (options.interactive === false) { - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } + // Returns the promise instead of registering it, so the permission gate can + // wrap the whole thing in one pending promise that stays unsettled until the + // tool has actually run. Null means nothing was dispatched. + const startExecution = (): Promise | null => { + if (options.interactive === false) { + if (options.autoExecuteTools === false) return null + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() } + + if (clientExecutable) { + const delegateWorkflowRunToClient = isWorkflowToolName(toolName) + const userLocalVfsCall = isUserLocalVfsToolCall(toolName, args) + if (isSimExecuted(toolName) && !delegateWorkflowRunToClient && !userLocalVfsCall) { + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() + } + return waitForClientExecution() + } + + if (options.autoExecuteTools === false) return null + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() + } + + if (toolCallNeedsApproval(toolName, context, options, frameRequestsApproval, args)) { + registerPendingToolPromise( + context, + toolCallId, + runGatedToolExecution( + toolCall, + toolCallId, + toolName, + args, + clientExecutable + ? MothershipStreamV1ToolExecutor.client + : MothershipStreamV1ToolExecutor.sim, + context, + options, + startExecution, + !hiddenInUi + ) + ) return } - if (clientExecutable) { - const delegateWorkflowRunToClient = isWorkflowToolName(toolName) - if (isSimExecuted(toolName) && !delegateWorkflowRunToClient) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } else { - toolCall.status = 'executing' - const pendingPromise = withCopilotSpan( - TraceSpan.CopilotToolWaitForClientResult, - { - [TraceAttr.ToolName]: toolName, - [TraceAttr.ToolCallId]: toolCallId, - [TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS, - ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), - }, - async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) - span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) - if (completion) { - span.setAttribute(TraceAttr.ToolOutcome, completion.status) - } - handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } - await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) - return ( - completion ?? { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool completion missing', - data: { error: 'Tool completion missing' }, - } - ) - } - ).catch((err) => { - logger.error(`Client-executable ${scopeLabel}tool wait failed`, { + const pending = startExecution() + if (pending) registerPendingToolPromise(context, toolCallId, pending) + + /** + * A client-executed tool runs in the browser or desktop app; this side only + * waits for it to report back through `/api/copilot/confirm`. + */ + function waitForClientExecution(): Promise { + toolCall.status = 'executing' + return withCopilotSpan( + TraceSpan.CopilotToolWaitForClientResult, + { + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS, + ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), + }, + async (span) => { + const completion = await waitForToolCompletion( toolCallId, - toolName, - error: toError(err).message, - }) - return { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool wait failed', - data: { error: 'Tool wait failed' }, + options.timeout || STREAM_TIMEOUT_MS, + options.abortSignal + ) + span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) + if (completion) { + span.setAttribute(TraceAttr.ToolOutcome, completion.status) } + handleClientCompletion(toolCall, toolCallId, completion) + if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + await markAsyncToolDelivered(toolCallId).catch((err) => { + logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { + toolCallId, + toolName, + error: toError(err).message, + }) + }) + } + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) + return ( + completion ?? { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool completion missing', + data: { error: 'Tool completion missing' }, + } + ) + } + ).catch((err) => { + logger.error(`Client-executable ${scopeLabel}tool wait failed`, { + toolCallId, + toolName, + error: toError(err).message, }) - registerPendingToolPromise(context, toolCallId, pendingPromise) - } - return - } - - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } + return { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool wait failed', + data: { error: 'Tool wait failed' }, + } + }) } } diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 4ffc7bcdbf..68fb457f07 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run' @@ -16,6 +17,7 @@ describe('resume leg context isolate/merge contract', () => { usage: { prompt: 10, completion: 5 }, cost: { input: 1, output: 2, total: 3 }, errors: ['pre-existing'], + completionStatus: MothershipStreamV1CompletionStatus.complete, }) const leg = makeResumeLegContext(base) @@ -28,6 +30,7 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.errors).toEqual([]) expect(leg.streamComplete).toBe(false) expect(leg.awaitingAsyncContinuation).toBeUndefined() + expect(leg.completionStatus).toBeUndefined() // A leg's own errors array is a fresh array (not the shared one) so a leg's // retry rollback can't truncate a sibling's errors. @@ -49,6 +52,7 @@ describe('resume leg context isolate/merge contract', () => { leg.usage = { prompt: 100, completion: 50 } leg.cost = { input: 4, output: 5, total: 9 } leg.errors.push('leg-err') + leg.completionStatus = MothershipStreamV1CompletionStatus.complete mergeResumeLegOutputs(base, leg) @@ -58,6 +62,19 @@ describe('resume leg context isolate/merge contract', () => { expect(base.usage).toEqual({ prompt: 100, completion: 50 }) expect(base.cost).toEqual({ input: 4, output: 5, total: 9 }) expect(base.errors).toEqual(['pre', 'leg-err']) + expect(base.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) + }) + + it('leaves the turn unfinished when only child legs fold back', () => { + const base = createStreamingContext() + + // A child leg that folds with a terminal pause never carries the turn's + // terminal event, so it must not report the turn as finished. + const childLeg = makeResumeLegContext(base) + childLeg.errors.push('subagent failed') + mergeResumeLegOutputs(base, childLeg) + + expect(base.completionStatus).toBeUndefined() }) it('does not multiply pre-fanout content across many legs (N children + one join leg)', () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 89d63bfb61..859ae7a7d4 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -22,7 +22,8 @@ const { mockGetMothershipSourceEnvHeaders, mockPrepareExecutionContext, mockRunStreamLoop, - mockToolWatchdogTimeoutMs, + mockPendingToolWaitBudgetMs, + mockGetAutoAllowedTools, mockUpdateRunStatus, mockEnv, } = vi.hoisted(() => ({ @@ -32,7 +33,8 @@ const { mockGetMothershipSourceEnvHeaders: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), - mockToolWatchdogTimeoutMs: vi.fn(() => 60_000), + mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), + mockGetAutoAllowedTools: vi.fn(async () => new Set()), mockUpdateRunStatus: vi.fn(), mockEnv: { COPILOT_API_KEY: undefined as string | undefined, @@ -65,9 +67,24 @@ vi.mock('@/lib/copilot/request/go/stream', () => { } } + const STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE = + 'The assistant stopped before finishing this turn. The work it already completed has been saved — send a message to continue from there.' + + class StreamEndedWithoutTerminalError extends Error { + path: string + + constructor(path: string) { + super(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + this.name = 'StreamEndedWithoutTerminalError' + this.path = path + } + } + return { BillingLimitError, CopilotBackendError, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, runStreamLoop: mockRunStreamLoop, } }) @@ -84,6 +101,12 @@ vi.mock('@/lib/core/config/env', () => ({ isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + getAutoAllowedTools: mockGetAutoAllowedTools, + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -95,11 +118,18 @@ vi.mock('@/lib/copilot/request/tools/billing', () => ({ vi.mock('@/lib/copilot/request/tools/executor', () => ({ executeToolAndReport: vi.fn(), forceFailHungToolCall: mockForceFailHungToolCall, - toolWatchdogTimeoutMs: mockToolWatchdogTimeoutMs, + pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, })) -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotBackendError } from '@/lib/copilot/request/go/stream' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { + CopilotBackendError, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, +} from '@/lib/copilot/request/go/stream' import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' afterAll(resetEnvFlagsMock) @@ -108,12 +138,96 @@ describe('runCopilotLifecycle', () => { beforeEach(() => { vi.clearAllMocks() mockEnv.COPILOT_API_KEY = undefined - setEnvFlags({ isHosted: false }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) + setEnvFlags({ + isHosted: false, + isCopilotBillingAttributionV1Enabled: false, + isCopilotToolPermissionsEnabled: false, + }) + mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) }) + describe('tool permission feature flag', () => { + const runMothershipTurn = () => + runCopilotLifecycle( + { message: 'hello', messageId: 'stream-flag' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + goRoute: '/api/mothership', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + }, + } + ) + + it('stays entirely inert while the flag is off', async () => { + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(false) + // Never even reads the preference tables when disabled. + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + + it('arms the gate and loads the allow list once the flag is on', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowed.has('terminal_run')).toBe(true) + expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') + }) + + it('stays off for the workflow-scoped copilot even with the flag on', async () => { + // That panel has no permission card, so gating there would hang the turn + // on a prompt nothing draws. + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-flag-2' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + goRoute: '/api/copilot', + executionContext: { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + }, + } + ) + + expect(captured?.toolPermissions.enabled).toBe(false) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + }) + it('runs cancelled completion persistence when a stream throws after abort', async () => { const abortController = new AbortController() abortController.abort('stop') @@ -646,18 +760,16 @@ describe('runCopilotLifecycle', () => { } ) - // 2) First resume leg dies mid-stream like a transient provider error: - // it records an error AND throws a retryable 5xx. + // 2) First resume leg is refused before the backend takes it: it records an + // error AND throws a retryable 5xx, which releases the claim in Go. mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, _fetchOptions: RequestInit, context: StreamingContext ): Promise => { - context.errors.push( - 'Copilot backend stream ended before a terminal event on /api/tools/resume' - ) - throw new CopilotBackendError('backend stream ended before a terminal event', { + context.errors.push('Copilot backend error (503): service unavailable') + throw new CopilotBackendError('Copilot backend error (503): service unavailable', { status: 503, }) } @@ -699,7 +811,7 @@ describe('runCopilotLifecycle', () => { ) }) - it('marks resume legs willRetryOnStreamError except the final attempt', async () => { + it('does not promise Go a transparent stream-error retry it will not perform', async () => { const bodies: Record[] = [] const executionContext: ExecutionContext = { userId: 'user-1', @@ -740,8 +852,8 @@ describe('runCopilotLifecycle', () => { context: StreamingContext ): Promise => { bodies.push(JSON.parse(String(fetchOptions.body))) - context.errors.push('Copilot backend stream ended before a terminal event') - throw new CopilotBackendError('backend stream ended before a terminal event', { + context.errors.push('Copilot backend error (503): service unavailable') + throw new CopilotBackendError('Copilot backend error (503): service unavailable', { status: 503, }) } @@ -760,15 +872,175 @@ describe('runCopilotLifecycle', () => { } ) - // Initial + 3 resume attempts. + // Initial + 3 resume attempts: a 5xx is still transient, so the bounded + // retry budget is unchanged. expect(mockRunStreamLoop).toHaveBeenCalledTimes(4) - // Initial leg is never retried by this loop → no flag. - expect(bodies[0].willRetryOnStreamError).toBeUndefined() - // Resume attempts 0 and 1 will be retried on a stream error → flagged. - expect(bodies[1].willRetryOnStreamError).toBe(true) - expect(bodies[2].willRetryOnStreamError).toBe(true) - // Final attempt (2) is terminal → not flagged, so Go bills + surfaces it. - expect(bodies[3].willRetryOnStreamError).toBeUndefined() + // No leg claims a transparent retry. The flag made Go swallow the error tag + // that explains the failure, and a stream error is never retried now. + for (const body of bodies) { + expect(body.willRetryOnStreamError).toBeUndefined() + } + }) + + it('does not retry a resume leg the backend already claimed and ended early', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + // The resume leg is answered with 200 and then ends without a terminal + // event — the backend has claimed the checkpoint and reported its outcome, + // so re-posting it would only reproduce and re-bill the same failure. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'Moved the files and updated the workflow.' + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + throw new StreamEndedWithoutTerminalError('/api/tools/resume') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(result.success).toBe(false) + expect(result.cancelled).toBe(false) + expect(result.error).toBe(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + // Everything that streamed before the leg died is still the user's answer. + expect(result.content).toBe('Moved the files and updated the workflow.') + }) + + it('resolves the turn normally when the backend completes it after an in-band failure', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.errors.push('Subagent build failed: workflow validation error') + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'The build step failed; here is what I changed anyway.' + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(result).toEqual( + expect.objectContaining({ + success: true, + cancelled: false, + content: 'The build step failed; here is what I changed anyway.', + errors: undefined, + }) + ) + }) + + it('keeps the request failed when the backend terminates the turn as an error', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.errors.push('The provider is overloaded') + context.completionStatus = MothershipStreamV1CompletionStatus.error + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(result.success).toBe(false) + expect(result.errors).toEqual(['The provider is overloaded']) }) it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index cd862e8b08..40d100148a 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -17,16 +17,19 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, } from '@/lib/copilot/generated/billing-protocol-v1' import { + MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' import { BillingLimitError, CopilotBackendError, runStreamLoop, + StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' import { getToolCallTerminalData, @@ -37,7 +40,7 @@ import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' import { executeToolAndReport, forceFailHungToolCall, - toolWatchdogTimeoutMs, + pendingToolWaitBudgetMs, } from '@/lib/copilot/request/tools/executor' import type { TraceCollector } from '@/lib/copilot/request/trace' import { RequestTraceV1SpanStatus } from '@/lib/copilot/request/trace' @@ -53,7 +56,11 @@ import type { import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' -import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags' +import { + isCopilotBillingAttributionV1Enabled, + isCopilotToolPermissionsEnabled, + isHosted, +} from '@/lib/core/config/env-flags' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' const logger = createLogger('CopilotLifecycle') @@ -90,6 +97,29 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { billingAttribution?: BillingAttributionSnapshot } +/** + * Seed the per-request tool permission state. + * + * This is the feature's single on-switch: everything downstream — stamping the + * wire frame, holding the tool, drawing the card, persisting a decision — keys + * off `enabled`, so a disabled request behaves exactly as it did before the + * feature existed and never touches the preference tables. + * + * Beyond the flag, gating is limited to interactive mothership chats: that is + * the only surface with a UI that can answer a prompt, so enabling it anywhere + * else would hang the turn until the orchestration timeout with nothing to click. + */ +async function resolveToolPermissions( + options: CopilotLifecycleOptions +): Promise { + const enabled = + isCopilotToolPermissionsEnabled && + options.interactive !== false && + (options.goRoute ?? '').startsWith('/api/mothership') + if (!enabled) return { enabled: false, autoAllowed: new Set() } + return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } +} + export async function runCopilotLifecycle( requestPayload: Record, options: CopilotLifecycleOptions @@ -177,6 +207,7 @@ export async function runCopilotLifecycle( executionId: resolvedExecutionId, runId: resolvedRunId, messageId: payloadMsgId, + toolPermissions: await resolveToolPermissions(lifecycleOptions), ...(lifecycleOptions.trace ? { trace: lifecycleOptions.trace } : {}), }) let onCompleteStarted = false @@ -191,8 +222,17 @@ export async function runCopilotLifecycle( hostedBillingRequest ) + // The backend's terminal `complete` is the turn's verdict. A failure it + // reported in-band on the way there — a tool or a subagent that failed and + // was handed back to the model as data — belongs to a turn that still + // finished, so it must not turn the whole request into an error and discard + // the work the user watched succeed. + const backendFinishedTurn = + context.completionStatus === MothershipStreamV1CompletionStatus.complete + const succeeded = !context.wasAborted && (backendFinishedTurn || context.errors.length === 0) + const result: OrchestratorResult = { - success: context.errors.length === 0 && !context.wasAborted, + success: succeeded, // `cancelled` is an explicit discriminator so callers can tell // "user hit Stop" (persist partial assistant content through the // cancelled completion path) from "backend errored" (do clear the @@ -208,7 +248,7 @@ export async function runCopilotLifecycle( toolCalls: buildToolCallSummaries(context), chatId: context.chatId, requestId: context.requestId, - errors: context.errors.length ? context.errors : undefined, + errors: !succeeded && context.errors.length ? context.errors : undefined, usage: context.usage, cost: context.cost, } @@ -338,6 +378,9 @@ function mothershipRequestHeaders( // - errors: a leg's transient retryable error (rolled back inside // runResumeLegWithRetry) must not truncate a concurrent sibling's shared // error array by index; each leg collects its own and merges the survivors. +// - completionStatus: the backend's terminal verdict, set only on the leg that +// carries the turn to its end; a stale one from a sibling would speak for a +// turn that leg never finished. // When adding a per-leg field, update BOTH functions (and the contract test in // resume-leg-context.test.ts). Exported only for that test. export function makeResumeLegContext(base: StreamingContext): StreamingContext { @@ -350,6 +393,7 @@ export function makeResumeLegContext(base: StreamingContext): StreamingContext { usage: undefined, cost: undefined, errors: [], + completionStatus: undefined, } } @@ -364,6 +408,7 @@ export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingC if (leg.sawMainToolCall) context.sawMainToolCall = true if (leg.wasAborted) context.wasAborted = true if (leg.errors.length > 0) context.errors.push(...leg.errors) + if (leg.completionStatus) context.completionStatus = leg.completionStatus } async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { @@ -415,15 +460,13 @@ async function runResumeLegWithRetry( let attempt = 0 for (;;) { const errorsBeforeAttempt = leg.errors.length - const willRetryOnStreamError = attempt < MAX_RESUME_ATTEMPTS - 1 - const legBody = willRetryOnStreamError ? { ...body, willRetryOnStreamError: true } : body try { await runStreamLoop( url, { method: 'POST', headers: mothershipRequestHeaders(hostedBillingRequest), - body: JSON.stringify(legBody), + body: JSON.stringify(body), }, leg, execContext, @@ -680,30 +723,17 @@ async function runCheckpointLoop( // Snapshot recorded errors before this attempt. If the attempt fails with // a retryable resume error, we roll back to this baseline before retrying // so a subsequent successful retry doesn't inherit the failed attempt's - // errors (e.g. "backend stream ended before a terminal event") and get + // errors (e.g. the 5xx the backend refused the leg with) and get // mis-finalized as `error`. const errorsBeforeAttempt = context.errors.length - // A resume leg that is not the last allowed attempt will be retried below - // on a retryable stream error. Tell Go so it treats a mid-flight provider - // error as non-terminal for the UI and suppresses the user-facing error tag - // that a recovered retry should not show. Billing is still flushed for - // every leg; /api/billing/update-cost records cumulative cost as a - // monotonic top-up, so the partial retry leg and the recovered terminal leg - // reconcile to the maximum cumulative total. Recomputed per attempt because - // the same payload is reused across retries. - const willRetryOnStreamError = isResume && resumeAttempt < MAX_RESUME_ATTEMPTS - 1 - const legPayload = willRetryOnStreamError - ? { ...payload, willRetryOnStreamError: true } - : payload - try { await runStreamLoop( `${mothershipBaseURL}${route}`, { method: 'POST', headers: mothershipRequestHeaders(hostedBillingRequest), - body: JSON.stringify(legPayload), + body: JSON.stringify(payload), }, context, execContext, @@ -805,7 +835,7 @@ async function runCheckpointLoop( const waitBudgetMs = Array.from(context.pendingToolPromises.keys()).reduce( (max, toolCallId) => - Math.max(max, toolWatchdogTimeoutMs(context.toolCalls.get(toolCallId)?.name)), + Math.max(max, pendingToolWaitBudgetMs(context.toolCalls.get(toolCallId))), 0 ) + TOOL_WATCHDOG_RESUME_GRACE_MS const waitSpan = context.trace.startSpan('Wait for Tools', 'lifecycle.wait_tools', { @@ -1098,7 +1128,11 @@ function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): function cancelPendingTools(context: StreamingContext): void { for (const [, toolCall] of context.toolCalls) { - if (toolCall.status === 'pending' || toolCall.status === 'executing') { + if ( + toolCall.status === 'pending' || + toolCall.status === 'executing' || + toolCall.status === 'awaiting_approval' + ) { setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.cancelled, error: 'Stopped by user', @@ -1107,10 +1141,25 @@ function cancelPendingTools(context: StreamingContext): void { } } +/** + * Only a leg the backend never took is worth re-posting: a network failure with + * no response at all, or a 5xx it answered with — Go releases the checkpoint + * claim on those, expecting the retry. + * + * Once the backend answers `200` the checkpoint is claimed and the leg runs to + * whatever outcome it reaches, so a leg that ends early is reporting a result, + * not a transport fault. Re-posting it reproduces the same result and bills the + * leg again — which is why the resume payload no longer claims + * `willRetryOnStreamError`: promising Go a transparent retry makes it suppress + * the error tag that explains the failure, and nothing here would retry it. + */ function isRetryableStreamError(error: unknown): boolean { if (error instanceof DOMException && error.name === 'AbortError') { return false } + if (error instanceof StreamEndedWithoutTerminalError) { + return false + } if (error instanceof CopilotBackendError) { return error.status !== undefined && error.status >= 500 } diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 789f8165fd..656feb7266 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -2,7 +2,10 @@ import '@sim/testing/mocks/executor' import { describe, expect, it } from 'vitest' import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' -import { toolWatchdogTimeoutMs } from '@/lib/copilot/request/tools/executor' +import { + pendingToolWaitBudgetMs, + toolWatchdogTimeoutMs, +} from '@/lib/copilot/request/tools/executor' describe('toolWatchdogTimeoutMs', () => { it('gives request-scoped MCP tools the long-running watchdog', () => { @@ -13,3 +16,19 @@ describe('toolWatchdogTimeoutMs', () => { expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) }) }) + +describe('pendingToolWaitBudgetMs', () => { + it('waits on a person for as long as the whole turn allows', () => { + // The 60s default would force-fail a permission prompt while the user was + // still reading it, resuming Go before they ever answered. + expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'awaiting_approval' })).toBe( + TOOL_WATCHDOG_LONG_RUNNING_MS + ) + }) + + it('falls back to the tool\u2019s own watchdog once it is actually executing', () => { + expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'executing' })).toBe( + TOOL_WATCHDOG_DEFAULT_MS + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index bdb90c3cf4..fd59ba2fa8 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -230,6 +230,21 @@ export function toolWatchdogTimeoutMs(toolName: string | undefined): number { : TOOL_WATCHDOG_DEFAULT_MS } +/** + * How long the resume gate may wait on one pending tool call. + * + * A call sitting on a permission prompt is waiting on a person, not on the + * executor, so the tool's own watchdog is the wrong bound — the 60s default + * would force-fail the prompt while the user was still reading it. Such a call + * gets the long-running budget, which matches the gate's own wait timeout. + */ +export function pendingToolWaitBudgetMs( + toolCall: Pick | undefined +): number { + if (toolCall?.status === 'awaiting_approval') return TOOL_WATCHDOG_LONG_RUNNING_MS + return toolWatchdogTimeoutMs(toolCall?.name) +} + class ToolExecutionTimeoutError extends Error { constructor(toolName: string, timeoutMs: number) { super( diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 139757524c..cbad14c58e 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -93,13 +93,11 @@ describe('serializeOutputForFile (json / txt / md)', () => { }) describe('normalizeOutputWorkspaceFileName', () => { - it('derives the leaf file name from workflow alias output paths', () => { - expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe( - 'changelog.md' + it('derives the leaf file name from nested, percent-encoded output paths', () => { + expect(normalizeOutputWorkspaceFileName('files/My%20Folder/notes.md')).toBe('notes.md') + expect(normalizeOutputWorkspaceFileName('files/My%20Folder/phase%201/implementation.md')).toBe( + 'implementation.md' ) - expect( - normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md') - ).toBe('implementation.md') }) it('still handles normal workspace file output paths', () => { diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts new file mode 100644 index 0000000000..78096cdc61 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -0,0 +1,358 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TraceCollector } from '@/lib/copilot/request/trace' + +const { toolRequiresApproval, waitForToolPermissionDecision } = vi.hoisted(() => ({ + toolRequiresApproval: vi.fn().mockReturnValue(true), + waitForToolPermissionDecision: vi.fn(), +})) + +vi.mock('@/lib/copilot/tool-executor', () => ({ + toolRequiresApproval, + isSimExecuted: vi.fn().mockReturnValue(true), + getToolEntry: vi.fn().mockReturnValue(undefined), + executeTool: vi.fn(), + ensureHandlersRegistered: vi.fn(), +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, waitForToolPermissionDecision } +}) + +import { MothershipStreamV1ToolExecutor } from '@/lib/copilot/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { + runGatedToolExecution, + toolCallNeedsApproval, +} from '@/lib/copilot/request/tools/permission' +import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' + +function makeContext() { + const context = createStreamingContext({ runId: 'run-1' }) + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.trace = new TraceCollector() + return context +} + +function makeToolCall(): ToolCallState { + return { + id: 'call-1', + name: 'terminal', + status: 'pending', + params: { operation: 'run', args: { command: 'ls' } }, + } +} + +function gate( + context: ReturnType, + toolCall: ToolCallState, + execute: () => Promise<{ status: string }> | null, + events: StreamEvent[] +) { + return runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + { + onEvent: (event) => { + events.push(event) + }, + }, + execute as () => Promise + ) +} + +describe('toolCallNeedsApproval', () => { + const runCall = { operation: 'run', args: { command: 'ls' } } + + it('gates a tool the catalog marks as requiring approval', () => { + expect(toolCallNeedsApproval('terminal', makeContext(), {}, false, runCall)).toBe(true) + }) + + it('does not gate terminal operations that only look at the screen', () => { + // The catalog flag is per tool, but a card for every `read` would train + // the user to click through the ones that matter. + const context = makeContext() + for (const operation of ['read', 'list', 'cwd', 'panes']) { + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation })).toBe(false) + } + }) + + it('does not gate a tool the user already always-allowed', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('terminal') + expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) + }) + + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { + expect( + toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) + ).toBe(false) + }) + + it('does not gate when the surface has the gate turned off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) + }) + + it('gates a resolved integration operation off the frame Go stamped', () => { + // gmail_read_v2 is request-local: it is not in the catalog at all, so the + // only thing marking it is the awaiting_approval status on the frame. + toolRequiresApproval.mockReturnValue(false) + expect(toolCallNeedsApproval('gmail_read_v2', makeContext(), {}, true)).toBe(true) + }) + + it('still honors always-allow for a frame-stamped integration operation', () => { + toolRequiresApproval.mockReturnValue(false) + const context = makeContext() + context.toolPermissions.autoAllowed.add('gmail_read_v2') + expect(toolCallNeedsApproval('gmail_read_v2', context, {}, true)).toBe(false) + }) +}) + +describe('gated tools are askable', () => { + it('every tool requiring approval renders a row the user can answer on', async () => { + // A gated tool with no visible row cannot be consented to. The runtime + // refuses to run such a call, so shipping one would silently disable the + // tool; this keeps the catalog honest instead. + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + const { getHiddenToolNames } = await vi.importActual< + typeof import('@/lib/copilot/tools/client/hidden-tools') + >('@/lib/copilot/tools/client/hidden-tools') + + const hidden = getHiddenToolNames() + const unaskable = Object.entries(TOOL_CATALOG) + .filter(([name, entry]) => entry.requiresApproval && (entry.internal || hidden.has(name))) + .map(([name]) => name) + + expect(unaskable).toEqual([]) + }) + + it('never marks a go-routed tool as requiring approval', async () => { + // Go executes these in-process and streams back a result; the call never + // reaches Sim's dispatch, so this gate has nothing to hold. Marking one + // would draw a permission card for work that already ran. + // + // call_integration_tool is the one exception, and only nominally: the + // catalog describes the gateway as go/sync, but at runtime Go resolves the + // operation and hands it to Sim on a checkpoint, stamping the frame with + // awaiting_approval. It is gated on that stamp, under the resolved + // operation's name, never under this one. + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + + const ungatable = Object.entries(TOOL_CATALOG) + .filter( + ([name, entry]) => + entry.requiresApproval && entry.route === 'go' && name !== 'call_integration_tool' + ) + .map(([name]) => name) + + expect(ungatable).toEqual([]) + }) + + it('gates the tools we intend to gate', async () => { + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + + expect( + Object.entries(TOOL_CATALOG) + .filter(([, entry]) => entry.requiresApproval) + .map(([name]) => name) + .sort() + ).toEqual([ + 'call_integration_tool', + 'delete_workspace_mcp_server', + 'deploy_api', + 'deploy_chat', + 'deploy_mcp', + 'function_execute', + 'promote_to_live', + 'redeploy', + 'run_code', + 'run_workflow', + 'run_workflow_until_block', + 'terminal', + ]) + }) +}) + +describe('runGatedToolExecution', () => { + beforeEach(() => { + vi.clearAllMocks() + toolRequiresApproval.mockReturnValue(true) + }) + + it('does not execute the tool until the user has answered', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + + let answer: (value: unknown) => void = () => {} + waitForToolPermissionDecision.mockReturnValue( + new Promise((resolve) => { + answer = resolve + }) + ) + + const pending = gate(context, toolCall, execute, []) + + // The decision is outstanding: nothing ran, and the promise the resume gate + // waits on has not settled. + await Promise.resolve() + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('awaiting_approval') + + answer({ toolCallId: 'call-1', decision: 'allow' }) + await pending + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('stays unsettled across the execution so the resume cannot run early', async () => { + const context = makeContext() + const toolCall = makeToolCall() + let finishExecution: (value: { status: string }) => void = () => {} + const execute = vi.fn().mockReturnValue( + new Promise((resolve) => { + finishExecution = resolve + }) + ) + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + const pending = gate(context, toolCall, execute, []) + let settled = false + void pending.then(() => { + settled = true + }) + + await vi.waitFor(() => expect(execute).toHaveBeenCalled()) + expect(settled).toBe(false) + + finishExecution({ status: 'success' }) + await pending + expect(settled).toBe(true) + }) + + it('reports a skip as a successful skipped result so the model adapts', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + const events: StreamEvent[] = [] + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'skip' }) + + const signal = await gate(context, toolCall, execute, events) + + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('skipped') + // Success keeps Go's consecutive-tool-failure breaker from counting a + // deliberate user decision as a malfunction. + expect(signal.status).toBe('success') + expect(toolCall.result).toMatchObject({ success: true }) + + const result = events.find((event) => event.payload?.phase === 'result') + expect(result?.payload).toMatchObject({ status: 'skipped', success: true }) + expect(result?.payload?.output).toMatchObject({ skipped: true, reason: 'user_declined' }) + }) + + it('treats allow-for-this-chat as an allow that also stops re-prompting', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) + }) + + it('does not suppress later prompts for a one-off allow', async () => { + const context = makeContext() + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(false) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) + + it('remembers always-allow for the rest of the turn', async () => { + const context = makeContext() + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'always_allow', + }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(false) + }) + + it('cancels rather than runs when the prompt is never answered', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + waitForToolPermissionDecision.mockResolvedValue(null) + + const signal = await gate(context, toolCall, execute, []) + + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('cancelled') + expect(signal.status).toBe('error') + }) + + it('refuses to run a gated tool whose row is hidden, rather than hanging the turn', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + const signal = await runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + {}, + execute as () => Promise, + false + ) + + expect(waitForToolPermissionDecision).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('skipped') + expect(signal.status).toBe('success') + expect(toolCall.result?.output).toMatchObject({ reason: 'no_prompt_surface' }) + }) + + it('turns the row back into a running one once allowed', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const events: StreamEvent[] = [] + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), events) + + const call = events.find((event) => event.payload?.phase === 'call') + expect(call?.payload).toMatchObject({ status: 'executing', toolCallId: 'call-1' }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts new file mode 100644 index 0000000000..a9829c8c15 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -0,0 +1,312 @@ +import { createLogger } from '@sim/logger' +import { TERMINAL_TOOL_NAME } from '@sim/terminal-protocol' +import { getErrorMessage } from '@sim/utils/errors' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + type MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { + decisionAllowsExecution, + decisionSuppressesFuturePrompts, + waitForToolPermissionDecision, +} from '@/lib/copilot/persistence/tool-permission' +import { withCopilotSpan } from '@/lib/copilot/request/otel' +import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' +import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' +import type { + OrchestratorOptions, + StreamingContext, + ToolCallState, +} from '@/lib/copilot/request/types' +import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor' + +const logger = createLogger('CopilotToolPermissionGate') + +/** + * Whether a `terminal` call is one worth stopping for. + * + * The catalog's approval flag is per tool, but the terminal tool covers both + * running commands and merely looking at the screen. Only running one is + * consequential; gating `read` or `list` would put a card in front of the user + * every time the agent glanced at a terminal, which trains them to click + * through the ones that matter. + */ +function terminalOperationNeedsApproval(args: Record | undefined): boolean { + return args?.operation === 'run' +} + +/** + * A human can take as long as they like to answer, so the wait is bounded only + * by the overall orchestration budget rather than a per-tool watchdog. + */ +const PERMISSION_WAIT_TIMEOUT_MS = ORCHESTRATION_TIMEOUT_MS + +export const TOOL_AWAITING_APPROVAL_STATUS = MothershipStreamV1ToolStatus.awaiting_approval + +/** + * Whether this call must be held for an explicit user decision. + * + * Headless and non-interactive runs (scheduled tasks, one-shot execute) are + * never gated: nobody is there to answer, and blocking them would hang the run + * until the orchestration timeout. + */ +export function toolCallNeedsApproval( + toolName: string, + context: StreamingContext, + options: OrchestratorOptions, + /** + * True when the incoming call frame already carries `awaiting_approval`. + * Go stamps this for resolved integration operations, whose definitions are + * request-local and so never appear in the generated catalog. + */ + frameRequestsApproval = false, + /** The call's arguments, for a tool whose gate depends on what it is doing. */ + args?: Record +): boolean { + if (!context.toolPermissions.enabled) return false + if (options.interactive === false) return false + + if (!frameRequestsApproval) { + if (!toolRequiresApproval(toolName)) return false + if (toolName === TERMINAL_TOOL_NAME && !terminalOperationNeedsApproval(args)) return false + // A go-routed tool executes inside mothership and never reaches Sim's + // dispatch, so there is nothing here to hold. Stamping the frame anyway + // would draw a card for work that already happened, with no waiter behind + // it. Go asks for those gates explicitly via the frame instead. + if (getToolEntry(toolName)?.route === 'go') { + logger.error('Tool declares requiresApproval but runs in Go, where Sim cannot gate it', { + toolName, + }) + return false + } + } + + return !context.toolPermissions.autoAllowed.has(toolName) +} + +function skipOutput(toolName: string) { + return { + skipped: true, + reason: 'user_declined', + message: `The user declined to run ${toolName}. Nothing was executed.`, + } +} + +function noPromptOutput(toolName: string) { + return { + skipped: true, + reason: 'no_prompt_surface', + message: `${toolName} requires the user's permission, but it has no visible row to ask on, so it was not run.`, + } +} + +/** + * Tell the client how a gated call ended without executing. + * + * Go's own tool_result for this call is suppressed (`markToolResultSeen`), so + * this synthetic frame is the only thing that moves the row out of its + * awaiting-approval state in the UI. + */ +async function emitGateResult( + toolCallId: string, + toolName: string, + executor: MothershipStreamV1ToolExecutor, + outcome: MothershipStreamV1ToolOutcome, + output: unknown, + options: OrchestratorOptions, + error?: string +): Promise { + try { + await options.onEvent?.({ + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: outcome === MothershipStreamV1ToolOutcome.skipped, + output, + status: outcome, + ...(error ? { error } : {}), + }, + }) + } catch (err) { + logger.warn('Failed to emit permission gate tool result', { + toolCallId, + toolName, + error: getErrorMessage(err), + }) + } +} + +/** + * Re-emit the call frame as `executing` once the user allows the tool. + * + * Without this the card would keep offering Allow/Skip until the tool finished, + * because no further call frame arrives from Go after a decision. + */ +async function emitApprovedCall( + toolCallId: string, + toolName: string, + executor: MothershipStreamV1ToolExecutor, + args: Record | undefined, + options: OrchestratorOptions +): Promise { + try { + await options.onEvent?.({ + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: MothershipStreamV1ToolStatus.executing, + ...(args ? { arguments: args } : {}), + }, + }) + } catch (err) { + logger.warn('Failed to emit approved tool call frame', { + toolCallId, + toolName, + error: getErrorMessage(err), + }) + } +} + +/** + * Hold a gated tool call until the user answers, then hand off to `execute`. + * + * The returned promise is what gets registered as the call's pending promise, + * and it deliberately stays unsettled across both the wait AND the subsequent + * execution. That is what keeps the mothership resume blocked: the checkpoint + * loop waits on the pending promises before it POSTs `/api/tools/resume`, so + * as long as this has not settled, no tool result reaches Go. + */ +export function runGatedToolExecution( + toolCall: ToolCallState, + toolCallId: string, + toolName: string, + args: Record | undefined, + executor: MothershipStreamV1ToolExecutor, + context: StreamingContext, + options: OrchestratorOptions, + execute: () => Promise | null, + /** + * False when the row this call would render on is hidden. Consent cannot be + * asked for, so the call is refused instead of silently run or left to hang. + */ + canPrompt = true +): Promise { + toolCall.status = 'awaiting_approval' + + return withCopilotSpan( + TraceSpan.CopilotToolWaitForPermission, + { + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), + }, + async (span): Promise => { + if (!canPrompt) { + // Fail closed. Running it would defeat the point of the flag, and + // waiting would hang the turn on a prompt that is never drawn. + logger.error('Gated tool has no visible row to prompt on; refusing to run it', { + toolCallId, + toolName, + }) + const output = noPromptOutput(toolName) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.skipped, + output, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.skipped, + output, + options + ) + return { status: MothershipStreamV1ToolOutcome.success, message: output.message } + } + + const decision = await waitForToolPermissionDecision( + toolCallId, + PERMISSION_WAIT_TIMEOUT_MS, + options.abortSignal + ) + + if (!decision) { + // Timed out or the turn was stopped. Fail the call rather than running + // it: an unanswered prompt is not consent. + span.setAttribute(TraceAttr.ToolOutcome, MothershipStreamV1ToolOutcome.cancelled) + const error = 'Timed out waiting for the user to approve this tool.' + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + output: { error }, + error, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.cancelled, + { error }, + options, + error + ) + return { status: MothershipStreamV1ToolOutcome.error, message: error } + } + + span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) + + if (decisionSuppressesFuturePrompts(decision.decision)) { + // Same-turn effect: a second call to this tool later in the turn must + // not re-prompt. The durable write (chat row or user settings) happens + // in the endpoint. + context.toolPermissions.autoAllowed.add(toolName) + } + + if (!decisionAllowsExecution(decision.decision)) { + const output = skipOutput(toolName) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.skipped, + output, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.skipped, + output, + options + ) + // Reported as success so a declined tool is a decision the model reads + // and adapts to, not a failure that counts toward Go's tool-failure + // breaker. + return { status: MothershipStreamV1ToolOutcome.success, message: output.message } + } + + await emitApprovedCall(toolCallId, toolName, executor, args, options) + + const execution = execute() + if (!execution) { + return { status: MothershipStreamV1ToolOutcome.success } + } + return execution + } + ) +} diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 9316dff907..bf4908896d 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -1,5 +1,8 @@ import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { + type MothershipStreamV1CompletionStatus, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' import type { RequestTraceV1Span } from '@/lib/copilot/generated/request-trace-v1' import type { StreamEvent } from '@/lib/copilot/request/session' import type { TraceCollector } from '@/lib/copilot/request/trace' @@ -7,7 +10,7 @@ import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/to export type { StreamEvent } -export type LocalToolCallStatus = 'pending' | 'executing' +export type LocalToolCallStatus = 'pending' | 'executing' | 'awaiting_approval' export type ToolCallStatus = LocalToolCallStatus | MothershipStreamV1ToolOutcome const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set( @@ -149,6 +152,13 @@ export interface StreamingContext { streamComplete: boolean wasAborted: boolean errors: string[] + /** + * Terminal status carried by the backend's `complete` event. Set only once + * the backend declares the turn finished, so it can outrank in-band failures + * recorded on the way there (a tool or subagent that failed and was handed + * back to the model as data). + */ + completionStatus?: MothershipStreamV1CompletionStatus usage?: { prompt: number; completion: number } cost?: { input: number; output: number; total: number } /** @@ -161,6 +171,16 @@ export interface StreamingContext { activeFileIntents: Map trace: TraceCollector subAgentTraceSpans?: Map + /** + * Per-request state for the tool permission gate. `autoAllowed` starts from + * the user's saved always-allow list and is added to in place when they pick + * "always allow" mid-turn, so a later call to the same tool in this same turn + * is not prompted a second time. + */ + toolPermissions: { + enabled: boolean + autoAllowed: Set + } } interface FileAttachment { diff --git a/apps/sim/lib/copilot/resources/availability.ts b/apps/sim/lib/copilot/resources/availability.ts new file mode 100644 index 0000000000..442ce06e2e --- /dev/null +++ b/apps/sim/lib/copilot/resources/availability.ts @@ -0,0 +1,20 @@ +import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' +import { isDesktopOnlyResource, type MothershipResource } from '@/lib/copilot/resources/types' +import { isTerminalAvailable } from '@/lib/terminal/transport' + +/** + * Whether this client can show the resource's panel at all. + * + * The browser and terminal panels are stored with the chat like any other + * resource, so the tab is still there when the chat is reopened. But they are + * windows onto something the desktop app owns — an embedded browser view, a + * pty — and opening that same chat in the web app would otherwise restore a + * tab that leads to an error. Such resources stay in the chat's stored + * resources either way; this only decides whether to put them on screen. + */ +export function canDisplayResource(resource: MothershipResource): boolean { + if (!isDesktopOnlyResource(resource)) return true + if (resource.type === 'browser') return isBrowserAgentAvailable() + if (resource.type === 'terminal') return isTerminalAvailable() + return false +} diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index ff97f23cc6..150c8b217b 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -181,57 +181,33 @@ describe('extractResourcesFromToolResult', () => { }) describe('extractDeletedResourcesFromToolResult', () => { - it('extracts every successfully deleted workflow from the batch result', () => { - const resources = extractDeletedResourcesFromToolResult( - 'delete_workflow', - { workflowIds: ['wf-1', 'wf-2', 'wf-failed'] }, - { - deleted: [ - { workflowId: 'wf-1', name: 'First workflow' }, - { workflowId: 'wf-2', name: 'Second workflow' }, - ], - failed: ['wf-failed'], - } - ) - - expect(resources).toEqual([ - { type: 'workflow', id: 'wf-1', title: 'First workflow' }, - { type: 'workflow', id: 'wf-2', title: 'Second workflow' }, - ]) - }) - - it('extracts deleted files from delete_file result data', () => { + it('extracts every kind rm deleted and skips the ones that failed', () => { expect( extractDeletedResourcesFromToolResult( - 'delete_file', - { paths: ['files/one.md', 'files/two.md'] }, + 'rm', + { paths: ['files/Reports/Old%20Report.pdf'] }, { - success: true, - data: { - deleted: [ - { id: 'file-1', name: 'one.md' }, - { id: 'file-2', name: 'two.md' }, - ], - failed: [], - }, + results: [ + { from: 'files/Reports/Old%20Report.pdf', kind: 'file', id: 'file-1' }, + { from: 'files/Archive', kind: 'file_folder', id: 'folder-1' }, + { from: 'workflows/Lead%20Router', kind: 'workflow', id: 'wf-1' }, + { from: 'workflows/Old%20Projects', kind: 'workflow_folder', id: 'wfolder-1' }, + { from: 'tables/Leads', kind: 'table', id: 'tbl-1' }, + { from: 'knowledgebases/support-docs', kind: 'knowledge_base', id: 'kb-1' }, + { from: 'files/missing.md', kind: 'file', error: 'Not found: files/missing.md' }, + ], } ) ).toEqual([ - { type: 'file', id: 'file-1', title: 'one.md' }, - { type: 'file', id: 'file-2', title: 'two.md' }, + { type: 'file', id: 'file-1', title: 'Old Report.pdf' }, + { type: 'filefolder', id: 'folder-1', title: 'Archive' }, + { type: 'workflow', id: 'wf-1', title: 'Lead Router' }, + { type: 'folder', id: 'wfolder-1', title: 'Old Projects' }, + { type: 'table', id: 'tbl-1', title: 'Leads' }, + { type: 'knowledgebase', id: 'kb-1', title: 'support-docs' }, ]) }) - it('extracts deleted file folders from delete_file_folder result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'delete_file_folder', - { paths: ['files/Archive'] }, - { success: true, data: { folders: 1, files: 2, deletedFolderIds: ['folder-1'] } } - ) - ).toEqual([{ type: 'filefolder', id: 'folder-1', title: 'Folder' }]) - }) - it('extracts only successfully deleted tables from user_table result data', () => { expect( extractDeletedResourcesFromToolResult( @@ -255,16 +231,6 @@ describe('extractDeletedResourcesFromToolResult', () => { ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) }) - it('extracts deleted workflow folders from manage_folder delete results', () => { - expect( - extractDeletedResourcesFromToolResult( - 'manage_folder', - { operation: 'delete', folderId: 'folder-1' }, - { deleted: ['folder-1'], failed: [] } - ) - ).toEqual([{ type: 'folder', id: 'folder-1', title: 'Folder' }]) - }) - it('removes scheduledtask resources on manage_scheduled_task delete', () => { const resources = extractDeletedResourcesFromToolResult( 'manage_scheduled_task', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 7874718ef5..ddd7b4f52d 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,9 +1,6 @@ import { CreateFile, CreateWorkflow, - DeleteFile, - DeleteFileFolder, - DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, Ffmpeg, @@ -13,8 +10,8 @@ import { GenerateVideo, Knowledge, KnowledgeBase, - ManageFolder, ManageScheduledTask, + Rm, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -243,14 +240,24 @@ export function extractResourcesFromToolResult( } const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { - [DeleteWorkflow.id]: 'workflow', - [DeleteFile.id]: 'file', - [DeleteFileFolder.id]: 'filefolder', [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', - [ManageFolder.id]: 'folder', [ManageScheduledTask.id]: 'scheduledtask', + // rm spans categories, so unlike every other entry its resource type comes + // from each outcome's kind rather than from this map. The entry exists so + // hasDeleteCapability(rm) holds; the rm case below ignores this value. + [Rm.id]: 'file', +} + +/** rm reports what it deleted per path; map that kind to the type the UI tracks. */ +const RM_KIND_RESOURCE_TYPE: Record = { + file: 'file', + file_folder: 'filefolder', + workflow: 'workflow', + workflow_folder: 'folder', + table: 'table', + knowledge_base: 'knowledgebase', } export function hasDeleteCapability(toolName: string): boolean { @@ -276,57 +283,20 @@ export function extractDeletedResourcesFromToolResult( const operation = (args.operation ?? params?.operation) as string | undefined switch (toolName) { - case DeleteWorkflow.id: { - const deleted = Array.isArray(result.deleted) ? result.deleted : [] - const resources = deleted.flatMap((entry): ChatResource[] => { - const deletedWorkflow = asRecord(entry) - const workflowId = deletedWorkflow.workflowId - if (typeof workflowId !== 'string' || !workflowId) return [] - return [ - { - type: resourceType, - id: workflowId, - title: typeof deletedWorkflow.name === 'string' ? deletedWorkflow.name : 'Workflow', - }, - ] - }) - if (resources.length > 0) return resources - - // Backward compatibility for historical single-workflow tool results. - const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted === true) { - return [ - { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, - ] - } - return [] - } - - case DeleteFile.id: { - const deleted = Array.isArray(data.deleted) ? data.deleted : [] - return deleted.flatMap((entry): ChatResource[] => { - const deletedFile = asRecord(entry) - const fileId = deletedFile.id - if (typeof fileId !== 'string' || !fileId) return [] - return [ - { - type: resourceType, - id: fileId, - title: typeof deletedFile.name === 'string' ? deletedFile.name : 'File', - }, - ] + case Rm.id: { + const outcomes = Array.isArray(result.results) ? result.results : [] + return outcomes.flatMap((entry): ChatResource[] => { + const outcome = asRecord(entry) + if (outcome.error) return [] + const { id, kind, from } = outcome + if (typeof id !== 'string' || !id || typeof kind !== 'string') return [] + const type = RM_KIND_RESOURCE_TYPE[kind] + if (!type) return [] + const path = typeof from === 'string' ? from : '' + const leaf = path.split('/').filter(Boolean).pop() ?? '' + return [{ type, id, title: leaf ? decodeURIComponent(leaf) : 'Deleted resource' }] }) } - - case DeleteFileFolder.id: { - const deletedFolderIds = Array.isArray(data.deletedFolderIds) - ? data.deletedFolderIds.filter( - (id): id is string => typeof id === 'string' && id.length > 0 - ) - : [] - return deletedFolderIds.map((id) => ({ type: resourceType, id, title: 'Folder' })) - } - case WorkspaceFile.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) @@ -378,14 +348,6 @@ export function extractDeletedResourcesFromToolResult( return [] } - case ManageFolder.id: { - if (operation !== 'delete') return [] - const deletedIds = Array.isArray(result.deleted) ? (result.deleted as unknown[]) : [] - return deletedIds.flatMap((id): ChatResource[] => - typeof id === 'string' && id ? [{ type: resourceType, id, title: 'Folder' }] : [] - ) - } - case ManageScheduledTask.id: { if (operation !== 'delete') return [] const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts new file mode 100644 index 0000000000..928198d774 --- /dev/null +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { addCopilotChatResourceBodySchema } from '@/lib/api/contracts/copilot' +import { + BROWSER_SESSION_RESOURCE_ID, + isDesktopOnlyResource, + isEphemeralResource, + type MothershipResource, + MothershipResourceType, + PERSISTED_RESOURCE_TYPES, + TERMINAL_SESSION_RESOURCE_ID, +} from './types' + +function resource(overrides: Partial = {}): MothershipResource { + return { type: 'file', id: 'r1', title: 'Thing', ...overrides } +} + +describe('isEphemeralResource', () => { + it('persists the desktop panels so their tabs survive reopening the chat', () => { + expect( + isEphemeralResource( + resource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' }) + ) + ).toBe(false) + expect( + isEphemeralResource( + resource({ type: 'terminal', id: TERMINAL_SESSION_RESOURCE_ID, title: 'Terminal' }) + ) + ).toBe(false) + }) + + it('keeps synthetic panels client-only', () => { + expect(isEphemeralResource(resource({ type: 'generic', id: 'results' }))).toBe(true) + expect(isEphemeralResource(resource({ type: 'file', id: 'streaming-file' }))).toBe(true) + }) + + it('treats an unrecognized type as ephemeral rather than trying a doomed write', () => { + expect(isEphemeralResource(resource({ type: 'nonsense' as MothershipResourceType }))).toBe(true) + }) +}) + +describe('isDesktopOnlyResource', () => { + it('marks the panels that need the desktop bridge', () => { + expect(isDesktopOnlyResource(resource({ type: 'browser' }))).toBe(true) + expect(isDesktopOnlyResource(resource({ type: 'terminal' }))).toBe(true) + }) + + it('leaves ordinary workspace resources alone', () => { + expect(isDesktopOnlyResource(resource({ type: 'workflow' }))).toBe(false) + expect(isDesktopOnlyResource(resource({ type: 'file' }))).toBe(false) + }) +}) + +/** + * The bug this guards against: the client decided what to persist from one + * list and the API validated against another, so `browser`, `task` and + * `integration` were openable but unsaveable — every write 400'd into a + * warning log and the tabs were gone on reload. Both sides now come from + * `PERSISTED_RESOURCE_TYPES`; these fail if anything reintroduces a second + * list. + */ +describe('client and server agree on what can be persisted', () => { + it.each(PERSISTED_RESOURCE_TYPES)('the API accepts a %s resource', (type) => { + const parsed = addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type, id: 'r1', title: 'Thing' }, + }) + expect(parsed.success).toBe(true) + }) + + it('the API rejects every type the client refuses to send', () => { + const ephemeral = Object.values(MothershipResourceType).filter((type) => + isEphemeralResource(resource({ type })) + ) + expect(ephemeral.length).toBeGreaterThan(0) + for (const type of ephemeral) { + const parsed = addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type, id: 'r1', title: 'Thing' }, + }) + expect(parsed.success, `expected the API to reject ${type}`).toBe(false) + } + }) + + it('covers every resource type, so a new one has to make the choice explicitly', () => { + const all = Object.values(MothershipResourceType) + const ephemeral = all.filter((type) => isEphemeralResource(resource({ type }))) + expect([...PERSISTED_RESOURCE_TYPES, ...ephemeral].sort()).toEqual([...all].sort()) + }) +}) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 41e0fee863..09c03370d9 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -10,6 +10,8 @@ export const MothershipResourceType = { log: 'log', integration: 'integration', generic: 'generic', + browser: 'browser', + terminal: 'terminal', } as const export type MothershipResourceType = (typeof MothershipResourceType)[keyof typeof MothershipResourceType] @@ -21,10 +23,88 @@ export interface MothershipResource { path?: string } -export function isEphemeralResource(resource: MothershipResource): boolean { - return resource.type === 'generic' || resource.id === 'streaming-file' +interface ResourcePolicy { + /** Stored with the chat, so the tab is still there when the chat is reopened. */ + persisted: boolean + /** + * Backed by something only the desktop app can provide. Still persisted, but + * a client without the bridge leaves the tab out rather than restoring a + * panel with nothing behind it. + */ + desktopOnly?: boolean } +/** + * What the app does with each kind of resource, in one place. + * + * These rules used to live in three, and they disagreed. A client-side check + * decided what to send, a Zod enum in the API contract decided what to accept, + * and a runtime allowlist in the route handler decided again — but the enum + * rejected `browser`, `task` and `integration` before the allowlist that + * permitted them ever ran. Those tabs looked fine until the chat was reopened, + * because the write had been failing the whole time into a warning log. The + * contract enum and the handler now derive from this table, so a type can no + * longer be openable and unsaveable at the same time. + */ +const RESOURCE_POLICY: Record = { + table: { persisted: true }, + file: { persisted: true }, + workflow: { persisted: true }, + knowledgebase: { persisted: true }, + folder: { persisted: true }, + filefolder: { persisted: true }, + task: { persisted: true }, + scheduledtask: { persisted: true }, + log: { persisted: true }, + integration: { persisted: true }, + // A synthetic panel with no addressable entity behind it to reopen. + generic: { persisted: false }, + browser: { persisted: true, desktopOnly: true }, + terminal: { persisted: true, desktopOnly: true }, +} + +/** + * Resource types the chat will store. The API contract builds its enum from + * this, which is what keeps client and server from drifting. + */ +export const PERSISTED_RESOURCE_TYPES = ( + Object.keys(RESOURCE_POLICY) as MothershipResourceType[] +).filter((type) => RESOURCE_POLICY[type].persisted) as [ + MothershipResourceType, + ...MothershipResourceType[], +] + +/** True when the resource's panel needs the desktop bridge to show anything. */ +export function isDesktopOnlyResource(resource: MothershipResource): boolean { + return RESOURCE_POLICY[resource.type]?.desktopOnly === true +} + +export function isEphemeralResource(resource: MothershipResource): boolean { + // The in-flight file preview is a placeholder that becomes a real file once + // the write lands, so persisting it would restore a tab for a file that was + // never created. + if (resource.id === 'streaming-file') return true + // An unrecognized type is treated as ephemeral: the server would reject it + // anyway, and failing to store it is better than a write that always errors. + return !RESOURCE_POLICY[resource.type]?.persisted +} + +/** + * Singleton id for the live browser-session panel, which hosts the desktop + * app's natively embedded browser view. Only this metadata is stored with the + * chat: reopening restores the tab, while the page and browser profile stay + * owned by the desktop app. + */ +export const BROWSER_SESSION_RESOURCE_ID = 'browser-session' + +/** + * Singleton id for the live terminal panel. As with the browser, only the + * metadata is stored — reopening the chat brings the panel back with a fresh + * shell, since the pty and its scrollback belong to the desktop app and do not + * outlive it. + */ +export const TERMINAL_SESSION_RESOURCE_ID = 'terminal-session' + /** Placeholder resource titles that a more specific title may overwrite during dedup. */ export const GENERIC_RESOURCE_TITLES = new Set([ 'Table', diff --git a/apps/sim/lib/copilot/tool-executor/index.ts b/apps/sim/lib/copilot/tool-executor/index.ts index 6b84ce9f2b..a287c8835a 100644 --- a/apps/sim/lib/copilot/tool-executor/index.ts +++ b/apps/sim/lib/copilot/tool-executor/index.ts @@ -1,3 +1,3 @@ export { executeTool } from './executor' export { ensureHandlersRegistered } from './register-handlers' -export { getToolEntry, isSimExecuted } from './router' +export { getToolEntry, isSimExecuted, toolRequiresApproval } from './router' diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 7b5c908671..29e18387f7 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -5,7 +5,6 @@ import { Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, - DeleteWorkflow, DeleteWorkspaceMcpServer, DeployApi, DeployChat, @@ -29,7 +28,6 @@ import { LoadDeployment, ManageCredential, ManageCustomTool, - ManageFolder, ManageMcpTool, ManageScheduledTask, ManageSkill, @@ -43,6 +41,7 @@ import { Read as ReadTool, Redeploy, RestoreResource, + Rm as RmTool, RunBlock, RunCode, RunFromBlock, @@ -93,12 +92,15 @@ import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' -import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' +import { + executeVfsCp, + executeVfsMkdir, + executeVfsMv, + executeVfsRm, +} from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, - executeDeleteWorkflow, executeGenerateApiKey, - executeManageFolder, executeMoveWorkflow, executeRenameWorkflow, executeRunBlock, @@ -147,8 +149,6 @@ function buildHandlerMap(): Record { [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), [CreateWorkflow.id]: h(executeCreateWorkflow), - [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [ManageFolder.id]: h(executeManageFolder), // rename_workflow / move_workflow were removed from the mothership catalog // in favor of mv; the executors stay registered under literal names so // in-flight checkpoints still resume. Delete after the mv release soaks. @@ -188,6 +188,7 @@ function buildHandlerMap(): Record { [MvTool.id]: h(executeVfsMv), [CpTool.id]: h(executeVfsCp), [MkdirTool.id]: h(executeVfsMkdir), + [RmTool.id]: h(executeVfsRm), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/copilot/tool-executor/router.ts index 46a6815cfd..13ea484300 100644 --- a/apps/sim/lib/copilot/tool-executor/router.ts +++ b/apps/sim/lib/copilot/tool-executor/router.ts @@ -39,6 +39,11 @@ export function isKnownTool(toolId: string): boolean { return isToolInCatalog(toolId) } +/** Declared in the mothership tool catalog; Go carries the flag but never enforces it. */ +export function toolRequiresApproval(toolId: string): boolean { + return getToolEntry(toolId)?.requiresApproval === true +} + interface PartitionedBatch { sim: ToolCallDescriptor[] go: ToolCallDescriptor[] diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts new file mode 100644 index 0000000000..b00b95f499 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment jsdom + */ +import { sleep } from '@sim/utils/helpers' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteBrowserTool, mockReportCompletion } = vi.hoisted(() => ({ + mockExecuteBrowserTool: vi.fn(), + mockReportCompletion: vi.fn(), +})) + +vi.mock('@/lib/browser-agent/transport', () => ({ + executeBrowserTool: mockExecuteBrowserTool, +})) +vi.mock('@/lib/copilot/tools/client/completion', () => ({ + reportClientToolCompletion: mockReportCompletion, +})) + +import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +/** Waits for the fire-and-forget execution promise chain to settle. */ +async function flush(): Promise { + await sleep(0) +} + +let toolCallCounter = 0 +function nextToolCallId(): string { + toolCallCounter += 1 + return `tool-call-${toolCallCounter}` +} + +describe('executeBrowserToolOnClient', () => { + beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.clear() + useBrowserSessionStore.getState().setSessionAlive(true) + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('executes the tool and reports success when the session is alive', async () => { + mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith(toolCallId, 'browser_snapshot', {}, 30_000) + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), { + text: 'page content', + }) + }) + + // The copilot serializes a result carrying this `attachment` shape into a + // real image content block, so the data URL has to be reshaped rather than + // passed through — an inline data URL would be charged against the tool + // result budget as text and shown to the model as a base64 string. + it('reshapes a screenshot into an image attachment the model can see', async () => { + mockExecuteBrowserTool.mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + url: 'https://example.com/pricing', + width: 1024, + height: 640, + }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_screenshot', {}) + await flush() + + const [, , , reported] = mockReportCompletion.mock.calls[0] + expect(reported.attachment).toEqual({ + type: 'image', + source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' }, + }) + expect(reported.content).toContain('https://example.com/pricing') + expect(reported.dataUrl).toBeUndefined() + expect(reported.width).toBe(1024) + }) + + it('falls back to a note when a screenshot is not a usable data URL', async () => { + mockExecuteBrowserTool.mockResolvedValue({ dataUrl: 'not-a-data-url', url: 'https://x.dev' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_screenshot', {}) + await flush() + + const [, , , reported] = mockReportCompletion.mock.calls[0] + expect(reported.attachment).toBeUndefined() + expect(reported.note).toContain('could not be encoded') + }) + + // The desktop driver parses browser_wait_for.timeoutMs with a lenient num() + // that coerces numeric strings, then clamps to 120s. This side has to match: + // budgeting less time than the desktop actually waits makes the renderer + // abort first, which strands the native promise on the serialized tool queue + // and stalls every later browser call behind it. + it.each([ + ['number', 30_000, 45_000], + ['numeric string', '30000', 45_000], + ['absent', undefined, 25_000], + ['non-numeric', 'soon', 25_000], + ['above the desktop clamp', 500_000, 135_000], + ])( + 'budgets browser_wait_for above the desktop wait (%s)', + async (_label, timeoutMs, expected) => { + mockExecuteBrowserTool.mockResolvedValue({ found: true }) + const toolCallId = nextToolCallId() + const params = timeoutMs === undefined ? {} : { timeoutMs } + + executeBrowserToolOnClient(toolCallId, 'browser_wait_for', params) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith( + toolCallId, + 'browser_wait_for', + params, + expected + ) + } + ) + + it('rejects page-dependent tools up front when the session is closed', async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'error', + expect.stringContaining('browser session is closed'), + expect.objectContaining({ sessionClosed: true }) + ) + }) + + it('still allows session-revival tools when the session is closed', async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + mockExecuteBrowserTool.mockResolvedValue({ url: 'https://example.com' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_navigate', { url: 'https://example.com' }) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith( + toolCallId, + 'browser_navigate', + { url: 'https://example.com' }, + 45_000 + ) + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), { + url: 'https://example.com', + }) + }) + + it('tags a failure with sessionClosed when the session died mid-call', async () => { + mockExecuteBrowserTool.mockImplementation(async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + throw new Error('The browser did not respond within 30000ms') + }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'error', + expect.stringContaining('browser session is closed'), + expect.objectContaining({ + sessionClosed: true, + error: expect.stringContaining('The browser did not respond within 30000ms'), + }) + ) + }) + + it('reports a plain error without the sessionClosed tag when the session is alive', async () => { + mockExecuteBrowserTool.mockRejectedValue(new Error('element not found')) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_click', { ref: 'e12' }) + await flush() + + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'error', 'element not found', { + error: 'element not found', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts new file mode 100644 index 0000000000..14c8d0c3be --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -0,0 +1,277 @@ +/** + * Client-side execution of `browser_*` copilot tools. + * + * Mirrors the other client-executed tool flows (run-tool, local filesystem): + * the Go orchestrator emits a client-executed tool call and blocks on Redis; + * this module performs the action through the desktop app's built-in agent + * browser and reports the outcome via the confirm endpoint, which wakes the + * server-side waiter. + */ +import type { BrowserToolName } from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { executeBrowserTool } from '@/lib/browser-agent/transport' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const logger = createLogger('CopilotBrowserToolExecution') + +const DEFAULT_TOOL_TIMEOUT_MS = 30_000 +const NAVIGATION_TOOL_TIMEOUT_MS = 45_000 +const WAIT_FOR_TIMEOUT_GRACE_MS = 15_000 +// Mirror the desktop driver's parse of browser_wait_for.timeoutMs exactly. It +// coerces numeric strings and clamps to a maximum; reading the value more +// strictly here would budget less time than the desktop actually waits, and the +// renderer aborting first strands the native promise on the serialized tool +// queue so every later browser call stalls behind it. +const DEFAULT_WAIT_FOR_TIMEOUT_MS = 10_000 +const MAX_WAIT_FOR_TIMEOUT_MS = 120_000 + +/** + * Tools that can revive a closed browser session by opening a fresh tab. + * Everything else requires a live page and is rejected up front when the + * session is closed, instead of burning the full IPC timeout per call — a + * dead session used to answer every tool with an indistinguishable generic + * ~30s timeout, which the agent retried indefinitely. + */ +const SESSION_REVIVAL_TOOLS: ReadonlySet = new Set([ + 'browser_navigate', + 'browser_open_url', + 'browser_open_tab', + 'browser_list_tabs', +]) + +const SESSION_CLOSED_MESSAGE = + 'The agent browser session is closed, so this browser tool cannot run. ' + + 'Call browser_navigate or browser_open_tab to start a new session, or report the situation to the user. ' + + 'Do not retry other browser tools until a new session is open.' +/** Tool events older than this are replays, not live instructions — never act on them. */ +const MAX_EVENT_AGE_MS = 120_000 +const EXECUTED_STORAGE_PREFIX = 'sim:copilot:browser-tool-executed:' + +/** + * Exactly-once guard. Stream recovery and tab reloads replay persisted tool + * events; a browser action must never run twice (re-opening tabs, re-clicking + * buttons). In-memory set for the fast path, sessionStorage so a reload of the + * same tab cannot re-execute what it already did. + */ +const executedToolCallIds = new Set() + +function hasAlreadyExecuted(toolCallId: string): boolean { + if (executedToolCallIds.has(toolCallId)) return true + if (typeof window === 'undefined') return false + try { + return window.sessionStorage.getItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`) !== null + } catch { + return false + } +} + +function markExecuted(toolCallId: string): void { + executedToolCallIds.add(toolCallId) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`, '1') + } catch { + // Best-effort; the in-memory set still covers this tab's lifetime. + } +} + +/** Milliseconds since the event was emitted, or null when unparsable. */ +function eventAgeMs(eventTs: string | undefined): number | null { + if (!eventTs) return null + const emitted = Date.parse(eventTs) + return Number.isNaN(emitted) ? null : Date.now() - emitted +} + +function timeoutForTool(toolName: BrowserToolName, params: Record): number | null { + if (toolName === 'browser_request_takeover') return null + if ( + toolName === 'browser_navigate' || + toolName === 'browser_open_url' || + toolName === 'browser_go_back' || + toolName === 'browser_go_forward' || + toolName === 'browser_open_tab' + ) { + return NAVIGATION_TOOL_TIMEOUT_MS + } + if (toolName === 'browser_wait_for') { + const raw = Number(params.timeoutMs) + const requested = + Number.isFinite(raw) && raw > 0 + ? Math.min(raw, MAX_WAIT_FOR_TIMEOUT_MS) + : DEFAULT_WAIT_FOR_TIMEOUT_MS + return requested + WAIT_FOR_TIMEOUT_GRACE_MS + } + return DEFAULT_TOOL_TIMEOUT_MS +} + +/** Splits a `data:;base64,` URL into its parts. */ +function parseBase64DataUrl(dataUrl: string): { mediaType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl) + if (!match) return null + return { mediaType: match[1], data: match[2] } +} + +/** + * Reshapes a screenshot into the `attachment` contract the copilot serializes + * into a real image content block, so the model sees the page rather than a + * note about it. The data URL itself never goes inline: `content` is the text + * the model reads beside the image, and the bytes travel under `attachment`. + * + * A malformed data URL degrades to the text note rather than shipping an + * attachment the provider would reject. + */ +function sanitizeResultForModel( + toolName: BrowserToolName, + result: unknown +): Record | undefined { + if (!isRecordLike(result)) { + return result === undefined ? undefined : { value: result } + } + if (toolName === 'browser_screenshot' && typeof result.dataUrl === 'string') { + const { dataUrl, ...rest } = result + const image = parseBase64DataUrl(dataUrl) + if (!image) { + return { + ...rest, + note: 'The screenshot could not be encoded. Use browser_snapshot or browser_read_text instead.', + } + } + const location = typeof rest.url === 'string' && rest.url ? ` of ${rest.url}` : '' + return { + ...rest, + content: `Screenshot${location}. This is the rendered viewport only — it carries no element ids, so use browser_snapshot before interacting.`, + attachment: { + type: 'image', + source: { type: 'base64', media_type: image.mediaType, data: image.data }, + }, + } + } + return result +} + +/** + * Fire-and-forget entry point invoked by the stream tool-event handler when a + * `browser_*` client tool call arrives. + * + * @param eventTs - the stream envelope's emission timestamp; stale events + * (replays after reconnect/reload) are dropped rather than re-executed. + */ +export function executeBrowserToolOnClient( + toolCallId: string, + toolName: BrowserToolName, + params: Record, + eventTs?: string +): void { + if (hasAlreadyExecuted(toolCallId)) { + logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName }) + return + } + const age = eventAgeMs(eventTs) + if (age !== null && age > MAX_EVENT_AGE_MS) { + logger.info('Skipping stale browser tool event', { toolCallId, toolName, age }) + return + } + markExecuted(toolCallId) + void doExecuteBrowserTool(toolCallId, toolName, params).catch((err) => { + logger.error('Unhandled error in client-side browser tool execution', { + toolCallId, + toolName, + error: toError(err).message, + }) + }) +} + +/** True when the desktop app has reported the agent browser session closed. */ +function isSessionClosed(): boolean { + return !useBrowserSessionStore.getState().sessionAlive +} + +async function doExecuteBrowserTool( + toolCallId: string, + toolName: BrowserToolName, + params: Record +): Promise { + if (isSessionClosed() && !SESSION_REVIVAL_TOOLS.has(toolName)) { + logger.warn('Rejecting browser tool: agent browser session is closed', { + toolCallId, + toolName, + }) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + SESSION_CLOSED_MESSAGE, + { error: SESSION_CLOSED_MESSAGE, sessionClosed: true } + ).catch((reportErr) => { + logger.error('Failed to report browser session-closed error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + return + } + // If the user leaves the page mid-action the awaited result is lost; tell + // the waiter so the turn fails fast instead of hanging until its timeout. + const onPageHide = () => { + navigator.sendBeacon( + COPILOT_CONFIRM_API_PATH, + new Blob( + [ + JSON.stringify({ + toolCallId, + status: ASYNC_TOOL_CONFIRMATION_STATUS.error, + message: + 'The user left the Sim window while this browser action was running, so its result was lost.', + }), + ], + { type: 'application/json' } + ) + ) + } + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', onPageHide) + } + + logger.info('Executing browser tool via the desktop agent browser', { toolCallId, toolName }) + + try { + const result = await executeBrowserTool( + toolCallId, + toolName, + params, + timeoutForTool(toolName, params) + ) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Browser action completed', + sanitizeResultForModel(toolName, result) + ) + } catch (err) { + // The session dying mid-call (e.g. during a takeover) surfaces as a + // generic timeout; tag it so the model learns the real, terminal cause + // instead of retrying against a dead session. + const sessionClosed = isSessionClosed() + const message = sessionClosed + ? `${toError(err).message} ${SESSION_CLOSED_MESSAGE}` + : toError(err).message + logger.warn('Browser tool failed', { toolCallId, toolName, error: message, sessionClosed }) + await reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + ...(sessionClosed ? { sessionClosed: true } : {}), + }).catch((reportErr) => { + logger.error('Failed to report browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + } finally { + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', onPageHide) + } + } +} diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts new file mode 100644 index 0000000000..ef66300b44 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' +import type { + AsyncCompletionData, + AsyncConfirmationStatus, +} from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { traceparentHeader } from '@/lib/copilot/tools/client/trace-context' + +const logger = createLogger('CopilotClientToolCompletion') + +export class CompletionReportError extends Error { + constructor(message: string) { + super(message) + this.name = 'CompletionReportError' + } +} + +/** + * Persist a client-executed tool result and wake the server-side async waiter. + * Shared by workflow execution and desktop-native client tools. + */ +export async function reportClientToolCompletion( + toolCallId: string, + status: AsyncConfirmationStatus, + message?: string, + data?: AsyncCompletionData +): Promise { + const basePayload = { + toolCallId, + status, + message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), + ...(data !== undefined ? { data } : {}), + } + const send = async (body: string) => + fetch(COPILOT_CONFIRM_API_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...traceparentHeader() }, + body, + }) + + const body = JSON.stringify(basePayload) + const largePayloadThreshold = 10 * 1024 * 1024 + const bodySize = new Blob([body]).size + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const response = await send(body) + if (response.ok) return + + if (isRecordLike(data) && bodySize > largePayloadThreshold) { + const { logs: _logs, ...dataWithoutLogs } = data + logger.warn('Completion failed with large payload, retrying without logs', { + toolCallId, + status: response.status, + bodySize, + }) + const retryResponse = await send( + JSON.stringify({ + toolCallId, + status, + message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), + data: dataWithoutLogs, + }) + ) + if (retryResponse.ok) return + lastError = new Error(`Completion retry failed with status ${retryResponse.status}`) + } else { + lastError = new Error(`Completion failed with status ${response.status}`) + } + } catch (error) { + lastError = toError(error) + } + + if (attempt < 2) { + await sleep(250) + } + } + + logger.error('Client tool completion failed after retries', { + toolCallId, + error: lastError?.message, + }) + throw new CompletionReportError(lastError?.message ?? 'Failed to report tool completion') +} diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index dbe06363d7..7609e0e570 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,11 +1,17 @@ -// load_agent_skill is retained for historical persisted messages; it is no -// longer emitted now that internal skills autoload. +// load_agent_skill and load_custom_tool are retained for historical persisted +// messages; neither is emitted any more. load_custom_tool was renamed +// load_mcp_tool once it became clear that MCP was the only catalog kind it +// could ever match. // search_integration_tools is gateway plumbing: the discovery step is not a // user-meaningful action, only the resolved call_integration_tool row is. +// load_skill is the same shape — the agent pulling in a reference guide before +// doing the work is a step toward the action, not the action. const HIDDEN_TOOL_NAMES = new Set([ 'load_agent_skill', 'load_custom_tool', + 'load_mcp_tool', 'load_integration_tool', + 'load_skill', 'search_integration_tools', ]) diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts new file mode 100644 index 0000000000..2bd8749aaa --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReportCompletion } = vi.hoisted(() => ({ + mockReportCompletion: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/client/completion', () => ({ + reportClientToolCompletion: mockReportCompletion, +})) + +import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' + +const mount = { + id: 'mount-1', + name: 'Project', + uri: 'localfs://mount-1/', + path: '~/code/project', + remembered: true, +} +const vfsRoot = 'user-local/Project--mount-1' + +describe('executeLocalFilesystemTool', () => { + const localFilesystem = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'simDesktop', { + configurable: true, + value: { localFilesystem }, + }) + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('projects granted mounts and glob results into canonical user-local VFS paths', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'glob') { + return { + ok: true, + data: { + entries: [ + { + name: 'index.ts', + uri: 'localfs://mount-1/src/index.ts', + kind: 'file', + size: 10, + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + ], + truncated: false, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-1', + 'glob', + { pattern: 'user-local/**/*.ts' }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'glob', + uri: 'localfs://mount-1/', + pattern: 'user-local/**/*.ts', + pathPrefix: vfsRoot, + requestId: 'tool-1', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-1', + 'success', + 'Local filesystem tool completed.', + { files: [`${vfsRoot}/src/index.ts`] } + ) + }) + expect(JSON.stringify(mockReportCompletion.mock.calls)).not.toContain('localfs://') + expect(JSON.stringify(mockReportCompletion.mock.calls)).not.toContain('~/code/project') + }) + + it('maps ordinary VFS read arguments to a bounded desktop read', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'read') { + return { + ok: true, + data: { + uri: 'localfs://mount-1/README.md', + content: 'second line', + startLine: 2, + endLine: 2, + totalLines: 3, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-read', + 'read', + { path: `${vfsRoot}/README.md`, offset: 1, limit: 1 }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'read', + uri: 'localfs://mount-1/README.md', + startLine: 2, + lineCount: 1, + requestId: 'tool-read', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-read', + 'success', + 'Local filesystem tool completed.', + { content: 'second line', totalLines: 3 } + ) + }) + }) + + it('preserves normal grep regex/options and rewrites every result path', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'grep') { + return { + ok: true, + data: { + matches: [ + { + uri: 'localfs://mount-1/src/index.ts', + line: 7, + text: 'const TODO = true', + }, + ], + truncated: false, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-grep', + 'grep', + { + pattern: 'TODO|FIXME', + path: vfsRoot, + ignoreCase: true, + lineNumbers: false, + context: 2, + maxResults: 10, + }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'grep', + uri: 'localfs://mount-1/', + pattern: 'TODO|FIXME', + caseSensitive: false, + maxResults: 10, + outputMode: 'content', + lineNumbers: false, + context: 2, + requestId: 'tool-grep', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-grep', + 'success', + 'Local filesystem tool completed.', + { + matches: [{ path: `${vfsRoot}/src/index.ts`, line: 7, content: 'const TODO = true' }], + } + ) + }) + }) + + it('cancels an in-flight native read on abort and never reports a stale completion', async () => { + let finishRead: + | ((response: { ok: false; code: 'CANCELLED'; error: string }) => void) + | undefined + localFilesystem.mockImplementation((request: { operation: string; requestId?: string }) => { + if (request.operation === 'list_mounts') { + return Promise.resolve({ ok: true, data: { mounts: [mount] } }) + } + if (request.operation === 'read') { + return new Promise((resolve) => { + finishRead = resolve + }) + } + if (request.operation === 'cancel') { + finishRead?.({ ok: false, code: 'CANCELLED', error: 'cancelled' }) + return Promise.resolve({ ok: true, data: { cancelled: true } }) + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + const controller = new AbortController() + + executeLocalFilesystemTool( + 'tool-abort', + 'read', + { path: `${vfsRoot}/README.md` }, + { workspaceId: 'ws-1', signal: controller.signal } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'read', requestId: 'tool-abort' }) + ) + }) + controller.abort('user stopped') + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'cancel', + requestId: 'tool-abort', + }) + }) + expect(mockReportCompletion).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.ts new file mode 100644 index 0000000000..04091d484b --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.ts @@ -0,0 +1,409 @@ +import type { + LocalFilesystemData, + LocalFilesystemMount, + LocalFilesystemRequest, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, + MAX_GREP_CONTEXT, + MAX_GREP_RESULTS, + MAX_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import micromatch from 'micromatch' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { USER_LOCAL_VFS_ROOT } from '@/lib/copilot/tools/local-filesystem' +import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { getDesktopBridge } from '@/lib/desktop' + +const logger = createLogger('CopilotLocalFilesystemTool') +/** + * This glob runs entirely in the renderer against already-listed mounts, so + * unlike the grep and read limits it is nobody else's business — the shell + * never authorizes against it. Deliberately local. + */ +const MAX_USER_LOCAL_GLOB_RESULTS = 500 + +const VFS_GLOB_OPTIONS: micromatch.Options = { + bash: false, + dot: false, + windows: false, + nobrace: true, + noext: true, +} + +interface LocalFilesystemExecutionContext { + workspaceId: string + chatId?: string + signal?: AbortSignal +} + +function requiredString(args: Record, name: string): string { + const value = args[name] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} is required`) + } + return value +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function bridge(): NonNullable { + const desktop = getDesktopBridge() + if (!desktop?.localFilesystem) { + throw new Error('The desktop local filesystem bridge is unavailable.') + } + return desktop +} + +function successfulData(response: LocalFilesystemResponse): LocalFilesystemData { + if (!response.ok) { + throw new Error(response.error) + } + return response.data +} + +function abortError(signal: AbortSignal): Error { + const error = new Error(signal.reason ? String(signal.reason) : 'Operation aborted') + error.name = 'AbortError' + return error +} + +function requestIdForToolCall(toolCallId: string): string { + return toolCallId +} + +async function invokeBridge( + request: LocalFilesystemRequest, + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw abortError(signal) + const requestId = 'requestId' in request ? request.requestId : undefined + const onAbort = () => { + if (requestId) { + void bridge().localFilesystem({ operation: 'cancel', requestId }) + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + try { + const data = successfulData(await bridge().localFilesystem(request)) + if (signal?.aborted) throw abortError(signal) + return data + } finally { + signal?.removeEventListener('abort', onAbort) + } +} + +function encodeMountName(name: string): string { + try { + return encodeVfsSegment(name) + } catch { + return encodeURIComponent(name) + } +} + +function mountVfsRoot(mount: LocalFilesystemMount): string { + return `${USER_LOCAL_VFS_ROOT}/${encodeMountName(mount.name)}--${mount.id}` +} + +function vfsPathForUri(mount: LocalFilesystemMount, uri: string): string { + const parsed = new URL(uri) + if (parsed.protocol !== 'localfs:' || parsed.hostname !== mount.id) { + throw new Error('The desktop app returned a local path outside the selected folder.') + } + const relativePath = parsed.pathname.replace(/^\/+/, '') + return relativePath ? `${mountVfsRoot(mount)}/${relativePath}` : mountVfsRoot(mount) +} + +function localUriForVfsPath(mount: LocalFilesystemMount, path: string): string { + const root = mountVfsRoot(mount) + if (path === root) return mount.uri + if (!path.startsWith(`${root}/`)) { + throw new Error(`Path is not inside a granted user-local folder: ${path}`) + } + return `${mount.uri}${path.slice(root.length + 1)}` +} + +async function listMounts(): Promise { + const data = await invokeBridge({ operation: 'list_mounts' }) + if (!('mounts' in data)) { + throw new Error('The desktop app returned an invalid mount list.') + } + return data.mounts +} + +function mountForPath(mounts: LocalFilesystemMount[], path: string): LocalFilesystemMount { + const match = mounts.find((mount) => { + const root = mountVfsRoot(mount) + return path === root || path.startsWith(`${root}/`) + }) + if (!match) { + throw new Error( + `No granted user-local folder contains "${path}". Use glob({pattern:"user-local/**"}) to discover canonical paths.` + ) + } + return match +} + +function omitHostPaths(data: LocalFilesystemData): LocalFilesystemData { + if ('mount' in data) { + if (!data.mount) return data + const { path: _path, ...mount } = data.mount as LocalFilesystemMount & { path?: unknown } + return { ...data, mount } + } + if ('mounts' in data) { + return { + ...data, + mounts: data.mounts.map((rawMount) => { + const { path: _path, ...mount } = rawMount as LocalFilesystemMount & { path?: unknown } + return mount + }), + } + } + return data +} + +async function executeUserLocalGlob( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise<{ files: string[] }> { + const pattern = requiredString(args, 'pattern') + const mounts = await listMounts() + const files = new Set() + const requestId = requestIdForToolCall(toolCallId) + + for (const mount of mounts) { + if (signal?.aborted) throw abortError(signal) + const root = mountVfsRoot(mount) + if (micromatch.isMatch(root, pattern, VFS_GLOB_OPTIONS)) { + files.add(root) + } + + const data = await invokeBridge( + { + operation: 'glob', + uri: mount.uri, + pattern, + pathPrefix: root, + requestId, + }, + signal + ) + if (!('entries' in data)) { + throw new Error('The desktop app returned an invalid glob result.') + } + for (const entry of data.entries) { + const path = vfsPathForUri(mount, entry.uri) + if (micromatch.isMatch(path, pattern, VFS_GLOB_OPTIONS)) { + files.add(path) + if (files.size >= MAX_USER_LOCAL_GLOB_RESULTS) break + } + } + if (files.size >= MAX_USER_LOCAL_GLOB_RESULTS) break + } + + return { files: [...files].sort() } +} + +async function executeUserLocalRead( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise<{ content: string; totalLines: number }> { + const path = requiredString(args, 'path') + const mounts = await listMounts() + const mount = mountForPath(mounts, path) + const offset = Math.max(0, Math.trunc(optionalNumber(args.offset) ?? 0)) + const requestedLimit = optionalNumber(args.limit) + const lineCount = Math.min( + MAX_READ_LINES, + Math.max(1, Math.trunc(requestedLimit ?? DEFAULT_READ_LINES)) + ) + const data = await invokeBridge( + { + operation: 'read', + uri: localUriForVfsPath(mount, path), + startLine: offset + 1, + lineCount, + requestId: requestIdForToolCall(toolCallId), + }, + signal + ) + if (!('content' in data) || !('totalLines' in data)) { + throw new Error('The desktop app returned an invalid read result.') + } + return { content: data.content, totalLines: data.totalLines } +} + +async function executeUserLocalGrep( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise> { + const pattern = requiredString(args, 'pattern') + const path = requiredString(args, 'path').replace(/\/+$/, '') + const outputMode = + args.output_mode === 'files_with_matches' || args.output_mode === 'count' + ? args.output_mode + : 'content' + const maxResults = Math.min( + MAX_GREP_RESULTS, + Math.max(1, Math.trunc(optionalNumber(args.maxResults) ?? DEFAULT_GREP_RESULTS)) + ) + const mounts = await listMounts() + const targets = + path === USER_LOCAL_VFS_ROOT + ? mounts.map((mount) => ({ mount, uri: mount.uri })) + : [ + { + mount: mountForPath(mounts, path), + uri: '', + }, + ] + if (targets.length === 1 && !targets[0].uri) { + targets[0].uri = localUriForVfsPath(targets[0].mount, path) + } + + const contentMatches: Array<{ path: string; line: number; content: string }> = [] + const matchingFiles = new Set() + const counts = new Map() + const requestId = requestIdForToolCall(toolCallId) + + for (const target of targets) { + const data = await invokeBridge( + { + operation: 'grep', + uri: target.uri, + pattern, + caseSensitive: args.ignoreCase !== true, + maxResults, + outputMode, + lineNumbers: args.lineNumbers !== false, + context: Math.min( + MAX_GREP_CONTEXT, + Math.max(0, Math.trunc(optionalNumber(args.context) ?? DEFAULT_GREP_CONTEXT)) + ), + requestId, + }, + signal + ) + + if ('matches' in data) { + for (const match of data.matches) { + contentMatches.push({ + path: vfsPathForUri(target.mount, match.uri), + line: match.line, + content: match.text, + }) + if (contentMatches.length >= maxResults) break + } + } else if ('files' in data) { + for (const uri of data.files) { + matchingFiles.add(vfsPathForUri(target.mount, uri)) + if (matchingFiles.size >= maxResults) break + } + } else if ('counts' in data) { + for (const count of data.counts) { + counts.set(vfsPathForUri(target.mount, count.uri), count.count) + if (counts.size >= maxResults) break + } + } else { + throw new Error('The desktop app returned an invalid grep result.') + } + + const currentCount = + outputMode === 'files_with_matches' + ? matchingFiles.size + : outputMode === 'count' + ? counts.size + : contentMatches.length + if (currentCount >= maxResults) break + } + + if (outputMode === 'files_with_matches') { + return { files: [...matchingFiles].sort() } + } + if (outputMode === 'count') { + return { + counts: [...counts.entries()] + .map(([countPath, count]) => ({ path: countPath, count })) + .sort((a, b) => a.path.localeCompare(b.path)), + } + } + return { + matches: contentMatches.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line), + } +} + +async function execute( + toolCallId: string, + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): Promise { + if (toolName === 'glob') return executeUserLocalGlob(toolCallId, args, context.signal) + if (toolName === 'grep') return executeUserLocalGrep(toolCallId, args, context.signal) + return executeUserLocalRead(toolCallId, args, context.signal) +} + +export function executeLocalFilesystemTool( + toolCallId: string, + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): void { + void execute(toolCallId, toolName, args, context).then( + async (data) => { + if (context.signal?.aborted) return + try { + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Local filesystem tool completed.', + data + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool completion', { + toolCallId, + toolName, + error: toError(reportError).message, + }) + } + }, + async (error) => { + if (context.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + return + } + const message = toError(error).message + logger.warn('Local filesystem tool failed', { toolCallId, toolName, error: message }) + try { + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + message, + { error: message } + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool error', { + toolCallId, + toolName, + error: toError(reportError).message, + }) + } + } + ) +} + +export const userLocalVfsTestHelpers = { + mountVfsRoot, + vfsPathForUri, + localUriForVfsPath, +} diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 44117d1195..cd66f64031 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncCompletionData, @@ -15,7 +13,10 @@ import { RunFromBlock, RunWorkflowUntilBlock, } from '@/lib/copilot/generated/tool-catalog-v1' -import { traceparentHeader } from '@/lib/copilot/tools/client/trace-context' +import { + CompletionReportError, + reportClientToolCompletion as reportCompletion, +} from '@/lib/copilot/tools/client/completion' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' @@ -39,13 +40,6 @@ interface PendingCompletionReport { data?: AsyncCompletionData } -class CompletionReportError extends Error { - constructor(message: string) { - super(message) - this.name = 'CompletionReportError' - } -} - function resolveWorkflowInput(params: Record): unknown { if (Object.hasOwn(params, 'workflow_input')) { return params.workflow_input @@ -566,73 +560,3 @@ function buildResultData(result: unknown): Record | undefined { return undefined } - -/** - * Report tool completion to the server via the existing /api/copilot/confirm endpoint. - * This persists the durable async-tool row and wakes the server-side waiter so - * it can continue the paused Copilot run and notify Go. - */ -async function reportCompletion( - toolCallId: string, - status: AsyncConfirmationStatus, - message?: string, - data?: AsyncCompletionData -): Promise { - const basePayload = { - toolCallId, - status, - message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), - ...(data !== undefined ? { data } : {}), - } - const send = async (body: string) => - fetch(COPILOT_CONFIRM_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...traceparentHeader() }, - body, - }) - - const body = JSON.stringify(basePayload) - const LARGE_PAYLOAD_THRESHOLD = 10 * 1024 * 1024 - const bodySize = new Blob([body]).size - let lastError: Error | null = null - - for (let attempt = 1; attempt <= 2; attempt++) { - try { - const res = await send(body) - if (res.ok) return - - if (isRecordLike(data) && bodySize > LARGE_PAYLOAD_THRESHOLD) { - const { logs: _logs, ...dataWithoutLogs } = data - logger.warn('[RunTool] reportCompletion failed with large payload, retrying without logs', { - toolCallId, - status: res.status, - bodySize, - }) - const retryRes = await send( - JSON.stringify({ - toolCallId, - status, - message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), - data: dataWithoutLogs, - }) - ) - if (retryRes.ok) return - lastError = new Error(`reportCompletion retry failed with status ${retryRes.status}`) - } else { - lastError = new Error(`reportCompletion failed with status ${res.status}`) - } - } catch (err) { - lastError = toError(err) - } - - if (attempt < 2) { - await sleep(250) - } - } - - logger.error('[RunTool] reportCompletion failed after retries', { - toolCallId, - error: lastError?.message, - }) - throw new CompletionReportError(lastError?.message ?? 'Failed to report tool completion') -} diff --git a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts new file mode 100644 index 0000000000..ce4e8ccbcc --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts @@ -0,0 +1,207 @@ +/** + * Client-side execution of `terminal_*` copilot tools. + * + * Mirrors the other client-executed tool flows (browser, run-tool, local + * filesystem): the Go orchestrator emits a client-executed tool call and blocks + * on Redis; this module performs the action through the desktop app's terminal + * and reports the outcome via the confirm endpoint, which wakes the + * server-side waiter. + */ +import { createLogger } from '@sim/logger' +import { + isTerminalOperation, + type TerminalOperation, + type TerminalToolArgs, +} from '@sim/terminal-protocol' +import { toError } from '@sim/utils/errors' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { executeTerminalTool } from '@/lib/terminal/transport' + +const logger = createLogger('CopilotTerminalToolExecution') + +/** Tool events older than this are replays, not live instructions. */ +const MAX_EVENT_AGE_MS = 120_000 +const EXECUTED_STORAGE_PREFIX = 'sim:copilot:terminal-tool-executed:' + +/** + * Exactly-once guard. Stream recovery and tab reloads replay persisted tool + * events, and re-running a shell command is the least forgiving thing in this + * codebase to get wrong — `rm -rf` twice is not the same as once. In-memory set + * for the fast path, sessionStorage so a reload of the same tab cannot + * re-execute what it already did. + */ +const executedToolCallIds = new Set() + +function hasAlreadyExecuted(toolCallId: string): boolean { + if (executedToolCallIds.has(toolCallId)) return true + if (typeof window === 'undefined') return false + try { + return window.sessionStorage.getItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`) !== null + } catch { + return false + } +} + +function markExecuted(toolCallId: string): void { + executedToolCallIds.add(toolCallId) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`, '1') + } catch { + // Best-effort; the in-memory set still covers this tab's lifetime. + } +} + +function eventAgeMs(eventTs: string | undefined): number | null { + if (!eventTs) return null + const emitted = Date.parse(eventTs) + return Number.isNaN(emitted) ? null : Date.now() - emitted +} + +/** + * `terminal_run` has no client-side deadline: it can sit on an approval chip + * for as long as the user takes, and the desktop side already bounds the + * command itself. The rest are near-instant, so a short timeout keeps a wedged + * bridge from stalling the turn. + */ +const QUICK_TOOL_TIMEOUT_MS = 15_000 + +function timeoutForOperation(operation: TerminalOperation): number | null { + // `run` waits on a command and `handoff` waits on a person; neither has a + // deadline this side can usefully impose. + return operation === 'run' || operation === 'handoff' ? null : QUICK_TOOL_TIMEOUT_MS +} + +/** + * Splits a `terminal` tool call into its operation and arguments. The model + * supplies both inside one params object, and an unrecognized operation is + * rejected here rather than sent to the desktop, so a malformed call fails + * with a useful message instead of a bridge error. + */ +function parseCall(params: Record): { + operation: TerminalOperation + args: TerminalToolArgs +} | null { + const operation = params.operation + if (!isTerminalOperation(operation)) return null + const args = params.args + return { + operation, + args: + args && typeof args === 'object' && !Array.isArray(args) ? (args as TerminalToolArgs) : {}, + } +} + +/** + * Fire-and-forget entry point invoked by the stream tool-event handler when a + * `terminal` client tool call arrives. + * + * @param eventTs - the stream envelope's emission timestamp; stale events + * (replays after reconnect/reload) are dropped rather than re-executed. + */ +export function executeTerminalToolOnClient( + toolCallId: string, + params: Record, + eventTs?: string +): void { + const call = parseCall(params) + if (!call) { + logger.warn('Ignoring terminal tool call with no recognized operation', { toolCallId }) + return + } + const operation = call.operation + if (hasAlreadyExecuted(toolCallId)) { + logger.info('Skipping already-executed terminal tool (replay)', { toolCallId, operation }) + return + } + const age = eventAgeMs(eventTs) + if (age !== null && age > MAX_EVENT_AGE_MS) { + logger.info('Skipping stale terminal tool event', { toolCallId, operation, age }) + return + } + markExecuted(toolCallId) + void doExecuteTerminalTool(toolCallId, operation, call.args).catch((err) => { + logger.error('Unhandled error in client-side terminal tool execution', { + toolCallId, + operation, + error: toError(err).message, + }) + }) +} + +async function doExecuteTerminalTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs +): Promise { + // If the user leaves the page mid-command the awaited result is lost; tell + // the waiter so the turn fails fast instead of hanging until its timeout. + const onPageHide = () => { + navigator.sendBeacon( + COPILOT_CONFIRM_API_PATH, + new Blob( + [ + JSON.stringify({ + toolCallId, + status: ASYNC_TOOL_CONFIRMATION_STATUS.error, + message: + 'The user left the Sim window while this terminal command was running, so its result was lost.', + }), + ], + { type: 'application/json' } + ) + ) + } + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', onPageHide) + } + + logger.info('Executing terminal operation via the desktop terminal', { toolCallId, operation }) + + try { + const timeoutMs = timeoutForOperation(operation) + const invocation = executeTerminalTool(toolCallId, operation, args) + const result = + timeoutMs === null + ? await invocation + : await Promise.race([ + invocation, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`The terminal did not respond within ${timeoutMs}ms`)), + timeoutMs + ) + }), + ]) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Terminal action completed', + result as Record | undefined + ) + } catch (err) { + const error = toError(err) + // A declined command is a normal outcome, not a fault: reporting it as + // cancelled lets the model adapt instead of retrying the same command. + const status = + error.name === 'REJECTED' + ? ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + : ASYNC_TOOL_CONFIRMATION_STATUS.error + logger.warn('Terminal operation failed', { toolCallId, operation, error: error.message }) + await reportClientToolCompletion(toolCallId, status, error.message, { + error: error.message, + ...(error.name ? { code: error.name } : {}), + }).catch((reportErr) => { + logger.error('Failed to report terminal tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + } finally { + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', onPageHide) + } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 51dabd8de1..3fc9065518 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -68,13 +68,6 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) -vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ - resolveWorkflowAliasForWorkspace: vi.fn().mockResolvedValue(null), -})) -vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({ - isPlanAliasPath: () => false, - workflowAliasSandboxPath: (p: string) => p, -})) import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' @@ -443,3 +436,90 @@ describe('executeFunctionExecute file mounts', () => { expect(file.type).toBeUndefined() }) }) + +async function mountError(inputs: Record): Promise { + try { + await executeFunctionExecute(inputs, context as never) + } catch (error) { + return (error as Error).message + } + throw new Error('expected the mount to be rejected') +} + +describe('executeFunctionExecute unmountable namespaces', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true }) + mockIsFeatureEnabled.mockResolvedValue(false) + mockHasCloudStorage.mockReturnValue(true) + mockListWorkspaceFiles.mockResolvedValue([]) + mockFindWorkspaceFileRecord.mockReturnValue(null) + mockListWorkspaceFileFolders.mockResolvedValue([]) + }) + + it('tells the agent a tool-result artifact is backend-served, not a wrong path', async () => { + const message = await mountError({ + inputFiles: ['internal/tool-results/user_table-toolu_019Ef.json'], + }) + + expect(message).toContain('Cannot mount "internal/tool-results/user_table-toolu_019Ef.json"') + expect(message).toContain('stored by the copilot backend') + expect(message).toContain('This path is correct') + expect(message).toContain('outputs.files[].path') + expect(message).toContain('user_table: outputPath') + // The old message sent the agent hunting for a canonical path that never existed. + expect(message).not.toContain('Input file not found') + expect(message).not.toContain('canonical VFS path copied from glob/read') + }) + + it('covers the rest of internal/ without the tool-result rerun advice', async () => { + const message = await mountError({ inputFiles: ['internal/memories/SESSION.md'] }) + + expect(message).toContain('served by the copilot backend') + expect(message).toContain('read or grep it') + expect(message).not.toContain('outputPath') + }) + + it('points recently-deleted/ paths at restore_resource', async () => { + const message = await mountError({ inputFiles: ['recently-deleted/files/old.csv'] }) + + expect(message).toContain('restore_resource') + }) + + it('points tables/ paths at inputs.tables', async () => { + const message = await mountError({ inputFiles: ['tables/Leads/meta.json'] }) + + expect(message).toContain('inputs.tables') + }) + + it('names the namespace for VFS metadata views', async () => { + const message = await mountError({ inputFiles: ['workflows/My%20Flow/state.json'] }) + + expect(message).toContain('workflows/ paths are VFS metadata views') + }) + + it('keeps the uploads/ guidance intact', async () => { + const message = await mountError({ inputFiles: ['uploads/report.json'] }) + + expect(message).toContain('materialize_file') + }) + + it('still reports a genuine files/ miss as not found', async () => { + const message = await mountError({ inputFiles: ['files/typo.csv'] }) + + expect(message).toContain('Input file not found: "files/typo.csv"') + }) + + it('explains an unmountable namespace passed as a directory', async () => { + const message = await mountError({ inputs: { directories: ['internal/tool-results'] } }) + + expect(message).toContain('Cannot mount "internal/tool-results"') + expect(message).toContain('stored by the copilot backend') + }) + + it('still reports a genuine files/ folder miss as not found', async () => { + const message = await mountError({ inputs: { directories: ['files/Missing'] } }) + + expect(message).toContain('Input directory not found: "files/Missing"') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 828e27b595..6029f71c0f 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,7 +1,5 @@ import { createLogger } from '@sim/logger' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getColumnId } from '@/lib/table/column-keys' @@ -159,6 +157,47 @@ async function pushWorkspaceFileMount( mounted.buffered += buffer.length } +/** + * Explains why a VFS path the agent legitimately discovered cannot be mounted, and + * what to do instead. Only workspace `files/` are backed by storage the sandbox can + * fetch from — `internal/` is served by the copilot backend and its bytes never reach + * Sim, `uploads/` is chat-scoped, `recently-deleted/` is archived, and the remaining + * namespaces are metadata views rather than stored file bytes. Returns null for + * `files/` references, where "not found" is the honest answer. + * + * These paths are correct and are advertised to the model as read/grep-able, so the + * generic not-found message ("copy the exact canonical path") is actively wrong for + * them: it sends the agent hunting for a path that does not exist. + */ +function unmountableNamespaceReason(filePath: string): string | null { + // Trailing slash so a bare namespace passed as a directory matches the same prefixes + // as a file path inside it. + const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` + + if (path.startsWith('uploads/')) { + return 'uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.' + } + if (path.startsWith('internal/tool-results/')) { + return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (function_execute: outputs.files[].path, user_table: outputPath) and mount that files/... path.' + } + if (path.startsWith('internal/')) { + return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' + } + if (path.startsWith('recently-deleted/')) { + return 'deleted resources are not mountable into the sandbox. Use restore_resource to restore it first, then mount the restored files/... path.' + } + if (path.startsWith('tables/')) { + return 'tables are not mounted as files. Pass the table in inputs.tables instead and it is mounted as CSV.' + } + const namespace = /^(workflows|knowledgebases|components|environment|agent|jobs|tasks)\//.exec( + path + )?.[1] + if (namespace) { + return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read or grep it and inline the values you need in code.` + } + return null +} + interface CanonicalFileInput { path: string sandboxPath?: string @@ -203,7 +242,6 @@ export async function resolveInputFiles( ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } - const betaEnabled = await isFeatureEnabled('mothership-beta') if (inputFiles?.length && workspaceId) { if (inputFiles.length > MAX_MOUNTED_FILES) { @@ -211,9 +249,7 @@ export async function resolveInputFiles( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId, { - includeReservedSystemFiles: betaEnabled, - }) + const allFiles = await listWorkspaceFiles(workspaceId) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -222,21 +258,11 @@ export async function resolveInputFiles( ? (fileRef as CanonicalFileInput).path : undefined if (!filePath) continue - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath }) - if (!alias && isPlanAliasPath(filePath)) { - logger.warn('Unsupported plan alias input file path', { filePath }) - continue - } - if (alias?.kind === 'plans_dir') { - logger.warn('Input file is a plan alias directory', { filePath }) - continue - } - const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath) + const record = findWorkspaceFileRecord(allFiles, filePath) if (!record) { - if (filePath.startsWith('uploads/')) { - throw new Error( - `Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.` - ) + const unmountable = unmountableNamespaceReason(filePath) + if (unmountable) { + throw new Error(`Cannot mount "${filePath}": ${unmountable}`) } throw new Error( `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` @@ -246,21 +272,14 @@ export async function resolveInputFiles( typeof fileRef === 'object' && fileRef !== null ? (fileRef as CanonicalFileInput).sandboxPath : undefined - const mountPath = - explicitSandboxPath || - (alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record)) + const mountPath = explicitSandboxPath || getSandboxWorkspaceFilePath(record) await pushWorkspaceFileMount(sandboxFiles, record, mountPath, mounted) } } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: betaEnabled, - }) - const allFiles = await listWorkspaceFiles(workspaceId, { - folders, - includeReservedSystemFiles: betaEnabled, - }) + const folders = await listWorkspaceFileFolders(workspaceId) + const allFiles = await listWorkspaceFiles(workspaceId, { folders }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -269,28 +288,23 @@ export async function resolveInputFiles( ? (dirRef as CanonicalDirectoryInput).path : undefined if (!dirPath) continue - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath }) - if (alias && alias.kind !== 'plans_dir') { - throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`) - } - if (!alias && isPlanAliasPath(dirPath)) { - throw new Error(`Unsupported plan alias directory: ${dirPath}`) - } - const backingDirPath = alias?.backingPath ?? dirPath - const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, '')) + const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) const folderDisplayPath = folderSegments.join('/') const folder = folders.find((candidate) => candidate.path === folderDisplayPath) if (!folder) { - throw new Error(`Input directory not found: ${dirPath}`) + const unmountable = unmountableNamespaceReason(dirPath) + throw new Error( + unmountable + ? `Cannot mount "${dirPath}": ${unmountable}` + : `Input directory not found: "${dirPath}". Pass a canonical workspace folder path copied from glob/read (e.g. "files/Reports").` + ) } const mountRoot = typeof dirRef === 'object' && dirRef !== null && (dirRef as CanonicalDirectoryInput).sandboxPath ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : alias - ? workflowAliasSandboxPath(alias.aliasPath) - : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` const descendants = allFiles.filter((file) => { if (!file.folderPath) return false return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) @@ -329,11 +343,7 @@ export async function resolveInputFiles( for (const record of descendants) { const relativeFolder = record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' - const relativePath = alias - ? encodeVfsPathSegments( - [relativeFolder, record.name].filter(Boolean).join('/').split('/') - ) - : [relativeFolder, record.name].filter(Boolean).join('/') + const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') await pushWorkspaceFileMount(sandboxFiles, record, `${mountRoot}/${relativePath}`, mounted) } } diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index d0e2cbc18c..ce1bf99bc2 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -446,32 +446,6 @@ describe('executeMaterializeFile - extract operation', () => { expect(mockDecompress).toHaveBeenCalledTimes(1) }) - it('folds reserved system folder names into the "archive" fallback folder', async () => { - // '.changelogs' / '.plans' back workflow changelog/plan aliases; extraction - // must never write into them (and the already-extracted lookup hides them, - // so a second extract would silently duplicate). - mockFindUpload.mockResolvedValue( - zipRow({ displayName: '.changelogs.zip', originalName: '.changelogs.zip' }) - ) - mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) - mockDecompress.mockResolvedValue({ - extracted: [{ id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }], - skipped: 0, - skippedUnsafePaths: [], - }) - - const result = await executeMaterializeFile( - { fileNames: ['.changelogs.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(true) - expect(mockDecompress).toHaveBeenCalledWith( - expect.any(Buffer), - expect.objectContaining({ rootFolderSegments: ['archive'] }) - ) - }) - it('folds degenerate archive names into the "archive" fallback folder', async () => { mockFindUpload.mockResolvedValue(zipRow({ displayName: '..zip', originalName: '..zip' })) mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 7c47c0105a..f5e3501979 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -14,7 +14,6 @@ import { import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { getServePathPrefix } from '@/lib/uploads' import { ArchiveError, @@ -304,9 +303,6 @@ async function executeImport( * empty), so a hostile upload name like `..zip` or `\x01.zip` lands in the * `archive` fallback instead of surfacing a raw internal error — and so the * VFS-encoded destination path can be computed before anything is extracted. - * Reserved system backing folders (`.changelogs`, `.plans`) also fall back: - * extraction must never write into — or hide behind — those namespaces (the - * already-extracted lookup skips them, so they'd also duplicate silently). */ function archiveFolderBaseName(displayName: string): string { const stripped = displayName @@ -315,12 +311,7 @@ function archiveFolderBaseName(displayName: string): string { .replace(/[\x00-\x1f\x7f]/g, '') .replace(/[/\\]/g, '-') .trim() - if ( - !stripped || - stripped === '.' || - stripped === '..' || - isReservedWorkflowAliasBackingDisplayPath(stripped) - ) { + if (!stripped || stripped === '.' || stripped === '..') { return 'archive' } return stripped diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index a59afaf30d..c3c3ac1438 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -14,6 +14,7 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | Mod+Z | Undo | | Mod+Shift+Z | Redo | | Mod+C | Copy selected blocks | +| Mod+X | Cut selected blocks | | Mod+V | Paste blocks | | Delete/Backspace | Delete selected blocks or edges | | Shift+L | Auto-layout canvas | @@ -23,9 +24,6 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa ### Panel Navigation | Shortcut | Action | |----------|--------| -| C | Focus Copilot tab | -| T | Focus Toolbar tab | -| E | Focus Editor tab | | Mod+F | Open workflow search and replace | | Mod+Alt+F | Focus Toolbar search | @@ -34,6 +32,8 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa |----------|--------| | Mod+K | Open search | | Mod+Shift+A | Add new agent workflow | +| Mod+Shift+P | Create workflow | +| Mod+B | Toggle sidebar | | Mod+L | Go to logs | ### Utility @@ -44,10 +44,10 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa ### Mouse Controls | Action | Control | |--------|---------| -| Pan/move canvas | Left-drag on empty space, scroll, or trackpad | -| Select multiple blocks | Right-drag to draw selection box | +| Pan/move canvas | Left-drag on empty space (hand mode, the default), middle-drag, scroll, or trackpad | +| Select multiple blocks | Shift+drag to draw a selection box. In cursor mode, left-drag on empty space draws it instead | | Drag block | Left-drag on block header | -| Add to selection | Mod+Click on blocks | +| Add to selection | Mod+Click or Shift+Click on blocks | ## Quick Reference — Workspaces | Action | How | @@ -71,13 +71,15 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | Action | How | |--------|-----| | Add a block | Drag from Toolbar panel, or right-click canvas → Add Block | -| Multi-select blocks | Mod+Click additional blocks, or shift-drag selection box | +| Multi-select blocks | Mod+Click or Shift+Click additional blocks, or Shift+drag a selection box | | Copy/Paste blocks | Mod+C / Mod+V | | Duplicate/Delete blocks | Right-click → action | | Rename a block | Click block name in header | | Enable/Disable block | Right-click → Enable/Disable | | Lock/Unlock block | Hover block → Click lock icon (Admin only) | | Toggle handle orientation | Right-click → Toggle Handles | +| Open a block in the Editor panel | Right-click → Open Editor | +| Move a block out of a loop/parallel | Right-click → Remove from Subflow | | Configure a block | Select block → use Editor panel on right | ## Quick Reference — Connections @@ -111,6 +113,6 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa |--------|-----| | Add/Edit/Delete workflow variable | Panel → Variables → Add Variable | | Add environment variable | Settings → Environment Variables → Add | -| Reference workflow variable | Use syntax | +| Reference workflow variable | Use syntax | | Reference environment variable | Use {{ENV_VAR}} syntax | ` diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index c27fbd737c..8e69e1dce8 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -97,63 +97,4 @@ describe('executeOpenResource', () => { ], }) }) - - it('opens workflow alias file paths through workspace file reference resolution', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_plan_file', - name: 'implementation.md', - folderPath: 'system/workflows/My Workflow/.plans', - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', path: 'workflows/My%20Workflow/.plans/implementation.md' }], - }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } - ) - - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'workflows/My%20Workflow/.plans/implementation.md' - ) - expect(result).toMatchObject({ - success: true, - resources: [ - { - type: 'file', - id: 'wf_plan_file', - title: 'implementation.md', - path: 'files/system/workflows/My%20Workflow/.plans/implementation.md', - }, - ], - }) - }) - - it('opens root plan alias file paths through workspace file reference resolution', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_root_plan', - name: 'root.md', - folderPath: 'system/.plans', - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', path: '.plans/root.md' }], - }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } - ) - - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', '.plans/root.md') - expect(result).toMatchObject({ - success: true, - resources: [ - { - type: 'file', - id: 'wf_root_plan', - title: 'root.md', - path: 'files/system/.plans/root.md', - }, - ], - }) - }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index f14a565c29..322c5524e5 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -269,15 +269,6 @@ describe('vfs mv/cp', () => { }) expect(result.success).toBe(true) }) - - it('rejects reserved alias backing paths', async () => { - const result = await executeVfsMv( - { sources: ['files/.plans/wf_1/launch.md'], destination: 'files/launch.md' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('Reserved system paths') - }) }) describe('workflows', () => { @@ -418,14 +409,11 @@ describe('vfs mv/cp', () => { }) }) - it('rejects flat namespaces and reserved paths', async () => { - const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, context) + it('rejects flat namespaces', async () => { + const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) expect(result.success).toBe(false) expect(result.output).toMatchObject({ - results: [ - { from: 'tables/CRM', error: expect.stringContaining('flat namespace') }, - { from: 'files/.plans/wf_1', error: expect.stringContaining('Reserved') }, - ], + results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], }) expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 44682d3e61..e5b7ffefda 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -16,10 +16,13 @@ import { decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRequestId } from '@/lib/core/utils/request' -import { getKnowledgeBases, updateKnowledgeBase } from '@/lib/knowledge/service' -import { listTables, renameTable } from '@/lib/table/service' +import { + deleteKnowledgeBase, + getKnowledgeBases, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import { deleteTable, listTables, renameTable } from '@/lib/table/service' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -27,16 +30,20 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, + resolveWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performCreateFolder, + performDeleteFolder, + performDeleteWorkflow, performUpdateFolder, performUpdateWorkflow, } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { + performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, performUpdateWorkspaceFileFolder, } from '@/lib/workspace-files/orchestration' @@ -57,6 +64,17 @@ const CATEGORY_REJECTIONS: Record = { 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', } +/** + * Same categories as CATEGORY_REJECTIONS, but the advice differs for a delete: + * an upload needs no cleanup and a recently-deleted item is already gone. + */ +const RM_CATEGORY_REJECTIONS: Record = { + uploads: + 'uploads/ files are chat-scoped and disappear with the chat — there is nothing to delete.', + 'recently-deleted': + 'recently-deleted/ items are already deleted. Use restore_resource to bring one back.', +} + interface VfsMutateOutcome { from: string to?: string @@ -70,13 +88,17 @@ function topLevelSegment(path: string): string { return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' } -function classifyCategory(path: string): { category: MutateCategory } | { error: string } { +function classifyCategory( + path: string, + rejections: Record = CATEGORY_REJECTIONS, + verbNoun = 'movable' +): { category: MutateCategory } | { error: string } { const top = topLevelSegment(path) if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } - const rejection = CATEGORY_REJECTIONS[top] + const rejection = rejections[top] if (rejection) return { error: rejection } return { - error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, + error: `"${path}" is not a ${verbNoun} resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, } } @@ -96,7 +118,10 @@ function assertMutationNotAborted(context: ExecutionContext): void { } } -function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { +function buildResult( + verb: MutateVerb | 'mkdir' | 'rm', + outcomes: VfsMutateOutcome[] +): ToolCallResult { const failed = outcomes.filter((o) => o.error) if (failed.length === outcomes.length) { return { @@ -161,11 +186,6 @@ export async function executeVfsMkdir( outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) continue } - if (top === 'files' && isWorkflowAliasBackingPath(path)) { - outcomes.push({ from: path, kind, error: `Reserved system path: ${path}` }) - continue - } - try { assertMutationNotAborted(context) let folderId: string | null @@ -348,15 +368,6 @@ async function mutateWorkspaceFiles( error: 'Workspace files cannot be copied — cp only duplicates workflows.', } } - for (const path of [...sources, destination]) { - if (isWorkflowAliasBackingPath(path)) { - return { - success: false, - error: `Reserved system paths cannot be moved or renamed: ${path}`, - } - } - } - const dest = await planDestination({ destination, sourceCount: sources.length, @@ -508,6 +519,36 @@ function makeWorkflowFolderEnsurer( } } +interface WorkflowRow { + id: string + name: string + folderId: string | null +} + +/** + * Every workflow in the workspace keyed by its canonical VFS directory, so a + * path resolves without a query per path. Shared by mv/cp and rm, which ask the + * same question of a workflows/ path: is this a workflow or a folder? + */ +async function loadWorkflowsByVfsPath( + workspaceId: string, + folderPathById: Map +): Promise> { + const rows = await db + .select({ id: workflowTable.id, name: workflowTable.name, folderId: workflowTable.folderId }) + .from(workflowTable) + .where(eq(workflowTable.workspaceId, workspaceId)) + const byPath = new Map() + for (const row of rows) { + const dir = canonicalWorkflowVfsDir({ + name: row.name, + folderPath: row.folderId ? folderPathById.get(row.folderId) : null, + }) + if (!byPath.has(dir)) byPath.set(dir, row) + } + return byPath +} + async function mutateWorkflows( verb: MutateVerb, sources: string[], @@ -518,22 +559,7 @@ async function mutateWorkflows( const index = await loadWorkflowFolderIndex(workspaceId) const { folderPathById, folderIdByPath } = index - const workflowRows = await db - .select({ - id: workflowTable.id, - name: workflowTable.name, - folderId: workflowTable.folderId, - }) - .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) - const workflowByPath = new Map() - for (const row of workflowRows) { - const dir = canonicalWorkflowVfsDir({ - name: row.name, - folderPath: row.folderId ? folderPathById.get(row.folderId) : null, - }) - if (!workflowByPath.has(dir)) workflowByPath.set(dir, row) - } + const workflowByPath = await loadWorkflowsByVfsPath(workspaceId, folderPathById) const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) @@ -550,7 +576,7 @@ async function mutateWorkflows( // Resolve every source against the in-memory maps before mutating anything. type SourceRef = - | { source: string; workflow: (typeof workflowRows)[number] } + | { source: string; workflow: WorkflowRow } | { source: string; folderId: string } | { source: string; error: string } const refs: SourceRef[] = [] @@ -766,3 +792,275 @@ async function renameFlatResource( { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, ]) } + +/** + * rm over the VFS: deletes the resource each path names. Every delete here is + * SOFT — the resource lands in recently-deleted/ and restore_resource brings it + * back — so this is the product's delete, not a purge. + * + * Scope is deliberately "things with a path". Removing something INSIDE a + * resource (a table row, a KB document, a workflow block) is an edit to that + * resource and stays with its owning tool. + */ +export async function executeVfsRm( + params: Record, + context: ExecutionContext +): Promise { + try { + const paths = normalizeSources(params.paths) + if (paths.length === 0) { + return { success: false, error: 'paths is required (an array of VFS paths to delete)' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + // Loaded at most once, and only when a workflows/ path in this call needs it. + let workflowIndex: Promise | undefined + const getWorkflowIndex = () => (workflowIndex ??= loadWorkflowRemoveIndex(workspaceId)) + + const outcomes: VfsMutateOutcome[] = [] + for (const path of paths) { + const classified = classifyCategory(path, RM_CATEGORY_REJECTIONS, 'deletable') + if ('error' in classified) { + outcomes.push({ from: path, kind: defaultKindFor(path), error: classified.error }) + continue + } + try { + assertMutationNotAborted(context) + outcomes.push( + await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) + ) + } catch (error) { + outcomes.push({ from: path, kind: defaultKindFor(path), error: toError(error).message }) + } + } + + return buildResult('rm', outcomes) + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +/** Best-effort kind for an outcome that failed before the resource was identified. */ +function defaultKindFor(path: string): VfsMutateOutcome['kind'] { + switch (topLevelSegment(path)) { + case 'workflows': + return 'workflow' + case 'tables': + return 'table' + case 'knowledgebases': + return 'knowledge_base' + default: + return 'file' + } +} + +function removeOne( + category: MutateCategory, + path: string, + context: ExecutionContext, + workspaceId: string, + getWorkflowIndex: () => Promise +): Promise { + switch (category) { + case 'files': + return removeWorkspaceFilePath(path, context, workspaceId) + case 'workflows': + return removeWorkflowPath(path, context, workspaceId, getWorkflowIndex) + case 'tables': + return removeTablePath(path, context, workspaceId) + case 'knowledgebases': + return removeKnowledgeBasePath(path, context, workspaceId) + } +} + +/** + * A files/ path is either a leaf file or a folder, and the two cannot collide, + * so resolving the file first and falling back to the folder is unambiguous. + * Both go through performDeleteWorkspaceFileItems — deleting a folder archives + * the files and subfolders inside it. + */ +async function removeWorkspaceFilePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const file = await resolveWorkspaceFileReference(workspaceId, path) + if (file) { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: context.userId, + fileIds: [file.id], + }) + if (!result.success) { + return { from: path, kind: 'file', id: file.id, error: result.error || 'Failed to delete' } + } + logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) + return { from: path, kind: 'file', id: file.id } + } + + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { + return { from: path, kind: 'file', error: 'Path must name a file or folder under files/' } + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: context.userId, + folderIds: [folderId], + }) + if (!result.success) { + return { + from: path, + kind: 'file_folder', + id: folderId, + error: result.error || 'Failed to delete', + } + } + logger.info('Deleted file folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'file_folder', id: folderId } +} + +interface WorkflowRemoveIndex { + workflowByPath: Map + folderIdByPath: Map +} + +async function loadWorkflowRemoveIndex(workspaceId: string): Promise { + const { folderPathById, folderIdByPath } = await loadWorkflowFolderIndex(workspaceId) + return { + workflowByPath: await loadWorkflowsByVfsPath(workspaceId, folderPathById), + folderIdByPath, + } +} + +/** + * Workflow first, then folder — the same resolution order mv uses. The lock + * assertions are what make a locked workflow (or one inside a locked folder) + * fail here rather than silently archiving. + */ +async function removeWorkflowPath( + path: string, + context: ExecutionContext, + workspaceId: string, + getWorkflowIndex: () => Promise +): Promise { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { + return { + from: path, + kind: 'workflow', + error: 'Path must name a workflow or folder under workflows/', + } + } + const encoded = encodeVfsPathSegments(segments) + const { workflowByPath, folderIdByPath } = await getWorkflowIndex() + + const workflow = workflowByPath.get(`workflows/${encoded}`) + if (workflow) { + await assertWorkflowMutable(workflow.id) + const result = await performDeleteWorkflow({ workflowId: workflow.id, userId: context.userId }) + if (!result.success) { + return { + from: path, + kind: 'workflow', + id: workflow.id, + error: result.error || 'Failed to delete workflow', + } + } + logger.info('Deleted workflow via rm', { workflowId: workflow.id, workspaceId }) + return { from: path, kind: 'workflow', id: workflow.id } + } + + const folderId = folderIdByPath.get(encoded) + if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } + + await assertFolderMutable(folderId) + const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId }) + if (!result.success) { + return { + from: path, + kind: 'workflow_folder', + id: folderId, + error: result.error || 'Failed to delete folder', + } + } + logger.info('Deleted workflow folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'workflow_folder', id: folderId } +} + +/** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ +function flatResourceName(path: string, category: 'tables' | 'knowledgebases'): string | null { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length !== 1) return null + return normalizeVfsSegment(segments[0]) +} + +async function removeTablePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const canonical = flatResourceName(path, 'tables') + if (!canonical) { + return { + from: path, + kind: 'table', + error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', + } + } + const match = (await listTables(workspaceId)).find( + (table) => normalizeVfsSegment(table.name) === canonical + ) + if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } + + await deleteTable(match.id, generateRequestId(), context.userId) + logger.info('Archived table via rm', { tableId: match.id, workspaceId }) + return { from: path, kind: 'table', id: match.id } +} + +async function removeKnowledgeBasePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const canonical = flatResourceName(path, 'knowledgebases') + if (!canonical) { + return { + from: path, + kind: 'knowledge_base', + error: + 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', + } + } + if (canonical === normalizeVfsSegment('connectors')) { + return { + from: path, + kind: 'knowledge_base', + error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', + } + } + const match = (await getKnowledgeBases(context.userId, workspaceId)).find( + (kb) => normalizeVfsSegment(kb.name) === canonical + ) + if (!match) + return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } + + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + if (!access.hasAccess) { + return { + from: path, + kind: 'knowledge_base', + id: match.id, + error: `Write access required to delete knowledge base "${match.name}"`, + } + } + + await deleteKnowledgeBase(match.id, generateRequestId()) + logger.info('Deleted knowledge base via rm', { knowledgeBaseId: match.id, workspaceId }) + return { from: path, kind: 'knowledge_base', id: match.id } +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index fd4a515a8a..0a1992ddd3 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -275,7 +275,10 @@ export async function executeVfsRead( success: false, error: isOversizedReadPlaceholder(uploadResult.content) ? uploadResult.content - : 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + : // Same as the workspace-file branch below: this size gate runs on + // the whole upload before any window, so "retry with offset/limit" + // would loop. Point at grep scoped to this path instead. + `Read result too large to return inline. Grep this single upload instead of reading it — grep({pattern: "...", path: "${path}"}) — because offset/limit do NOT shrink an upload read: the size check runs on the whole file before the window is applied.`, } } const windowedUpload = applyWindow(uploadResult) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index a6a1d675dc..8aea602d32 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -28,14 +28,7 @@ import { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, } from '@/lib/workflows/executor/execution-state' -import { - performCreateFolder, - performCreateWorkflow, - performDeleteFolder, - performDeleteWorkflow, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' +import { performCreateWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -349,15 +342,9 @@ function notifyWorkflowUpdated(workflowId: string): void { } import type { - CreateFolderParams, CreateWorkflowParams, - DeleteFolderParams, - DeleteWorkflowParams, GenerateApiKeyParams, - ManageFolderParams, - MoveFolderParams, MoveWorkflowParams, - RenameFolderParams, RenameWorkflowParams, RunBlockParams, RunFromBlockParams, @@ -461,51 +448,6 @@ export async function executeCreateWorkflow( } } -export async function executeCreateFolder( - params: CreateFolderParams, - context: ExecutionContext -): Promise { - try { - const name = typeof params?.name === 'string' ? params.name.trim() : '' - if (!name) { - return { success: false, error: 'name is required' } - } - if (name.length > 200) { - return { success: false, error: 'Folder name must be 200 characters or less' } - } - - const workspaceId = - params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - const parentId = params?.parentId || null - - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - await assertFolderMutable(parentId) - assertWorkflowMutationNotAborted(context) - - const result = await performCreateFolder({ - userId: context.userId, - workspaceId, - name, - parentId, - }) - if (!result.success || !result.folder) { - return { success: false, error: result.error || 'Failed to create folder' } - } - - return { - success: true, - output: { - folderId: result.folder.id, - name: result.folder.name, - workspaceId: result.folder.workspaceId, - parentId: result.folder.parentId, - }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunWorkflow( params: RunWorkflowParams, context: ExecutionContext @@ -773,47 +715,6 @@ export async function executeMoveWorkflow( } } -export async function executeMoveFolder( - params: MoveFolderParams, - context: ExecutionContext -): Promise { - try { - const folderId = params.folderId - if (!folderId) { - return { success: false, error: 'folderId is required' } - } - - const parentId = params.parentId || null - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - if (!(await verifyFolderWorkspace(folderId, workspaceId))) { - return { success: false, error: 'Folder not found' } - } - if (parentId && !(await verifyFolderWorkspace(parentId, workspaceId))) { - return { success: false, error: 'Parent folder not found' } - } - - await assertFolderMutable(folderId) - await assertFolderMutable(parentId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateFolder({ - folderId, - workspaceId, - userId: context.userId, - parentId, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to move folder' } - } - - return { success: true, output: { folderId, parentId } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunWorkflowUntilBlock( params: RunWorkflowUntilBlockParams, context: ExecutionContext @@ -1096,143 +997,6 @@ export async function executeSetBlockEnabled( } } -export async function executeDeleteWorkflow( - params: DeleteWorkflowParams, - context: ExecutionContext -): Promise { - try { - const workflowIds = params.workflowIds - if (!workflowIds || workflowIds.length === 0) { - return { success: false, error: 'workflowIds is required' } - } - - const deleted: Array<{ workflowId: string; name: string }> = [] - const failed: string[] = [] - - for (const workflowId of workflowIds) { - try { - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - - const result = await performDeleteWorkflow({ workflowId, userId: context.userId }) - if (result.success) { - deleted.push({ workflowId, name: workflowRecord.name }) - } else { - failed.push(workflowId) - } - } catch { - failed.push(workflowId) - } - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -export async function executeDeleteFolder( - params: DeleteFolderParams, - context: ExecutionContext -): Promise { - try { - const folderIds = params.folderIds - if (!folderIds || folderIds.length === 0) { - return { success: false, error: 'folderIds is required' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - const folders = await listFolders(workspaceId) - const deleted: string[] = [] - const failed: string[] = [] - - for (const folderId of folderIds) { - const folder = folders.find((f) => f.folderId === folderId) - if (!folder) { - failed.push(folderId) - continue - } - - assertWorkflowMutationNotAborted(context) - - try { - await assertFolderMutable(folderId) - - const result = await performDeleteFolder({ - folderId, - workspaceId, - userId: context.userId, - folderName: folder.folderName, - }) - - if (result.success) { - deleted.push(folderId) - } else { - failed.push(folderId) - } - } catch { - failed.push(folderId) - } - } - - return { success: deleted.length > 0, output: { deleted, failed } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -async function executeRenameFolder( - params: RenameFolderParams, - context: ExecutionContext -): Promise { - try { - const folderId = params.folderId - if (!folderId) { - return { success: false, error: 'folderId is required' } - } - const name = typeof params.name === 'string' ? params.name.trim() : '' - if (!name) { - return { success: false, error: 'name is required' } - } - if (name.length > 200) { - return { success: false, error: 'Folder name must be 200 characters or less' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - if (!(await verifyFolderWorkspace(folderId, workspaceId))) { - return { success: false, error: 'Folder not found' } - } - - await assertFolderMutable(folderId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateFolder({ - folderId, - workspaceId, - userId: context.userId, - name, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename folder' } - } - - return { success: true, output: { folderId, name } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - /** * Strip the `workflows/` VFS prefix from a folder path, returning the * folder-relative remainder. `workflows` (or an empty path) maps to the @@ -1285,116 +1049,6 @@ function resolveFolderIdByPath( return { folderId } } -/** Resolve the folder a manage_folder op targets, preferring folderId over path. */ -async function resolveManageFolderTarget( - params: ManageFolderParams, - getFolderPaths: () => Promise -): Promise<{ folderId: string } | { error: string }> { - const directId = typeof params.folderId === 'string' ? params.folderId.trim() : '' - if (directId) return { folderId: directId } - const path = typeof params.path === 'string' ? params.path.trim() : '' - if (!path) return { error: 'Provide the folder path (e.g. "workflows/Marketing") or folderId.' } - return resolveFolderIdByPath(path, await getFolderPaths()) -} - -/** - * Resolve the destination parent for move/create. parentId/destinationPath are - * optional; their absence (or an explicit root) targets the workspace root - * (parentId null). - */ -async function resolveManageFolderParent( - params: ManageFolderParams, - getFolderPaths: () => Promise -): Promise<{ parentId: string | null } | { error: string }> { - const directId = typeof params.parentId === 'string' ? params.parentId.trim() : '' - if (directId) return { parentId: directId } - if (params.parentId === null) return { parentId: null } - const dest = typeof params.destinationPath === 'string' ? params.destinationPath.trim() : '' - if (!dest || !workflowFolderRelativePath(dest)) return { parentId: null } - const parent = resolveFolderIdByPath(dest, await getFolderPaths(), 'Destination folder') - if ('error' in parent) return parent - return { parentId: parent.folderId } -} - -/** - * Single entry point for folder CRUD (create/rename/move/delete). Resolves the - * VFS-path/folderId handles, then delegates to the existing folder handlers so - * all DB orchestration (performCreateFolder / performUpdateFolder / - * performDeleteFolder) stays in one place. - */ -export async function executeManageFolder( - params: ManageFolderParams, - context: ExecutionContext -): Promise { - try { - const operation = typeof params?.operation === 'string' ? params.operation.trim() : '' - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - - // Fetch the workspace folder list at most once, lazily — only when a path - // (vs an explicit id) actually needs resolving, and shared across the - // target + parent lookups a single move/create performs. - let folderPathsPromise: Promise | undefined - const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathIndex(workspaceId)) - - switch (operation) { - case 'create': { - let name = typeof params.name === 'string' ? params.name.trim() : '' - let parentId: string | null = null - const path = typeof params.path === 'string' ? params.path.trim() : '' - if (!name && path) { - const segments = decodeVfsPathSegments(workflowFolderRelativePath(path)) - if (segments.length === 0) { - return { success: false, error: 'create requires a folder name or path' } - } - name = segments[segments.length - 1] - const parentSegments = segments.slice(0, -1) - if (parentSegments.length > 0) { - const parent = resolveFolderIdByPath( - encodeVfsPathSegments(parentSegments), - await getFolderPaths(), - 'Parent folder' - ) - if ('error' in parent) return { success: false, error: parent.error } - parentId = parent.folderId - } - } else { - const parent = await resolveManageFolderParent(params, getFolderPaths) - if ('error' in parent) return { success: false, error: parent.error } - parentId = parent.parentId - } - if (!name) return { success: false, error: 'create requires a folder name or path' } - return executeCreateFolder({ name, parentId: parentId ?? undefined, workspaceId }, context) - } - case 'rename': { - const name = typeof params.name === 'string' ? params.name.trim() : '' - if (!name) return { success: false, error: 'rename requires a new name' } - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - return executeRenameFolder({ folderId: target.folderId, name }, context) - } - case 'move': { - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - const parent = await resolveManageFolderParent(params, getFolderPaths) - if ('error' in parent) return { success: false, error: parent.error } - return executeMoveFolder({ folderId: target.folderId, parentId: parent.parentId }, context) - } - case 'delete': { - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - return executeDeleteFolder({ folderIds: [target.folderId] }, context) - } - default: - return { - success: false, - error: `Unknown operation "${operation}". Use create, rename, move, or delete.`, - } - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/local-filesystem.ts b/apps/sim/lib/copilot/tools/local-filesystem.ts new file mode 100644 index 0000000000..db6fe72b47 --- /dev/null +++ b/apps/sim/lib/copilot/tools/local-filesystem.ts @@ -0,0 +1,23 @@ +/** + * Granted local folders are addressed through the ordinary VFS: the model uses + * `read`/`grep`/`glob` against paths under `user-local/`, exactly as it does + * for workspace files. There is no separate local toolset, and local files are + * read-only. + */ +export const USER_LOCAL_VFS_ROOT = 'user-local' + +export function hasUserLocalVfsPrefix(value: unknown): value is string { + if (typeof value !== 'string') return false + return value === USER_LOCAL_VFS_ROOT || value.startsWith(`${USER_LOCAL_VFS_ROOT}/`) +} + +export function isUserLocalVfsToolCall( + name: string, + args: Record | undefined +): boolean { + if (!args) return false + if (name === 'read') return hasUserLocalVfsPrefix(args.path) + if (name === 'grep') return hasUserLocalVfsPrefix(args.path) + if (name === 'glob') return hasUserLocalVfsPrefix(args.pattern) + return false +} diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 146d023777..1d5e362954 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -6,7 +6,6 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { inferContentType } from './workspace-file' const logger = createLogger('CreateFileServerTool') @@ -27,7 +26,6 @@ interface CreateFileResult { name: string contentType: string vfsPath: string - backingVfsPath?: string } } @@ -52,13 +50,6 @@ export const createFileServerTool: BaseServerTool -} - -interface DeleteFileResult { - success: boolean - message: string - data?: { - deleted: { id: string; name: string }[] - failed: string[] - } -} - -export const deleteFileServerTool: BaseServerTool = { - name: DeleteFile.id, - async execute(params: DeleteFileArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - const nested = params.args - const paths: string[] = - params.paths ?? - (nested?.paths as string[] | undefined) ?? - [params.path || (nested?.path as string) || ''].filter(Boolean) - const legacyFileIds: string[] = - params.fileIds ?? - (nested?.fileIds as string[] | undefined) ?? - [params.fileId || (nested?.fileId as string) || ''].filter(Boolean) - - if (paths.length === 0 && legacyFileIds.length === 0) { - return { success: false, message: 'paths is required' } - } - - const deletable: { id: string; name: string }[] = [] - const failed: string[] = [] - - for (const path of paths) { - const existingFile = await resolveWorkspaceFileReference(workspaceId, path) - if (!existingFile) { - failed.push(path) - continue - } - deletable.push({ id: existingFile.id, name: existingFile.name }) - } - - for (const fileId of legacyFileIds) { - const existingFile = await getWorkspaceFile(workspaceId, fileId) - if (!existingFile) { - failed.push(fileId) - continue - } - deletable.push({ id: fileId, name: existingFile.name }) - } - - if (deletable.length > 0) { - assertServerToolNotAborted(context) - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - fileIds: deletable.map((file) => file.id), - }) - if (!result.success) { - return { success: false, message: result.error || 'Failed to delete files' } - } - } - - for (const file of deletable) { - logger.info('File deleted via delete_file', { - fileId: file.id, - name: file.name, - userId: context.userId, - }) - } - - const parts: string[] = [] - if (deletable.length > 0) - parts.push(`Deleted: ${deletable.map((file) => file.name).join(', ')}`) - if (failed.length > 0) parts.push(`Not found: ${failed.join(', ')}`) - - return { - success: deletable.length > 0, - message: parts.join('. '), - data: { deleted: deletable, failed }, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 5f2bf0b7e2..01fce21d37 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox, @@ -89,10 +88,11 @@ export async function getE2BDocFormat(fileName: string): Promise ({ - betaFlag: { value: false }, +const { mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({ mockLoadCompiledDoc: vi.fn(), mockRunSandboxTask: vi.fn(), })) @@ -28,9 +27,6 @@ vi.mock('./doc-compiled-store', () => ({ loadCompiledDoc: mockLoadCompiledDoc, storeCompiledDoc: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: vi.fn(async () => betaFlag.value), -})) vi.mock('@/app/api/files/utils', () => ({ getContentType: (name: string) => name.endsWith('.pdf') @@ -54,7 +50,6 @@ describe('resolveServableDocBytes', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isDocSandboxEnabled: true }) - betaFlag.value = false }) it('swaps generated-doc source for the compiled artifact + binary content type', async () => { @@ -148,10 +143,9 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).not.toHaveBeenCalled() }) - it('throws when a generated XLSX artifact is not ready (E2B + mothership-beta enabled)', async () => { + it('throws when a generated XLSX artifact is not ready (E2B enabled)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) setEnvFlags({ isDocSandboxEnabled: true }) - betaFlag.value = true await expect( resolveServableDocBytes({ @@ -165,8 +159,6 @@ describe('resolveServableDocBytes', () => { }) it('returns raw XLSX source when there is no workspaceId (xlsx has no isolated-vm path)', async () => { - betaFlag.value = true - const result = await resolveServableDocBytes({ rawBuffer: XLSX_SOURCE, fileName: 'sheet.xlsx', diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 9236438b1e..22c26d4b7b 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -8,7 +7,6 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -19,7 +17,6 @@ import { import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performCreateWorkspaceFileFolder, - performDeleteWorkspaceFileItems, performMoveWorkspaceFileItems, performUpdateWorkspaceFileFolder, } from '@/lib/workspace-files/orchestration' @@ -53,13 +50,6 @@ interface MoveFileFolderArgs extends WorkspaceScopedArgs { parentId?: string | null } -interface DeleteFileFolderArgs extends WorkspaceScopedArgs { - paths?: string[] - path?: string - folderIds?: string[] - folderId?: string -} - interface MoveFileArgs extends WorkspaceScopedArgs { paths?: string[] path?: string @@ -231,13 +221,6 @@ export const createFileFolderServerTool: BaseServerTool = { - name: DeleteFileFolder.id, - async execute( - params: DeleteFileFolderArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) - const folderIds = - paths.length > 0 - ? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path))) - : (params.folderIds ?? - stringArrayValue(payload?.folderIds) ?? - [stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean)) - if (folderIds.length === 0) return { success: false, message: 'paths is required' } - - assertServerToolNotAborted(context) - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - folderIds, - }) - if (!result.success || !result.deletedItems) { - return { success: false, message: result.error || 'Failed to delete file folders' } - } - - logger.info('File folders deleted via delete_file_folder', { - workspaceId, - folderIds, - folders: result.deletedItems.folders, - files: result.deletedItems.files, - userId: context.userId, - }) - - return { - success: result.deletedItems.folders > 0 || result.deletedItems.files > 0, - message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, - data: { ...result.deletedItems, deletedFolderIds: folderIds }, - } - } catch (error) { - return { success: false, message: toError(error).message } - } - }, -} - export const moveFileServerTool: BaseServerTool = { name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 7ddf618b38..95c14ea56e 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -8,9 +8,6 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' @@ -209,9 +206,8 @@ export async function compileDocForWrite(args: { if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { return { ok: false, - message: isDocSandboxEnabled - ? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.' - : 'Excel (.xlsx) generation requires the document sandbox, which is not enabled in this environment.', + message: + 'Excel (.xlsx) generation requires the document sandbox, which is not enabled in this environment.', } } @@ -293,32 +289,8 @@ export const workspaceFileServerTool: BaseServerTool = { [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], rename_file: ['*'], - [DeleteFile.id]: ['*'], [shareFileServerTool.name]: ['*'], move_file: ['*'], create_file_folder: ['*'], rename_file_folder: ['*'], move_file_folder: ['*'], - [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], [GenerateVideo.id]: ['generate'], @@ -177,14 +171,12 @@ const baseServerToolRegistry: Record = { [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, [renameFileServerTool.name]: renameFileServerTool, - [deleteFileServerTool.name]: deleteFileServerTool, [shareFileServerTool.name]: shareFileServerTool, [moveFileServerTool.name]: moveFileServerTool, [listFileFoldersServerTool.name]: listFileFoldersServerTool, [createFileFolderServerTool.name]: createFileFolderServerTool, [renameFileFolderServerTool.name]: renameFileFolderServerTool, [moveFileFolderServerTool.name]: moveFileFolderServerTool, - [deleteFileFolderServerTool.name]: deleteFileFolderServerTool, [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, [generateImageServerTool.name]: generateImageServerTool, [generateVideoServerTool.name]: generateVideoServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 8c515ef18f..b0dd887cb4 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -419,6 +419,37 @@ describe('userTableServerTool.import_file', () => { }) }) + it('points a chat-upload path at materialize_file instead of globbing files/', async () => { + mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) + + const result = await userTableServerTool.execute( + { + operation: 'import_file', + args: { tableId: 'tbl_1', fileId: 'uploads/people.csv' }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/materialize_file/) + expect(result.message).not.toMatch(/glob\("files/) + }) + + it('still tells the agent to glob files\\/ for a genuine workspace-file miss', async () => { + mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) + + const result = await userTableServerTool.execute( + { + operation: 'import_file', + args: { tableId: 'tbl_1', fileId: 'files/typo.csv' }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/File not found: "files\/typo\.csv"/) + }) + it('rejects a background import while another job holds the table slot', async () => { mockResolveWorkspaceFileReference.mockResolvedValueOnce({ name: 'big.csv', diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index a88e1dee90..9d3f35438f 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -115,6 +115,14 @@ const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) { const record = await resolveWorkspaceFileReference(workspaceId, fileReference) if (!record) { + // Only workspace files resolve here. A chat upload is a real, correctly-copied + // path, so pointing it at glob("files/**") would send the agent looking for a + // file that is not in that tree until materialize_file moves it there. + if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new Error( + `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } throw new Error( `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index da571a7cd0..feaa674f75 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -17,6 +17,7 @@ import { getToolCompletedTitle, getToolDisplayTitle, getToolStatusDisplayTitle, + getWaitCountdownTitle, humanizeToolName, mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' @@ -55,7 +56,7 @@ function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[] describe('humanizeToolName', () => { it('title-cases snake_case names', () => { - expect(humanizeToolName('manage_folder')).toBe('Manage Folder') + expect(humanizeToolName('manage_scheduled_task')).toBe('Manage Scheduled Task') }) it('title-cases kebab-case names', () => { @@ -231,17 +232,25 @@ describe('getToolDisplayTitle for the vfs verbs', () => { expect(getToolDisplayTitle('create_file')).toBe('Creating file') }) - it('shows deleted file and folder names', () => { + it('titles rm from toolTitle, falling back to the paths', () => { + expect(getToolDisplayTitle('rm', { toolTitle: 'Old Report.pdf' })).toBe( + 'Deleting Old Report.pdf' + ) + // rm spans categories, so with no toolTitle the paths are the only signal. expect( - getToolDisplayTitle('delete_file', { - paths: ['files/Reports/Old%20Report.pdf'], - }) - ).toBe('Deleting Old Report.pdf') - expect( - getToolDisplayTitle('delete_file_folder', { + getToolDisplayTitle('rm', { paths: ['files/Old%20Reports', 'files/Drafts'], }) ).toBe('Deleting Old Reports and Drafts') + expect( + getToolDisplayTitle('rm', { + paths: ['workflows/Lead%20Router'], + }) + ).toBe('Deleting Lead Router') + // rm has no TOOL_TITLES entry (its case always returns), so a bare call + // must not fall through to the humanizer and render as "Rm". + expect(getToolDisplayTitle('rm', {})).toBe('Deleting resource') + expect(getToolDisplayTitle('rm')).toBe('Deleting resource') }) it('uses the derived verb for mv titles', () => { @@ -280,8 +289,8 @@ describe('getToolDisplayTitle for workflow resources', () => { 'Editing Lead Router' ) expect( - getToolDisplayTitle('delete_workflow', { - workflowNames: ['Lead Router', 'Lead Enricher'], + getToolDisplayTitle('rm', { + paths: ['workflows/Lead%20Router', 'workflows/Lead%20Enricher'], }) ).toBe('Deleting Lead Router and Lead Enricher') }) @@ -313,16 +322,7 @@ describe('getToolDisplayTitle for managed resources', () => { }, 'Renaming Stripe to Production Stripe', ], - [ - 'manage_folder', - { operation: 'rename', path: 'workflows/Old%20Name', name: 'New Name' }, - 'Renaming Old Name to New Name', - ], - [ - 'manage_folder', - { operation: 'delete', path: 'workflows/Marketing/Q3%20Campaigns' }, - 'Deleting Q3 Campaigns', - ], + ['rm', { paths: ['workflows/Marketing/Q3%20Campaigns'] }, 'Deleting Q3 Campaigns'], ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], ['manage_skill', { operation: 'list' }, 'Viewing skills'], @@ -472,3 +472,134 @@ describe('getToolDisplayTitle for context management', () => { expect(getToolStatusDisplayTitle('Summarizing context', 'success')).toBe('Summarized context') }) }) + +describe('wait titles', () => { + // The row is on screen for the whole pause, so a bare "Wait" reads as a + // stall. The duration is the entire content of this tool. + it('names the duration it is pausing for', () => { + expect(getToolDisplayTitle('wait', { seconds: 30 })).toBe('Waiting 30s') + }) + + it('includes the reason when the model gives one', () => { + expect(getToolDisplayTitle('wait', { seconds: 15, reason: 'the test suite to finish' })).toBe( + 'Waiting 15s for the test suite to finish' + ) + }) + + it('degrades to a bare verb rather than printing a bogus duration', () => { + expect(getToolDisplayTitle('wait', {})).toBe('Waiting') + expect(getToolDisplayTitle('wait', { seconds: 0 })).toBe('Waiting') + expect(getToolDisplayTitle('wait', { seconds: 'soon' })).toBe('Waiting') + }) + + it('rounds a fractional duration instead of showing decimals', () => { + expect(getToolDisplayTitle('wait', { seconds: 2.4 })).toBe('Waiting 2s') + }) + + it('reads as past tense once the pause is over', () => { + expect(getToolCompletedTitle('Waiting 30s')).toBe('Waited 30s') + expect(getToolCompletedTitle('Waiting 15s for the test suite to finish')).toBe( + 'Waited 15s for the test suite to finish' + ) + }) +}) + +describe('terminal titles', () => { + const call = (operation: string, args?: Record) => + getToolDisplayTitle('terminal', { operation, ...(args ? { args } : {}) }) + + it('names the command being run', () => { + expect(call('run', { command: 'bun test' })).toBe('Running bun test') + }) + + it('titles each operation rather than reading as a bare "Terminal"', () => { + expect(call('read')).toBe('Reading terminal') + expect(call('input')).toBe('Typing into terminal') + expect(call('kill')).toBe('Stopping command') + expect(call('list')).toBe('Listing terminals') + expect(call('new')).toBe('Opening terminal') + expect(call('panes')).toBe('Listing tmux panes') + }) + + it('collapses newlines so a multi-line command stays one row', () => { + expect(call('run', { command: 'cd apps/sim\n bun test' })).toBe('Running cd apps/sim bun test') + }) + + it('truncates a long command rather than wrapping the row', () => { + const title = call('run', { command: 'echo '.repeat(40) }) + expect(title.length).toBeLessThanOrEqual('Running '.length + 48) + expect(title.endsWith('…')).toBe(true) + }) + + it('falls back to a generic label before the arguments have streamed in', () => { + expect(call('run')).toBe('Running command') + expect(getToolDisplayTitle('terminal', {})).toBe('Using terminal') + }) + + it('names what the user has to do when the terminal is handed over', () => { + expect(call('handoff', { reason: 'Enter your sudo password' })).toBe( + 'Waiting for you: Enter your sudo password' + ) + expect(call('handoff')).toBe('Waiting for you in the terminal') + }) + + it('still titles rows from before the tools were consolidated', () => { + // Persisted transcripts reference the old one-tool-per-operation names. + expect(getToolDisplayTitle('terminal_run', { command: 'bun test' })).toBe('Running bun test') + expect(getToolDisplayTitle('terminal_read')).toBe('Reading terminal') + }) + + it('reads as past tense once each terminal action settles', () => { + expect(getToolCompletedTitle('Running bun test')).toBe('Ran bun test') + expect(getToolCompletedTitle('Stopping command')).toBe('Stopped command') + expect(getToolCompletedTitle('Reading terminal')).toBe('Read terminal') + expect(getToolCompletedTitle('Using terminal')).toBe('Used terminal') + }) +}) + +describe('wait countdown', () => { + const args = { seconds: 30, reason: 'Claude Code to finish the summary' } + + it('starts at the full duration', () => { + expect(getWaitCountdownTitle(args, 0)).toBe('Waiting 30s for Claude Code to finish the summary') + }) + + it('counts down as the pause runs', () => { + expect(getWaitCountdownTitle(args, 5_000)).toBe( + 'Waiting 25s for Claude Code to finish the summary' + ) + expect(getWaitCountdownTitle(args, 29_000)).toBe( + 'Waiting 1s for Claude Code to finish the summary' + ) + }) + + it('holds a whole second rather than ticking on partial ones', () => { + expect(getWaitCountdownTitle(args, 999)).toBe(getWaitCountdownTitle(args, 0)) + expect(getWaitCountdownTitle(args, 1_001)).toBe(getWaitCountdownTitle(args, 1_999)) + }) + + it('drops the number at zero instead of freezing on "0s"', () => { + // The row can outlive its own countdown while the turn picks back up, and + // a stuck "0s" reads as broken. + expect(getWaitCountdownTitle(args, 30_000)).toBe( + 'Waiting for Claude Code to finish the summary' + ) + expect(getWaitCountdownTitle(args, 90_000)).toBe( + 'Waiting for Claude Code to finish the summary' + ) + }) + + it('never counts up from a clock that jumped backwards', () => { + expect(getWaitCountdownTitle(args, -5_000)).toBe( + 'Waiting 30s for Claude Code to finish the summary' + ) + }) + + it('stays a bare verb when there is no duration to count', () => { + expect(getWaitCountdownTitle({}, 3_000)).toBe('Waiting') + }) + + it('matches the static title at zero elapsed', () => { + expect(getWaitCountdownTitle(args, 0)).toBe(getToolDisplayTitle('wait', args)) + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5aee46abeb..091d289a5b 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -85,6 +85,18 @@ function namedOperationTitle( return display ? `${display.verb} ${target || display.resource}` : placeholder } +/** Compact form of a URL for titles: host + path, no scheme/query noise. */ +function displayUrl(raw: string): string { + if (!raw) return '' + try { + const url = new URL(raw) + const path = url.pathname === '/' ? '' : url.pathname + return `${url.host}${path}`.slice(0, 80) + } catch { + return raw.slice(0, 80) + } +} + function isWorkflowArtifactPath(path: string, filename: string): boolean { const trimmed = path.trim() return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) @@ -421,15 +433,11 @@ const TOOL_TITLES: Record = { generate_video: 'Generating video', generate_audio: 'Generating audio', ffmpeg: 'Processing media', - manage_folder: 'Folder action', check_deployment_status: 'Checking deployment status', complete_scheduled_task: 'Completing scheduled task', create_file: 'Creating file', create_file_folder: 'Creating folder', create_workspace_mcp_server: 'Creating MCP server', - delete_file: 'Deleting file', - delete_file_folder: 'Deleting folder', - delete_workflow: 'Deleting workflow', delete_workspace_mcp_server: 'Deleting MCP server', deploy_api: 'Deploying API', deploy_chat: 'Deploying chat', @@ -473,6 +481,20 @@ const TOOL_TITLES: Record = { update_deployment_version: 'Updating deployment', update_scheduled_task_history: 'Updating task history', update_workspace_mcp_server: 'Updating MCP server', + // Browser agent tools without an argument-aware title. + browser_go_back: 'Going back', + browser_go_forward: 'Going forward', + browser_switch_tab: 'Switching tab', + browser_close_tab: 'Closing tab', + browser_list_tabs: 'Listing tabs', + browser_list_sessions: 'Checking signed-in sites', + browser_snapshot: 'Scanning page', + browser_read_text: 'Reading page', + browser_screenshot: 'Taking screenshot', + browser_click: 'Clicking element', + browser_type: 'Typing text', + browser_select_option: 'Selecting option', + browser_hover: 'Hovering element', // Subagent trigger tools, when surfaced as a tool call. workflow: 'Workflow Agent', run: 'Run Agent', @@ -487,6 +509,7 @@ const TOOL_TITLES: Record = { search: 'Search Agent', file: 'File Agent', media: 'Media Agent', + browser: 'Browser Agent', superagent: 'Executing action', respond: 'Gathering thoughts', context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE, @@ -532,6 +555,86 @@ export function humanizeToolName(name: string): string { return humanizeDisplayIdentifier(name) } +/** One shape, so the live countdown and the settled title never diverge. */ +function formatWaitTitle(seconds: number, reason: string): string { + const duration = seconds > 0 ? ` ${seconds}s` : '' + return reason ? `Waiting${duration} for ${reason}` : `Waiting${duration}` +} + +function requestedWaitSeconds(args: ToolArgs): number { + const raw = args?.seconds + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.round(raw) : 0 +} + +/** + * The duration is the whole content of a pause: without it the row is an + * unexplained stall, which is what the user is staring at while it runs. + */ +function waitTitle(args: ToolArgs): string { + return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) +} + +/** + * The title of a pause that is still running, counting down what is left. + * + * A number that never changes next to a spinner looks the same as a hung turn, + * and a pause is the one row where the user is doing nothing but watching it. + * At zero the number is dropped rather than frozen at "0s", because the pause + * itself is over and what remains is the turn picking back up. + */ +export function getWaitCountdownTitle(args: ToolArgs, elapsedMs: number): string { + const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000)) + const remaining = Math.max(0, requestedWaitSeconds(args) - elapsedSeconds) + return formatWaitTitle(remaining, stringArg(args, 'reason')) +} + +/** Past this a command wraps the row; the terminal panel still shows it in full. */ +const MAX_COMMAND_TITLE_LENGTH = 48 + +function runningCommandTitle(rawCommand: string): string { + const command = rawCommand.replace(/\s+/g, ' ') + if (!command) return 'Running command' + const shortened = + command.length > MAX_COMMAND_TITLE_LENGTH + ? `${command.slice(0, MAX_COMMAND_TITLE_LENGTH - 1)}…` + : command + return `Running ${shortened}` +} + +const TERMINAL_OPERATION_TITLES: Record = { + read: 'Reading terminal', + input: 'Typing into terminal', + kill: 'Stopping command', + cwd: 'Checking terminal', + list: 'Listing terminals', + new: 'Opening terminal', + switch: 'Switching terminal', + close: 'Closing terminal', + panes: 'Listing tmux panes', +} + +/** + * The terminal tool carries what it does in `operation`, so the row title has + * to come from the arguments rather than the tool name — otherwise every shell + * action in the transcript reads simply "Terminal". + */ +function terminalTitle(args: ToolArgs): string { + const operation = stringArg(args, 'operation') + const nested = args?.args + const inner: ToolArgs = + nested && typeof nested === 'object' && !Array.isArray(nested) + ? (nested as Record) + : undefined + if (operation === 'run') return runningCommandTitle(stringArg(inner, 'command')) + if (operation === 'handoff') { + // Matches the browser takeover row: the reason is the whole point of the + // row, since it is what the user has to act on. + const reason = stringArg(inner, 'reason') + return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the terminal' + } + return TERMINAL_OPERATION_TITLES[operation] ?? 'Using terminal' +} + /** * Resolve a tool-call display title from its name and arguments. Argument-aware * cases come first, then the static map, then a humanized fallback. This never @@ -564,6 +667,31 @@ export function getToolDisplayTitle(name: string, args?: Record return materializeFileTitle(args) case 'open_resource': return openResourceTitle(args) + case 'wait': + return waitTitle(args) + case 'terminal': + return terminalTitle(args) + // The surface used to be one tool per operation. Conversations recorded + // then still reference those names, so they keep their titles rather than + // regressing to a humanized "Terminal Run". + case 'terminal_run': + return runningCommandTitle(stringArg(args, 'command')) + case 'terminal_read': + return 'Reading terminal' + case 'terminal_input': + return 'Typing into terminal' + case 'terminal_kill': + return 'Stopping command' + case 'terminal_cwd': + return 'Checking terminal' + case 'terminal_list': + return 'Listing terminals' + case 'terminal_new': + return 'Opening terminal' + case 'terminal_switch': + return 'Switching terminal' + case 'terminal_close': + return 'Closing terminal' case 'restore_resource': { const type = stringArg(args, 'type') return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` @@ -607,14 +735,6 @@ export function getToolDisplayTitle(name: string, args?: Record return setGlobalWorkflowVariablesTitle(args) case 'create_file': return createFileTitle(args) - case 'delete_file': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'file')}` - } - case 'delete_file_folder': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'folder')}` - } case 'share_file': { const action = stringArg(args, 'action') || 'share' const path = stringArg(args, 'path') @@ -629,13 +749,6 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'workflowName', 'name', 'title') return `Editing ${target || 'workflow'}` } - case 'delete_workflow': { - const target = summarizeTargets( - stringArrayArg(args, 'workflowNames'), - countedResourceTarget(args, 'workflowIds', 'workflow', 'workflows') - ) - return `Deleting ${target}` - } case 'create_workspace_mcp_server': { const target = firstStringArg(args, 'name', 'serverName', 'title') return `Creating ${target || 'MCP server'}` @@ -679,6 +792,14 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Creating ${target}` : 'Creating folder' } + case 'rm': { + // toolTitle is the model's phrasing; the paths are the fallback because + // rm spans categories and there is no one noun to count. + const target = + firstStringArg(args, 'toolTitle', 'title') || + summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource') + return target ? `Deleting ${target}` : 'Deleting' + } case 'enrichment_run': { const subject = nestedStringArg( args, @@ -695,6 +816,38 @@ export function getToolDisplayTitle(name: string, args?: Record const url = stringArg(args, 'url') return url ? `Scraping ${url}` : 'Scraping page' } + case 'browser_navigate': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url}` : 'Opening page' + } + case 'browser_open_url': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url}` : 'Opening page' + } + case 'browser_open_tab': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url} in a new tab` : 'Opening new tab' + } + case 'browser_wait_for': { + const text = stringArg(args, 'text') + return text ? `Waiting for "${text}"` : 'Waiting for page' + } + case 'browser_press_key': { + const key = stringArg(args, 'key') + return key ? `Pressing ${key}` : 'Pressing key' + } + case 'browser_scroll': { + const direction = stringArg(args, 'direction') + return direction ? `Scrolling ${direction}` : 'Scrolling page' + } + case 'browser_extract': { + const instruction = stringArg(args, 'instruction') + return instruction ? `Extracting ${instruction}` : 'Extracting page data' + } + case 'browser_request_takeover': { + const reason = stringArg(args, 'reason') + return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the browser' + } case 'crawl_website': { const url = stringArg(args, 'url') return url ? `Crawling ${url}` : 'Crawling website' @@ -763,32 +916,6 @@ export function getToolDisplayTitle(name: string, args?: Record delete: { verb: 'Deleting', resource: 'credential' }, }) } - case 'manage_folder': { - const operation = stringArg(args, 'operation') - if (operation === 'rename') { - const rawFrom = firstStringArg(args, 'oldPath', 'source', 'path', 'folderName') - const rawTo = firstStringArg(args, 'newPath', 'destination', 'newName', 'name', 'title') - const from = rawFrom ? pathLeaf(rawFrom) : '' - const to = rawTo ? pathLeaf(rawTo) : '' - if (from && to) return `Renaming ${from} to ${to}` - return to ? `Renaming folder to ${to}` : 'Renaming folder' - } - const rawTarget = firstStringArg( - args, - 'newPath', - 'destination', - 'path', - 'folderName', - 'name', - 'title' - ) - const target = rawTarget ? pathLeaf(rawTarget) : '' - return namedOperationTitle(args, target, 'Folder action', { - create: { verb: 'Creating', resource: 'folder' }, - move: { verb: 'Moving', resource: 'folder' }, - delete: { verb: 'Deleting', resource: 'folder' }, - }) - } case 'run_workflow': case 'run_from_block': case 'run_workflow_until_block': @@ -826,6 +953,8 @@ const COMPLETED_VERB_REWRITES: Record = { Cancelling: 'Cancelled', Calling: 'Called', Checking: 'Checked', + Clicking: 'Clicked', + Closing: 'Closed', Combining: 'Combined', Comparing: 'Compared', Completing: 'Completed', @@ -846,6 +975,8 @@ const COMPLETED_VERB_REWRITES: Record = { Gathering: 'Gathered', Generating: 'Generated', Getting: 'Got', + Going: 'Went', + Hovering: 'Hovered', Importing: 'Imported', Inspecting: 'Inspected', Listing: 'Listed', @@ -856,6 +987,7 @@ const COMPLETED_VERB_REWRITES: Record = { Opening: 'Opened', Overwriting: 'Overwrote', Preparing: 'Prepared', + Pressing: 'Pressed', Processing: 'Processed', Promoting: 'Promoted', Querying: 'Queried', @@ -867,19 +999,28 @@ const COMPLETED_VERB_REWRITES: Record = { Restoring: 'Restored', Running: 'Ran', Saving: 'Saved', + Scanning: 'Scanned', Scraping: 'Scraped', + Scrolling: 'Scrolled', Searching: 'Searched', + Selecting: 'Selected', Setting: 'Set', Sharing: 'Shared', + Stopping: 'Stopped', Summarizing: 'Summarized', + Switching: 'Switched', Syncing: 'Synced', + Taking: 'Took', Toggling: 'Toggled', Trimming: 'Trimmed', + Typing: 'Typed', Undeploying: 'Undeployed', Unsharing: 'Unshared', Updating: 'Updated', + Using: 'Used', Validating: 'Validated', Viewing: 'Viewed', + Waiting: 'Waited', Writing: 'Wrote', } diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index 0acdbbda43..764199f697 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -7,9 +7,6 @@ const mocks = vi.hoisted(() => { return { FileConflictError, - ensureWorkflowAliasBacking: vi.fn(), - ensureWorkspacePlanBacking: vi.fn(), - resolveWorkflowAliasForWorkspace: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), @@ -20,15 +17,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({ - ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking, - ensureWorkspacePlanBacking: mocks.ensureWorkspacePlanBacking, -})) - -vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ - resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, -})) - vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, @@ -45,244 +33,13 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' -describe('resource writer workflow aliases', () => { +describe('resource writer', () => { beforeEach(() => { vi.clearAllMocks() - mocks.ensureWorkflowAliasBacking.mockResolvedValue({}) - mocks.ensureWorkspacePlanBacking.mockResolvedValue({}) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id') }) - it('creates workflow plan aliases through backing workspace files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-plan', - name: 'launch.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'launch.md', - 'text/markdown', - { folderId: 'folder-id', exactName: true } - ) - expect(result).toMatchObject({ - id: 'file-plan', - vfsPath: 'workflows/My%20Workflow/.plans/launch.md', - backingVfsPath: 'files/.plans/wf_1/launch.md', - mode: 'create', - }) - }) - - it('overwrites workflow changelog aliases through backing workspace files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'changelog', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/changelog.md', - backingPath: 'files/.changelogs/wf_1.md', - backingFolderPath: 'files/.changelogs', - }) - mocks.getWorkspaceFileByName.mockResolvedValue({ - id: 'file-changelog', - name: 'wf_1.md', - type: 'text/markdown', - folderPath: '.changelogs', - }) - mocks.updateWorkspaceFileContent.mockResolvedValue({ - id: 'file-changelog', - name: 'wf_1.md', - size: 7, - type: 'text/markdown', - url: '/download', - folderPath: '.changelogs', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/changelog.md', - mode: 'overwrite', - }, - buffer: Buffer.from('updated'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.updateWorkspaceFileContent).toHaveBeenCalledWith( - 'workspace-1', - 'file-changelog', - 'user-1', - Buffer.from('updated'), - 'text/markdown' - ) - expect(result).toMatchObject({ - id: 'file-changelog', - vfsPath: 'workflows/My%20Workflow/changelog.md', - backingVfsPath: 'files/.changelogs/wf_1.md', - mode: 'overwrite', - }) - }) - - it('creates root workspace plan aliases through workspace backing files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: '.plans/root.md', - backingPath: 'files/.plans/workspace/root.md', - backingFolderPath: 'files/.plans/workspace', - planRelativePath: 'root.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-root-plan', - name: 'root.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: '.plans/root.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.ensureWorkspacePlanBacking).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'workspace'], - }) - expect(result).toMatchObject({ - id: 'file-root-plan', - vfsPath: '.plans/root.md', - backingVfsPath: 'files/.plans/workspace/root.md', - mode: 'create', - }) - }) - - it('rejects direct writes to reserved workflow alias backing paths', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - - await expect( - writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/.plans/wf_1/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - ).rejects.toThrow( - 'Reserved workflow alias backing paths must be accessed through their alias path' - ) - - expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('rejects validation of reserved workflow alias backing paths', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - - await expect( - validateWorkspaceFileWriteTarget({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/.changelogs/wf_1.md', - mode: 'overwrite', - }, - }) - ).rejects.toThrow( - 'Reserved workflow alias backing paths must be accessed through their alias path' - ) - - expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() - }) - - it('uses exact-name creates for alias backing files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-plan', - name: 'launch.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'launch.md', - 'text/markdown', - { folderId: 'folder-id', exactName: true } - ) - }) - it('auto-creates missing parent folders for plain workspace file creates', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.uploadWorkspaceFile.mockResolvedValue({ @@ -326,7 +83,6 @@ describe('resource writer workflow aliases', () => { }) it('validates create targets read-only, resolving existing parent folders without creating', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') mocks.getWorkspaceFileByName.mockResolvedValue(null) @@ -349,7 +105,6 @@ describe('resource writer workflow aliases', () => { }) it('accepts create targets with missing parent folders during validation without creating them', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) const validation = await validateWorkspaceFileWriteTarget({ @@ -370,35 +125,4 @@ describe('resource writer workflow aliases', () => { folderId: null, }) }) - - it('reports alias path when exact-name alias backing creation conflicts', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockRejectedValue(new mocks.FileConflictError('launch.md')) - - await expect( - writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - ).rejects.toThrow( - 'File already exists at workflows/My%20Workflow/.plans/launch.md. Use mode "overwrite" to update it.' - ) - }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 148c0ee033..33ad1c3530 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,24 +1,10 @@ import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { - ensureWorkflowAliasBacking, - ensureWorkspacePlanBacking, -} from '@/lib/copilot/vfs/workflow-alias-backing' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { - isPlanAliasPath, - isWorkflowAliasBackingPath, - WORKFLOW_CHANGELOG_BACKING_FOLDER, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - type WorkflowAliasTarget, -} from '@/lib/copilot/vfs/workflow-aliases' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, normalizeWorkspaceFileItemName, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { - FileConflictError, getWorkspaceFileByName, resolveWorkspaceFileReference, updateWorkspaceFileContent, @@ -41,7 +27,6 @@ export interface WorkspaceFileWriteResult { contentType: string downloadUrl?: string vfsPath: string - backingVfsPath?: string mode: WorkspaceFileWriteMode } @@ -55,7 +40,6 @@ export type WorkspaceFileWriteValidation = | { mode: 'create' vfsPath: string - backingVfsPath?: string fileName: string /** Null for root targets AND for parent chains that don't exist yet — validation is read-only; missing folders are created at write time. */ folderId: string | null @@ -63,7 +47,6 @@ export type WorkspaceFileWriteValidation = | { mode: 'overwrite' vfsPath: string - backingVfsPath?: string existingFileId: string } @@ -122,9 +105,7 @@ async function resolveCreateTarget( throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) } } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) if (!folderId) { return { fileName: parsed.fileName, @@ -151,138 +132,11 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) } -function assertNotReservedWorkflowAliasBackingPath(path: string): void { - if (isWorkflowAliasBackingPath(path)) { - throw new Error( - `Reserved workflow alias backing paths must be accessed through their alias path: ${path}` - ) - } -} - -async function resolveWorkflowAliasFileTarget(args: { - workspaceId: string - userId?: string - alias: WorkflowAliasTarget -}): Promise { - if (args.alias.kind === 'plans_dir') { - throw new Error(`Cannot write file content to plan alias directory: ${args.alias.aliasPath}`) - } - - if (args.userId && args.alias.scope === 'workflow') { - await ensureWorkflowAliasBacking({ - workspaceId: args.workspaceId, - userId: args.userId, - workflowId: args.alias.workflowId, - workflowName: args.alias.workflowName, - }) - } else if (args.userId && args.alias.scope === 'workspace') { - await ensureWorkspacePlanBacking({ - workspaceId: args.workspaceId, - userId: args.userId, - }) - } - - if (args.alias.kind === 'changelog') { - const folderSegments = [WORKFLOW_CHANGELOG_BACKING_FOLDER] - const folderId = args.userId - ? await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: folderSegments, - }) - : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { - includeReservedSystemFolders: true, - }) - if (!folderId) { - throw new Error( - `Workflow changelog backing folder is not provisioned for ${args.alias.aliasPath}` - ) - } - const fileName = `${args.alias.workflowId}.md` - return { - fileName, - folderId, - vfsPath: args.alias.aliasPath, - existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), - } - } - - const relativeSegments = decodeVfsPathSegments(args.alias.planRelativePath ?? '') - if (relativeSegments.length === 0) { - throw new Error(`Workflow plan alias must include a file path: ${args.alias.aliasPath}`) - } - const fileName = normalizeWorkspaceFileItemName(relativeSegments.at(-1) ?? '', 'File') - const folderSegments = [ - WORKFLOW_PLANS_BACKING_FOLDER, - args.alias.scope === 'workflow' ? args.alias.workflowId : WORKSPACE_PLANS_BACKING_FOLDER, - ...relativeSegments.slice(0, -1), - ].map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) - const folderId = args.userId - ? await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: folderSegments, - }) - : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { - includeReservedSystemFolders: true, - }) - if (!folderId) { - throw new Error(`Plan backing directory is not provisioned for ${args.alias.aliasPath}.`) - } - - return { - fileName, - folderId, - vfsPath: args.alias.aliasPath, - existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), - } -} - export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string userId?: string target: WorkspaceFileWriteTarget }): Promise { - const alias = await resolveWorkflowAliasForWorkspace({ - workspaceId: args.workspaceId, - path: args.target.path, - }) - if (!alias && isPlanAliasPath(args.target.path)) { - throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) - } - if (alias) { - const resolved = await resolveWorkflowAliasFileTarget({ - workspaceId: args.workspaceId, - userId: args.userId, - alias, - }) - if (args.target.mode === 'overwrite') { - if (!resolved.existingFile) { - throw new Error(`File not found for overwrite: ${alias.aliasPath}`) - } - return { - mode: 'overwrite', - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - existingFileId: resolved.existingFile.id, - } - } - if (resolved.existingFile) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - return { - mode: 'create', - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - fileName: resolved.fileName, - folderId: resolved.folderId, - } - } - - assertNotReservedWorkflowAliasBackingPath(args.target.path) - if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { @@ -312,77 +166,6 @@ export async function writeWorkspaceFileByPath(args: { inferredMimeType: string }): Promise { const contentType = args.target.mimeType || args.inferredMimeType - const alias = await resolveWorkflowAliasForWorkspace({ - workspaceId: args.workspaceId, - path: args.target.path, - }) - if (!alias && isPlanAliasPath(args.target.path)) { - throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) - } - if (alias) { - const resolved = await resolveWorkflowAliasFileTarget({ - workspaceId: args.workspaceId, - userId: args.userId, - alias, - }) - - if (args.target.mode === 'overwrite') { - if (!resolved.existingFile) { - throw new Error(`File not found for overwrite: ${alias.aliasPath}`) - } - const updated = await updateWorkspaceFileContent( - args.workspaceId, - resolved.existingFile.id, - args.userId, - args.buffer, - contentType || resolved.existingFile.type - ) - return { - id: updated.id, - name: updated.name, - size: updated.size, - contentType: updated.type, - downloadUrl: updated.url, - vfsPath: alias.aliasPath, - backingVfsPath: vfsPathForRecord(updated), - mode: 'overwrite', - } - } - - if (resolved.existingFile) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - const uploaded = await uploadWorkspaceFile( - args.workspaceId, - args.userId, - args.buffer, - resolved.fileName, - contentType, - { folderId: resolved.folderId, exactName: true } - ).catch((error: unknown) => { - if (error instanceof FileConflictError) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - throw error - }) - return { - id: uploaded.id, - name: uploaded.name, - size: uploaded.size, - contentType: uploaded.type, - downloadUrl: uploaded.url, - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - mode: 'create', - } - } - - assertNotReservedWorkflowAliasBackingPath(args.target.path) - if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts deleted file mode 100644 index e770b516c7..0000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { dbChainMockFns, drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - ensureWorkspaceFileFolderPath: vi.fn(), - listWorkspaceFileFolders: vi.fn(), - getWorkspaceFileByName: vi.fn(), - uploadWorkspaceFile: vi.fn(), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, - listWorkspaceFileFolders: mocks.listWorkspaceFileFolders, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFileByName: mocks.getWorkspaceFileByName, - uploadWorkspaceFile: mocks.uploadWorkspaceFile, -})) - -import { cleanupWorkflowAliasBacking, ensureWorkflowAliasBacking } from './workflow-alias-backing' - -describe('workflow alias backing', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.ensureWorkspaceFileFolderPath.mockImplementation(({ pathSegments }) => - Promise.resolve(`folder:${pathSegments.join('/')}`) - ) - }) - - it('provisions reserved folders and creates a headed changelog when missing', async () => { - mocks.getWorkspaceFileByName - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'file-1', name: 'wf_1.md' }) - - const result = await ensureWorkflowAliasBacking({ - workspaceId: 'workspace-1', - userId: 'user-1', - workflowId: 'wf_1', - workflowName: 'My Workflow', - }) - - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.changelogs'], - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'wf_1'], - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'workspace'], - }) - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('# My Workflow Changelog\n', 'utf-8'), - 'wf_1.md', - 'text/markdown', - { folderId: 'folder:.changelogs' } - ) - expect(result.changelogFile).toMatchObject({ id: 'file-1' }) - }) - - it('reuses an existing changelog backing file', async () => { - mocks.getWorkspaceFileByName.mockResolvedValueOnce({ id: 'file-existing', name: 'wf_2.md' }) - - const result = await ensureWorkflowAliasBacking({ - workspaceId: 'workspace-1', - userId: 'user-1', - workflowId: 'wf_2', - }) - - expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() - expect(result.changelogFile).toMatchObject({ id: 'file-existing' }) - }) - - describe('cleanupWorkflowAliasBacking', () => { - /** - * Folder paths resolve independently of `deletedAt`, so a live file parented - * to an archived folder still resolves to a backing path. The archived - * `.changelogs` folder below therefore has to participate in file ownership - * while staying out of the set of folders that get archived. - */ - const folders = [ - { id: 'changelog-live', path: '.changelogs', deletedAt: null }, - { id: 'changelog-archived', path: '.changelogs', deletedAt: new Date() }, - { id: 'plans-wf1', path: '.plans/wf_1', deletedAt: null }, - { id: 'plans-wf1-nested', path: '.plans/wf_1/nested', deletedAt: null }, - { id: 'plans-wf1-archived', path: '.plans/wf_1/old', deletedAt: new Date() }, - { id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null }, - { id: 'unrelated', path: 'documents', deletedAt: null }, - ] - - beforeEach(() => { - mocks.listWorkspaceFileFolders.mockResolvedValue(folders) - }) - - it('scopes file ownership by folder id, including archived folders', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - - expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested', 'plans-wf1-archived']) - expect(inArrayValues).toContainEqual(['changelog-live', 'changelog-archived']) - }) - - it('archives only live folders owned by the workflow', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - - expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested']) - expect(inArrayValues.flat()).not.toContain('plans-wf2') - expect(inArrayValues.flat()).not.toContain('unrelated') - }) - - it('matches the changelog by the workflow-scoped filename', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'wf_1.md') - }) - - it('restricts the update to workspace-context files', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'workspace') - }) - - it('still archives the changelog when the workflow has no plans folder', async () => { - mocks.listWorkspaceFileFolders.mockResolvedValue([ - { id: 'changelog-live', path: '.changelogs', deletedAt: null }, - { id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null }, - ]) - - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - expect(inArrayValues).toContainEqual(['changelog-live']) - expect(inArrayValues.flat()).not.toContain('plans-wf2') - expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) - }) - - /** - * The decisive guard: `and()` drops `undefined`, so an ownership clause that - * resolved to nothing would leave a WHERE matching every file in the - * workspace. No ownership must mean no UPDATE is issued at all. - */ - it('issues no update at all when the workflow owns no backing folders', async () => { - mocks.listWorkspaceFileFolders.mockResolvedValue([ - { id: 'unrelated', path: 'documents', deletedAt: null }, - ]) - - const result = await cleanupWorkflowAliasBacking({ - workspaceId: 'workspace-1', - workflowId: 'wf_missing', - }) - - expect(dbChainMockFns.update).not.toHaveBeenCalled() - expect(drizzleOrmMock.inArray).not.toHaveBeenCalled() - expect(result).toEqual({ files: 0, folders: 0 }) - }) - - it('never archives files belonging to another workflow', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.flatMap(([, values]) => values) - expect(inArrayValues).not.toContain('plans-wf2') - }) - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts deleted file mode 100644 index 8cec8b2ffc..0000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { db } from '@sim/db' -import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' -import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm' -import { - WORKFLOW_CHANGELOG_BACKING_FOLDER, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, -} from '@/lib/copilot/vfs/workflow-aliases' -import { - ensureWorkspaceFileFolderPath, - listWorkspaceFileFolders, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - uploadWorkspaceFile, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -export interface WorkflowAliasBacking { - changelogFolderId: string - plansRootFolderId: string - workflowPlansFolderId: string - workspacePlansFolderId: string - changelogFile: WorkspaceFileRecord | null -} - -function initialChangelogContent(workflowName?: string): string { - const title = workflowName?.trim() || 'Workflow' - return `# ${title} Changelog\n` -} - -export async function ensureWorkflowAliasBacking(args: { - workspaceId: string - userId: string - workflowId: string - workflowName?: string -}): Promise { - const changelogFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_CHANGELOG_BACKING_FOLDER], - }) - const plansRootFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], - }) - const workflowPlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, args.workflowId], - }) - const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], - }) - - if ( - !changelogFolderId || - !plansRootFolderId || - !workflowPlansFolderId || - !workspacePlansFolderId - ) { - throw new Error('Failed to provision workflow alias backing folders') - } - - const changelogName = `${args.workflowId}.md` - let changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { - folderId: changelogFolderId, - }) - if (!changelogFile) { - await uploadWorkspaceFile( - args.workspaceId, - args.userId, - Buffer.from(initialChangelogContent(args.workflowName), 'utf-8'), - changelogName, - 'text/markdown', - { folderId: changelogFolderId } - ) - changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { - folderId: changelogFolderId, - }) - } - - return { - changelogFolderId, - plansRootFolderId, - workflowPlansFolderId, - workspacePlansFolderId, - changelogFile, - } -} - -export async function ensureWorkspacePlanBacking(args: { - workspaceId: string - userId: string -}): Promise<{ plansRootFolderId: string; workspacePlansFolderId: string }> { - const plansRootFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], - }) - const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], - }) - if (!plansRootFolderId || !workspacePlansFolderId) { - throw new Error('Failed to provision workspace plan backing folders') - } - return { plansRootFolderId, workspacePlansFolderId } -} - -export async function cleanupWorkflowAliasBacking(args: { - workspaceId: string - workflowId: string - deletedAt?: Date -}): Promise<{ files: number; folders: number }> { - const deletedAt = args.deletedAt ?? new Date() - const folders = await listWorkspaceFileFolders(args.workspaceId, { - scope: 'all', - includeReservedSystemFolders: true, - }) - - const workflowPlansPath = `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` - const isPlansFolder = (path: string) => - path === workflowPlansPath || path.startsWith(`${workflowPlansPath}/`) - - /** - * Folders whose files this workflow owns, resolved by id rather than by path. - * A file's `folderPath` is derived solely from its `folderId`, so matching on - * folder membership is equivalent to the path comparison it replaces — without - * loading every file in the workspace to compute it. - * - * Soft-deleted folders are included: path resolution ignores `deletedAt`, so a - * live file parented to an archived folder still resolved to these paths and - * must still be archived here. - */ - const ownedFileFolderIds = folders.filter((f) => isPlansFolder(f.path)).map((f) => f.id) - const changelogFolderIds = folders - .filter((f) => f.path === WORKFLOW_CHANGELOG_BACKING_FOLDER) - .map((f) => f.id) - - /** Only live folders are archived, matching the previous behavior. */ - const ownedFolderIds = folders - .filter((f) => !f.deletedAt && isPlansFolder(f.path)) - .map((f) => f.id) - - /** - * Collected as a list so the guard below tests the same value that is spread into - * `or()`. `and()` and `or()` both drop `undefined` arguments, so an ownership - * clause that silently resolved to nothing would leave a WHERE of workspace + - * context + not-deleted — which archives every file in the workspace. Gating on a - * non-empty list makes that unrepresentable. - */ - const ownershipFilters = [ - ownedFileFolderIds.length > 0 ? inArray(workspaceFiles.folderId, ownedFileFolderIds) : null, - changelogFolderIds.length > 0 - ? and( - inArray(workspaceFiles.folderId, changelogFolderIds), - eq(workspaceFiles.originalName, `${args.workflowId}.md`) - ) - : null, - ].filter((filter): filter is SQL => filter != null) - - let archivedFiles: { id: string }[] = [] - if (ownershipFilters.length > 0) { - archivedFiles = await db - .update(workspaceFiles) - .set({ deletedAt }) - .where( - and( - eq(workspaceFiles.workspaceId, args.workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt), - or(...ownershipFilters) - ) - ) - .returning({ id: workspaceFiles.id }) - } - - let archivedFolders: { id: string }[] = [] - if (ownedFolderIds.length > 0) { - archivedFolders = await db - .update(workspaceFileFolder) - .set({ deletedAt }) - .where( - and( - eq(workspaceFileFolder.workspaceId, args.workspaceId), - inArray(workspaceFileFolder.id, ownedFolderIds), - isNull(workspaceFileFolder.deletedAt) - ) - ) - .returning({ id: workspaceFileFolder.id }) - } - - return { files: archivedFiles.length, folders: archivedFolders.length } -} diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts deleted file mode 100644 index 7fa6fa52f7..0000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { db } from '@sim/db' -import { folder as folderTable, workflow } from '@sim/db/schema' -import { and, asc, eq, isNull } from 'drizzle-orm' -import { - buildWorkflowAliasWorkflowEntries, - isPlanAliasPath, - resolveWorkflowAliasPath, - resolveWorkspacePlanAliasPath, - type WorkflowAliasTarget, -} from '@/lib/copilot/vfs/workflow-aliases' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { canonicalizeVfsPath } from './path-utils' - -export async function resolveWorkflowAliasForWorkspace(args: { - workspaceId: string - path: string -}): Promise { - if (!(await isFeatureEnabled('mothership-beta'))) return null - if (!isPlanAliasPath(args.path)) return null - - let canonicalPath: string - try { - canonicalPath = canonicalizeVfsPath(args.path) - } catch { - canonicalPath = args.path.trim().replace(/^\/+|\/+$/g, '') - } - - const workspacePlanAlias = resolveWorkspacePlanAliasPath(canonicalPath) - if (workspacePlanAlias) return workspacePlanAlias - - const [workflowRows, folderRows] = await Promise.all([ - db - .select({ - id: workflow.id, - name: workflow.name, - folderId: workflow.folderId, - }) - .from(workflow) - .where(and(eq(workflow.workspaceId, args.workspaceId), isNull(workflow.archivedAt))) - .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), - db - .select({ - folderId: folderTable.id, - folderName: folderTable.name, - parentId: folderTable.parentId, - }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, args.workspaceId), - eq(folderTable.resourceType, 'workflow'), - isNull(folderTable.deletedAt) - ) - ) - .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)), - ]) - return resolveWorkflowAliasPath( - canonicalPath, - buildWorkflowAliasWorkflowEntries(workflowRows, folderRows) - ) -} diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts deleted file mode 100644 index 33b0701358..0000000000 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildWorkflowAliasWorkflowEntries, - isWorkflowAliasBackingPath, - resolveWorkflowAliasPath, - resolveWorkspacePlanAliasPath, - workflowChangelogBackingPath, - workspacePlanBackingPath, -} from './workflow-aliases' - -describe('workflow aliases', () => { - const folders = [ - { folderId: 'root-a', folderName: 'Folder A', parentId: null }, - { folderId: 'nested', folderName: 'Nested', parentId: 'root-a' }, - { folderId: 'root-b', folderName: 'Folder B', parentId: null }, - ] - - it('resolves root workspace plan aliases to workspace backing files', () => { - const alias = resolveWorkspacePlanAliasPath('.plans/root.md') - - expect(alias).toMatchObject({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: '.plans/root.md', - planRelativePath: 'root.md', - backingPath: workspacePlanBackingPath('root.md'), - }) - }) - - it('preserves nested root workspace plan paths in backing storage', () => { - const alias = resolveWorkspacePlanAliasPath('.plans/nested/phase-1.md') - - expect(alias).toMatchObject({ - kind: 'plan_file', - scope: 'workspace', - planRelativePath: 'nested/phase-1.md', - backingPath: 'files/.plans/workspace/nested/phase-1.md', - }) - }) - - it('rejects root plan directory paths as file aliases', () => { - expect(resolveWorkspacePlanAliasPath('.plans')).toMatchObject({ - kind: 'plans_dir', - scope: 'workspace', - }) - expect(resolveWorkspacePlanAliasPath('.plans/.folder')).toMatchObject({ - kind: 'plans_dir', - scope: 'workspace', - }) - expect(resolveWorkspacePlanAliasPath('.plans/links.json')).toBeNull() - }) - - it('resolves root workflow changelog aliases to workflow-id keyed backing files', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_123', name: 'Root Flow', folderId: null }], - [] - ) - - const alias = resolveWorkflowAliasPath('workflows/Root%20Flow/changelog.md', workflows) - - expect(alias).toMatchObject({ - kind: 'changelog', - workflowId: 'wf_123', - aliasPath: 'workflows/Root%20Flow/changelog.md', - backingPath: workflowChangelogBackingPath('wf_123'), - }) - }) - - it('resolves nested plan aliases using the workflow folder path', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_nested', name: 'Planner', folderId: 'nested' }], - folders - ) - - const alias = resolveWorkflowAliasPath( - 'workflows/Folder%20A/Nested/Planner/.plans/launch.md', - workflows - ) - - expect(alias).toMatchObject({ - kind: 'plan_file', - workflowId: 'wf_nested', - planRelativePath: 'launch.md', - backingPath: 'files/.plans/wf_nested/launch.md', - }) - }) - - it('keeps same-name workflows in different folders distinct', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [ - { id: 'wf_a', name: 'Duplicate', folderId: 'root-a' }, - { id: 'wf_b', name: 'Duplicate', folderId: 'root-b' }, - ], - folders - ) - - expect( - resolveWorkflowAliasPath('workflows/Folder%20A/Duplicate/changelog.md', workflows) - ).toMatchObject({ workflowId: 'wf_a', backingPath: 'files/.changelogs/wf_a.md' }) - expect( - resolveWorkflowAliasPath('workflows/Folder%20B/Duplicate/changelog.md', workflows) - ).toMatchObject({ workflowId: 'wf_b', backingPath: 'files/.changelogs/wf_b.md' }) - }) - - it('keeps backing paths stable across workflow rename', () => { - const before = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_stable', name: 'Old Name', folderId: null }], - [] - ) - const after = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_stable', name: 'New Name', folderId: null }], - [] - ) - - expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', before)?.backingPath).toBe( - 'files/.changelogs/wf_stable.md' - ) - expect(resolveWorkflowAliasPath('workflows/New%20Name/changelog.md', after)?.backingPath).toBe( - 'files/.changelogs/wf_stable.md' - ) - expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', after)).toBeNull() - }) - - it('rejects arbitrary workflow-local files and missing workflows', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_123', name: 'Root Flow', folderId: null }], - [] - ) - - expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull() - expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull() - }) - - it('recognizes reserved backing paths after VFS segment canonicalization', () => { - expect(isWorkflowAliasBackingPath('files/.plans/wf_1/launch.md')).toBe(true) - expect(isWorkflowAliasBackingPath('files/%2Eplans/wf_1/launch.md')).toBe(true) - expect(isWorkflowAliasBackingPath('files/ordinary/launch.md')).toBe(false) - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.ts deleted file mode 100644 index 2ad8e6aa0d..0000000000 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { - canonicalWorkspaceFilePath, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -export const WORKFLOW_CHANGELOG_ALIAS_NAME = 'changelog.md' -export const WORKFLOW_PLANS_ALIAS_DIR = '.plans' -export const WORKFLOW_ALIAS_LINKS_NAME = 'links.json' -export const WORKFLOW_CHANGELOG_BACKING_FOLDER = '.changelogs' -export const WORKFLOW_PLANS_BACKING_FOLDER = '.plans' -export const WORKSPACE_PLANS_BACKING_FOLDER = 'workspace' - -export type WorkflowAliasKind = 'changelog' | 'plan_file' | 'plans_dir' -export type WorkflowAliasScope = 'workspace' | 'workflow' - -export interface WorkflowAliasWorkflow { - id: string - name: string - folderPath?: string | null -} - -export interface WorkflowAliasWorkflowRow { - id: string - name: string - folderId?: string | null -} - -export interface WorkflowAliasFolderRow { - folderId: string - folderName: string - parentId: string | null -} - -interface BaseWorkflowAliasTarget { - kind: WorkflowAliasKind - scope: WorkflowAliasScope - aliasPath: string - backingPath: string - backingFolderPath: string - planRelativePath?: string -} - -export type WorkflowAliasTarget = - | (BaseWorkflowAliasTarget & { - kind: 'changelog' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plans_dir' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plan_file' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - planRelativePath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plans_dir' - scope: 'workspace' - }) - | (BaseWorkflowAliasTarget & { - kind: 'plan_file' - scope: 'workspace' - planRelativePath: string - }) - -export interface WorkflowAliasLink { - kind: WorkflowAliasKind - aliasPath: string - backingPath: string - backingFileId?: string -} - -export function workflowVfsPath(workflow: WorkflowAliasWorkflow): string { - const safeName = normalizeVfsSegment(workflow.name) - return workflow.folderPath - ? `workflows/${workflow.folderPath}/${safeName}` - : `workflows/${safeName}` -} - -export function buildWorkflowAliasWorkflowEntries( - workflows: WorkflowAliasWorkflowRow[], - folders: WorkflowAliasFolderRow[] -): WorkflowAliasWorkflow[] { - const folderMap = new Map() - for (const folder of folders) { - folderMap.set(folder.folderId, { name: folder.folderName, parentId: folder.parentId }) - } - - const folderPathCache = new Map() - const folderPath = (folderId: string): string => { - const cached = folderPathCache.get(folderId) - if (cached) return cached - - const folder = folderMap.get(folderId) - if (!folder) return '' - - const safeName = normalizeVfsSegment(folder.name) - const path = folder.parentId ? `${folderPath(folder.parentId)}/${safeName}` : safeName - folderPathCache.set(folderId, path) - return path - } - - return workflows.map((workflow) => ({ - id: workflow.id, - name: workflow.name, - folderPath: workflow.folderId ? folderPath(workflow.folderId) : null, - })) -} - -export function workflowChangelogBackingPath(workflowId: string): string { - return canonicalWorkspaceFilePath({ - folderPath: WORKFLOW_CHANGELOG_BACKING_FOLDER, - name: `${workflowId}.md`, - }) -} - -export function workflowPlansBackingFolderPath(workflowId: string): string { - return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(workflowId)}` -} - -export function workspacePlansBackingFolderPath(): string { - return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(WORKSPACE_PLANS_BACKING_FOLDER)}` -} - -export function workspacePlanBackingPath(planRelativePath: string): string { - const segments = decodeVfsPathSegments(planRelativePath) - if (segments.length === 0) { - throw new Error('Workspace plan alias must include a plan file path') - } - return canonicalWorkspaceFilePath({ - folderPath: [ - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - ...segments.slice(0, -1), - ].join('/'), - name: segments[segments.length - 1], - }) -} - -export function workflowPlanBackingPath(workflowId: string, planRelativePath: string): string { - const segments = decodeVfsPathSegments(planRelativePath) - if (segments.length === 0) { - throw new Error('Workflow plan alias must include a plan file path') - } - return canonicalWorkspaceFilePath({ - folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, workflowId, ...segments.slice(0, -1)].join('/'), - name: segments[segments.length - 1], - }) -} - -function workflowAliasTargetForPath(workflow: WorkflowAliasWorkflow, rawPath: string) { - const workflowPath = workflowVfsPath(workflow) - const changelogPath = `${workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}` - if (rawPath === changelogPath) { - return { - kind: 'changelog' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: changelogPath, - backingPath: workflowChangelogBackingPath(workflow.id), - backingFolderPath: `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}`, - } - } - - const plansDirPath = `${workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}` - if (rawPath === plansDirPath || rawPath === `${plansDirPath}/.folder`) { - return { - kind: 'plans_dir' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: plansDirPath, - backingPath: workflowPlansBackingFolderPath(workflow.id), - backingFolderPath: workflowPlansBackingFolderPath(workflow.id), - } - } - - const plansPrefix = `${plansDirPath}/` - if (rawPath.startsWith(plansPrefix)) { - const planRelativePath = rawPath.slice(plansPrefix.length) - if (!planRelativePath || planRelativePath === '.folder') return null - return { - kind: 'plan_file' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: rawPath, - backingPath: workflowPlanBackingPath(workflow.id, planRelativePath), - backingFolderPath: workflowPlansBackingFolderPath(workflow.id), - planRelativePath, - } - } - - return null -} - -export function resolveWorkspacePlanAliasPath(path: string): WorkflowAliasTarget | null { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - if ( - normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || - normalizedPath === `${WORKFLOW_PLANS_ALIAS_DIR}/.folder` - ) { - return { - kind: 'plans_dir', - scope: 'workspace', - aliasPath: WORKFLOW_PLANS_ALIAS_DIR, - backingPath: workspacePlansBackingFolderPath(), - backingFolderPath: workspacePlansBackingFolderPath(), - } - } - - const plansPrefix = `${WORKFLOW_PLANS_ALIAS_DIR}/` - if (!normalizedPath.startsWith(plansPrefix)) return null - const planRelativePath = normalizedPath.slice(plansPrefix.length) - if ( - !planRelativePath || - planRelativePath === '.folder' || - planRelativePath === WORKFLOW_ALIAS_LINKS_NAME - ) { - return null - } - return { - kind: 'plan_file', - scope: 'workspace', - aliasPath: normalizedPath, - backingPath: workspacePlanBackingPath(planRelativePath), - backingFolderPath: workspacePlansBackingFolderPath(), - planRelativePath, - } -} - -export function resolveWorkflowAliasPath( - path: string, - workflows: WorkflowAliasWorkflow[] -): WorkflowAliasTarget | null { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - if (!normalizedPath.startsWith('workflows/')) return null - - const bySpecificity = [...workflows].sort( - (a, b) => workflowVfsPath(b).length - workflowVfsPath(a).length - ) - for (const workflow of bySpecificity) { - const target = workflowAliasTargetForPath(workflow, normalizedPath) - if (target) return target - } - return null -} - -export function isWorkflowAliasPath(path: string): boolean { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath.startsWith('workflows/') && - (normalizedPath.endsWith(`/${WORKFLOW_CHANGELOG_ALIAS_NAME}`) || - normalizedPath.includes(`/${WORKFLOW_PLANS_ALIAS_DIR}/`) || - normalizedPath.endsWith(`/${WORKFLOW_PLANS_ALIAS_DIR}`)) - ) -} - -export function isWorkspacePlanAliasPath(path: string): boolean { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || - normalizedPath.startsWith(`${WORKFLOW_PLANS_ALIAS_DIR}/`) - ) -} - -export function isPlanAliasPath(path: string): boolean { - return isWorkspacePlanAliasPath(path) || isWorkflowAliasPath(path) -} - -export function isWorkflowAliasBackingPath(path: string): boolean { - const trimmedPath = path.trim().replace(/^\/+|\/+$/g, '') - let normalizedPath = trimmedPath - if (trimmedPath.startsWith('files/')) { - try { - normalizedPath = `files/${decodeVfsPathSegments(trimmedPath.slice('files/'.length)) - .map((segment) => normalizeVfsSegment(segment)) - .join('/')}` - } catch { - normalizedPath = trimmedPath - } - } - return ( - normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` || - normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` || - normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}/`) || - normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/`) - ) -} - -export function isReservedWorkflowAliasBackingDisplayPath(path?: string | null): boolean { - if (!path) return false - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath === WORKFLOW_CHANGELOG_BACKING_FOLDER || - normalizedPath === WORKFLOW_PLANS_BACKING_FOLDER || - normalizedPath.startsWith(`${WORKFLOW_CHANGELOG_BACKING_FOLDER}/`) || - normalizedPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/`) - ) -} - -export function workflowAliasSandboxPath(aliasPath: string): string { - return `/home/user/${aliasPath.trim().replace(/^\/+/, '')}` -} - -export function buildWorkflowAliasLinks(args: { - workflowPath: string - workflowId: string - changelog?: WorkspaceFileRecord | null - planFiles?: WorkspaceFileRecord[] -}): WorkflowAliasLink[] { - const links: WorkflowAliasLink[] = [ - { - kind: 'changelog', - aliasPath: `${args.workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`, - backingPath: workflowChangelogBackingPath(args.workflowId), - backingFileId: args.changelog?.id, - }, - { - kind: 'plans_dir', - aliasPath: `${args.workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`, - backingPath: workflowPlansBackingFolderPath(args.workflowId), - }, - ] - - for (const file of args.planFiles ?? []) { - const relativePath = file.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}`, '') - .replace(/^\/+/, '') - const aliasRelativePath = encodeVfsPathSegments( - [relativePath, file.name].filter(Boolean).join('/').split('/') - ) - const aliasPath = [args.workflowPath, WORKFLOW_PLANS_ALIAS_DIR, aliasRelativePath].join('/') - links.push({ - kind: 'plan_file', - aliasPath, - backingPath: canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }), - backingFileId: file.id, - }) - } - - return links -} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 24d788280b..2be1181041 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -85,21 +85,8 @@ import { serializeVersions, serializeWorkflowMeta, } from '@/lib/copilot/vfs/serializers' -import { - buildWorkflowAliasLinks, - isWorkflowAliasBackingPath, - WORKFLOW_ALIAS_LINKS_NAME, - WORKFLOW_CHANGELOG_ALIAS_NAME, - WORKFLOW_PLANS_ALIAS_DIR, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - workflowChangelogBackingPath, - workspacePlanBackingPath, - workspacePlansBackingFolderPath, -} from '@/lib/copilot/vfs/workflow-aliases' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -502,7 +489,6 @@ export class WorkspaceVFS { >() private deploymentCache = new Map>() private _workspaceId = '' - private _betaEnabled = false /** * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to @@ -668,7 +654,6 @@ export class WorkspaceVFS { this.deploymentCache = new Map() this._customBlockTypes = null this._workspaceId = workspaceId - this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -698,7 +683,6 @@ export class WorkspaceVFS { customBlocksSummary, mcpServersSummary, skillsSummary, - taskSummary, jobsSummary, wsRow, members, @@ -712,10 +696,13 @@ export class WorkspaceVFS { timed('custom_blocks', this.materializeCustomBlocks(workspaceId)), timed('mcp_servers', this.materializeMcpServers(workspaceId)), timed('skills', this.materializeSkills(workspaceId)), - timed('tasks', this.materializeTasks(workspaceId, userId)), timed('jobs', this.materializeJobs(workspaceId)), timed('workspace_row', getWorkspaceWithOwner(workspaceId)), timed('members', getUsersWithPermissions(workspaceId)), + // Writes tasks/ files only — WORKSPACE.md has no Tasks section + // (recent chats reorder every turn and would bust the cached + // prompt prefix), so nothing is destructured from this one. + timed('tasks', this.materializeTasks(workspaceId, userId)), ]) const workspaceMdData = { @@ -727,7 +714,6 @@ export class WorkspaceVFS { files: fileSummary, oauthIntegrations: envSummary.oauthIntegrations, envVariables: envSummary.envVariables, - tasks: taskSummary, customTools: toolsSummary, customBlocks: customBlocksSummary, mcpServers: mcpServersSummary, @@ -884,13 +870,10 @@ export class WorkspaceVFS { path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' ): Promise { - if (!this._betaEnabled && isWorkflowAliasBackingPath(path)) { - return null - } const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId, { includeReservedSystemFiles: true }) + const files = await listWorkspaceFiles(this._workspaceId) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } @@ -1271,18 +1254,12 @@ export class WorkspaceVFS { .replace(/\/content$/, '') .replace(/^\/+/, '') - if (!this._betaEnabled && isWorkflowAliasBackingPath(fileReference)) { - return null - } if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { - scope, - includeReservedSystemFiles: this._betaEnabled, - }) + const files = await listWorkspaceFiles(this._workspaceId, { scope }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null return readFileRecord(record) @@ -1345,7 +1322,6 @@ export class WorkspaceVFS { * Returns a summary for WORKSPACE.md generation. */ private async materializeWorkflows(workspaceId: string): Promise { - const workflowArtifactsEnabled = this._betaEnabled const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), listFolders(workspaceId), @@ -1354,16 +1330,6 @@ export class WorkspaceVFS { const folderPaths = this.buildFolderPaths(folderRows) const lockedFolderIds = this.computeLockedFolderIds(folderRows) - // NOTE: materialization is a pure READ. Alias backing (changelog/plan - // folders + files) is ensured at write time — workflow create/rename - // (lib/workflows/utils) and alias writes (vfs/resource-writer, - // tools/server/files/workspace-file) — never here. Ensuring per workflow - // on every materialize meant N storage/DB writes per read tool call, and - // concurrent materializations contending on the same rows. - const workspaceFiles = workflowArtifactsEnabled - ? await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) - : [] - // Register all folders in the VFS so empty folders are discoverable. for (const { folderId } of folderRows) { const folderPath = folderPaths.get(folderId) @@ -1381,74 +1347,6 @@ export class WorkspaceVFS { const inheritedFolderLock = wf.folderId ? lockedFolderIds.has(wf.folderId) : false this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf, { inheritedFolderLock })) - if (workflowArtifactsEnabled) { - const changelog = findWorkspaceFileRecord( - workspaceFiles, - workflowChangelogBackingPath(wf.id) - ) - let changelogContent = '' - if (changelog) { - try { - changelogContent = (await readFileRecord(changelog))?.content ?? '' - } catch (err) { - logger.warn('Failed to read workflow changelog alias backing file', { - workspaceId, - workflowId: wf.id, - fileId: changelog.id, - error: toError(err).message, - }) - } - } - if (changelog) { - this.files.set(`${prefix}${WORKFLOW_CHANGELOG_ALIAS_NAME}`, changelogContent) - } - this.files.set(`${prefix}${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') - - const planFiles = workspaceFiles.filter((file) => { - if (!file.folderPath) return false - return ( - file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}` || - file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}/`) - ) - }) - for (const planFile of planFiles) { - const relativeFolder = planFile.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}`, '') - .replace(/^\/+/, '') - const aliasPlanPath = [ - prefix, - `${WORKFLOW_PLANS_ALIAS_DIR}/`, - relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', - normalizeVfsSegment(planFile.name), - ].join('') - try { - this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') - } catch (err) { - logger.warn('Failed to read workflow plan alias backing file', { - workspaceId, - workflowId: wf.id, - fileId: planFile.id, - error: toError(err).message, - }) - } - } - this.files.set( - `${prefix}${WORKFLOW_ALIAS_LINKS_NAME}`, - JSON.stringify( - { - aliases: buildWorkflowAliasLinks({ - workflowPath, - workflowId: wf.id, - changelog, - planFiles, - }), - }, - null, - 2 - ) - ) - } - // Heavy per-workflow content is LAZY: a read/glob never loads the block // graph, runs lint, or queries executions/deployments. Only a read of the // specific artifact — or a grep whose scope touches it — resolves it. @@ -1747,15 +1645,8 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const workflowArtifactsEnabled = this._betaEnabled - const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: true, - }) - const files = await listWorkspaceFiles(workspaceId, { - folders, - includeReservedSystemFiles: true, - throwOnError: true, - }) + const folders = await listWorkspaceFileFolders(workspaceId) + const files = await listWorkspaceFiles(workspaceId, { folders, throwOnError: true }) // Batch-load public share state so each file's metadata carries an ambient // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. // Fail soft: share state is only metadata enrichment, so a lookup failure @@ -1771,12 +1662,6 @@ export class WorkspaceVFS { }) } for (const folder of folders) { - if ( - !workflowArtifactsEnabled && - isWorkflowAliasBackingPath(`files/${encodeVfsPathSegments(folder.path.split('/'))}`) - ) { - continue - } this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -1785,9 +1670,6 @@ export class WorkspaceVFS { folderPath: file.folderPath, name: file.name, }) - if (!workflowArtifactsEnabled && isWorkflowAliasBackingPath(filePath)) { - continue - } const share = shareByFileId.get(file.id) const shared = share?.isActive ?? false this.files.set( @@ -1809,86 +1691,13 @@ export class WorkspaceVFS { ) } - if (workflowArtifactsEnabled) { - this.files.set(`${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') - const workspacePlanFiles = files.filter((file) => { - if (!file.folderPath) return false - return ( - file.folderPath === - `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}` || - file.folderPath.startsWith( - `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}/` - ) - ) - }) - const workspacePlanLinks = [] - for (const planFile of workspacePlanFiles) { - const relativeFolder = planFile.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}`, '') - .replace(/^\/+/, '') - const aliasRelativePath = [ - relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', - normalizeVfsSegment(planFile.name), - ].join('') - const aliasPlanPath = `${WORKFLOW_PLANS_ALIAS_DIR}/${aliasRelativePath}` - const relativeSegments = aliasRelativePath.split('/').slice(0, -1) - for (let index = 0; index < relativeSegments.length; index++) { - this.files.set( - `${WORKFLOW_PLANS_ALIAS_DIR}/${relativeSegments.slice(0, index + 1).join('/')}/.folder`, - '' - ) - } - try { - this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') - workspacePlanLinks.push({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: aliasPlanPath, - backingPath: workspacePlanBackingPath(aliasRelativePath), - backingFileId: planFile.id, - }) - } catch (err) { - logger.warn('Failed to read workspace plan alias backing file', { - workspaceId, - fileId: planFile.id, - error: toError(err).message, - }) - } - } - this.files.set( - `${WORKFLOW_PLANS_ALIAS_DIR}/${WORKFLOW_ALIAS_LINKS_NAME}`, - JSON.stringify( - { - aliases: [ - { - kind: 'plans_dir', - scope: 'workspace', - aliasPath: WORKFLOW_PLANS_ALIAS_DIR, - backingPath: workspacePlansBackingFolderPath(), - }, - ...workspacePlanLinks, - ], - }, - null, - 2 - ) - ) - } - - return files - .filter( - (f) => - !isWorkflowAliasBackingPath( - canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) - ) - ) - .map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })) + return files.map((f) => ({ + id: f.id, + name: f.name, + type: f.type, + size: f.size, + folderPath: f.folderPath ?? null, + })) } catch (err) { logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { workspaceId, @@ -2199,13 +2008,11 @@ export class WorkspaceVFS { } /** - * Materialize mothership task chats as browsable conversation files. - * Returns a summary for WORKSPACE.md generation. + * Materialize mothership task chats as browsable conversation files under + * `tasks/{title}/`. Nothing is returned: the inventory deliberately has no + * Tasks section, so these files are reached through glob/read only. */ - private async materializeTasks( - workspaceId: string, - userId: string - ): Promise { + private async materializeTasks(workspaceId: string, userId: string): Promise { try { const taskRows = await db .select({ @@ -2275,18 +2082,11 @@ export class WorkspaceVFS { this.files.set(`${prefix}chat.json`, serializeTaskChat(messages)) } } - - return taskRows.map((t) => ({ - id: t.id, - title: t.title || 'Untitled task', - updatedAt: t.updatedAt, - })) } catch (err) { logger.warn('Failed to materialize tasks', { workspaceId, error: toError(err).message, }) - return [] } } diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 1345b72c05..115242cbfb 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -50,6 +50,18 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Holds tools the catalog marks `requiresApproval` — shell commands, workflow + * runs, sandboxed code, deployments, integration calls — behind an explicit + * Allow / Skip prompt, blocking the mothership turn until the user answers. + * + * Off by default: turning it on makes the copilot prompt on its most frequently + * used tools, so it is an opt-in change in how the product feels, not just a + * safety toggle. With it off nothing is stamped, gated, or persisted, and an + * approval stamp arriving from Go is cleared on the way to the client. + */ +export const isCopilotToolPermissionsEnabled = isTruthy(env.COPILOT_TOOL_PERMISSIONS_ENABLED) + /** * Is billing enforcement enabled. * diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7afd1fb748..f2df132144 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -55,6 +55,8 @@ export const env = createEnv({ COPILOT_BILLING_ATTRIBUTION_V1_ENABLED: z.boolean().optional(), /** Rejects markerless old-Go billing traffic only when explicitly enabled. */ COPILOT_BILLING_PROTOCOL_REQUIRED: z.boolean().optional(), + /** Gates risky copilot tools behind an Allow / Skip prompt. Off by default. */ + COPILOT_TOOL_PERMISSIONS_ENABLED: z.boolean().optional(), SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API COPILOT_SOURCE_ENV: z.enum(['dev', 'staging', 'prod']).optional(), // Source Sim environment sent to mothership for callbacks COPILOT_DEV_URL: z.string().url().optional(), // Sim agent API URL for the dev mothership environment @@ -491,7 +493,6 @@ export const env = createEnv({ // Invitations - for self-hosted deployments DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments) - MOTHERSHIP_BETA_FEATURES: z.boolean().optional(), // Enable beta Mothership planning/changelog artifact surfaces // Development Tools REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index aae8a7a2b6..7f65c3ffd9 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -75,7 +75,6 @@ describe('getFeatureFlags', () => { it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => { const flags = await getFeatureFlags() // All registered flags should be present, disabled (env vars unset in test env) - expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) expect(flags['pii-granular-redaction']).toEqual({ enabled: false }) expect(flags['trigger-eu-region']).toEqual({ enabled: false }) @@ -103,7 +102,6 @@ describe('getFeatureFlags', () => { setEnvFlags({ isAppConfigEnabled: true }) mockFetch.mockResolvedValue(null) const flags = await getFeatureFlags() - expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) expect(flags['pii-granular-redaction']).toEqual({ enabled: false }) expect(flags['trigger-eu-region']).toEqual({ enabled: false }) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 69c3229058..b8a9e807e0 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -56,14 +56,6 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { - 'mothership-beta': { - description: - 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler. ' + - 'Note: userId/orgId targeting only works for WorkspaceVfs (resolved in materialize). ' + - 'getE2BDocFormat, resolveInputFiles, and resolveWorkflowAliasForWorkspace evaluate without ' + - 'user context — use enabled:true for global rollout rather than per-user targeting.', - fallback: 'MOTHERSHIP_BETA_FEATURES', - }, 'table-snapshot-cache': { description: 'Mount Sim tables into code sandboxes by reference via a version-keyed CSV snapshot in ' + diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 02c44a1ed9..b6fec51bb2 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,6 +5,7 @@ import http from 'http' import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' +import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' @@ -31,49 +32,6 @@ export interface AsyncValidationResult extends ValidationResult { originalHostname?: string } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated) - * - Various edge cases that regex patterns miss - */ -export function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - if (range !== 'unicast') { - return true - } - - if (addr.kind() === 'ipv6') { - const v6 = addr as ipaddr.IPv6 - const parts = v6.parts - const firstSixZero = parts.slice(0, 6).every((p) => p === 0) - if (firstSixZero) { - const embedded = ipaddr.fromByteArray([ - (parts[6] >> 8) & 0xff, - parts[6] & 0xff, - (parts[7] >> 8) & 0xff, - parts[7] & 0xff, - ]) - return embedded.range() !== 'unicast' - } - } - - return false - } catch { - return true - } -} - /** * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding * @@ -101,18 +59,10 @@ export async function validateUrlWithDNS( const hostname = parsedUrl.hostname const hostnameLower = hostname.toLowerCase() - const cleanHostname = - hostnameLower.startsWith('[') && hostnameLower.endsWith(']') - ? hostnameLower.slice(1, -1) - : hostnameLower + const cleanHostname = unwrapIpv6Brackets(hostnameLower) - let isLocalhost = cleanHostname === 'localhost' - if (ipaddr.isValid(cleanHostname)) { - const processedIP = ipaddr.process(cleanHostname).toString() - if (processedIP === '127.0.0.1' || processedIP === '::1') { - isLocalhost = true - } - } + // Whole loopback range — see the matching note in input-validation.ts. + const isLocalhost = cleanHostname === 'localhost' || isLoopbackIp(cleanHostname) try { // Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs @@ -121,14 +71,9 @@ export async function validateUrlWithDNS( const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true }) const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] - const resolvedIsLoopback = - ipaddr.isValid(address) && - (() => { - const ip = ipaddr.process(address).toString() - return ip === '127.0.0.1' || ip === '::1' - })() + const resolvedIsLoopback = isLoopbackIp(address) - if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { + if (isPrivateIp(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { logger.warn('URL resolves to blocked IP address', { paramName, hostname, @@ -213,7 +158,7 @@ export async function validateAndPinProxyUrl( // validateUrlWithDNS permits loopback for self-hosted dev targets; a proxy governs // egress, so loopback/private proxy hosts stay blocked unconditionally. - if (isPrivateOrReservedIP(resolvedIP)) { + if (isPrivateIp(resolvedIP)) { return { isValid: false, error: 'proxyUrl resolves to a blocked IP address' } } @@ -250,19 +195,13 @@ export async function validateDatabaseHost( return { isValid: false, error: `${paramName} is required` } } - const lowerHost = host.toLowerCase() - const cleanHost = - lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost + const cleanHost = unwrapIpv6Brackets(host.toLowerCase()) if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be localhost` } } - if ( - ipaddr.isValid(cleanHost) && - isPrivateOrReservedIP(cleanHost) && - !isPrivateDatabaseHostsAllowed - ) { + if (isPrivateIpHost(cleanHost) && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be a private IP address` } } @@ -272,7 +211,7 @@ export async function validateDatabaseHost( const resolved = await dns.lookup(cleanHost, { all: true, verbatim: true }) const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] - if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { + if (isPrivateIp(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', { paramName, hostname: host, @@ -519,7 +458,7 @@ export function createSsrfGuardedLookup(): LookupFunction { dns .lookup(hostname, { all: true, verbatim: false }) .then((addresses) => { - const usable = addresses.filter((entry) => !isPrivateOrReservedIP(entry.address)) + const usable = addresses.filter((entry) => !isPrivateIp(entry.address)) if (usable.length === 0) { callback( new Error(`Blocked by SSRF policy: ${hostname} has no publicly routable address`), @@ -552,7 +491,7 @@ function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void { url.hostname.startsWith('[') && url.hostname.endsWith(']') ? url.hostname.slice(1, -1) : url.hostname - if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) { + if (ipaddr.isValid(host) && isPrivateIp(host)) { // The pinned-private carve-out permits exactly its own validated IP as a target (a // self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing // else private (a redirect to e.g. the cloud metadata IP is still blocked). diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index e6d90a1518..df0200af3a 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -27,7 +27,6 @@ import { validateWorkdayTenantUrl, } from '@/lib/core/security/input-validation' import { - isPrivateOrReservedIP, validateAndPinProxyUrl, validateDatabaseHost, validateUrlWithDNS, @@ -567,147 +566,6 @@ describe('sanitizeForLogging', () => { }) }) -describe('isPrivateOrReservedIP', () => { - describe('IPv4 private/reserved ranges', () => { - it.concurrent.each([ - ['192.168.1.1'], - ['192.168.0.0'], - ['10.0.0.1'], - ['10.255.255.255'], - ['172.16.0.1'], - ['172.31.255.255'], - ['127.0.0.1'], - ['127.255.255.255'], - ['169.254.169.254'], - ['0.0.0.0'], - ['224.0.0.1'], - ])('blocks IPv4 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv6 reserved ranges', () => { - it.concurrent.each([ - ['::1'], - ['::'], - ['fe80::1'], - ['fc00::1'], - ['fd00::1'], - ['ff02::1'], - ['2001:db8::1'], - ])('blocks IPv6 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { - it.concurrent.each([ - ['::ffff:192.168.1.1'], - ['::ffff:127.0.0.1'], - ['::ffff:169.254.169.254'], - ['::ffff:c0a8:101'], - ['::ffff:0:0'], - ])('blocks mapped private/reserved %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent('allows mapped public IPv4 ::ffff:8.8.8.8', () => { - expect(isPrivateOrReservedIP('::ffff:8.8.8.8')).toBe(false) - }) - }) - - describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { - it.concurrent('blocks NAT64-encoded private IPv4', () => { - expect(isPrivateOrReservedIP('64:ff9b::192.168.1.1')).toBe(true) - }) - }) - - describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { - it.concurrent.each([ - ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], - ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], - ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], - ['::7f00:1', '127.0.0.1 (loopback)'], - ['::7f00:0001', '127.0.0.1 (zero-padded)'], - ['::a00:1', '10.0.0.1 (RFC1918)'], - ['::ac10:1', '172.16.0.1 (RFC1918)'], - ['::e000:1', '224.0.0.1 (multicast)'], - ['::192.168.1.1', 'dotted form ::192.168.1.1'], - ['::169.254.169.254', 'dotted form ::169.254.169.254'], - ['::127.0.0.1', 'dotted form ::127.0.0.1'], - ['::10.0.0.1', 'dotted form ::10.0.0.1'], - ])('blocks %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent.each([ - ['::8.8.8.8', 'dotted form embedding public IPv4'], - ['::808:808', 'hex form embedding 8.8.8.8'], - ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], - ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - - it.concurrent.each([ - ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], - ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], - ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], - ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], - ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { - it.concurrent.each([ - ['2606:4700:4700::1111'], - ['2001:4860:4860::8888'], - ['::1:c0a8:101'], - ['1::c0a8:101'], - ['1:2:3:4:5:6:c0a8:101'], - ])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 public addresses', () => { - it.concurrent.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 alternate notations', () => { - it.concurrent.each([['0177.0.0.1'], ['0x7f000001']])('blocks loopback notation %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('invalid input', () => { - it.concurrent.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g']])('rejects %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) -}) - -describe('URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)', () => { - it.concurrent('Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it', () => { - const url = new URL('http://[::192.168.1.1]/') - const cleanHostname = - url.hostname.startsWith('[') && url.hostname.endsWith(']') - ? url.hostname.slice(1, -1) - : url.hostname - expect(cleanHostname).toBe('::c0a8:101') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) - - it.concurrent('Node normalizes [::169.254.169.254] and validator blocks the metadata IP', () => { - const url = new URL('http://[::169.254.169.254]/') - const cleanHostname = url.hostname.slice(1, -1) - expect(cleanHostname).toBe('::a9fe:a9fe') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) -}) - describe('validateUrlWithDNS', () => { describe('basic validation', () => { it('should reject invalid URLs', async () => { @@ -1239,6 +1097,20 @@ describe('validateExternalUrl', () => { expect(result.isValid).toBe(true) }) + /** + * The whole 127.0.0.0/8 range is the same machine. Matching only the + * 127.0.0.1 literal made this validator disagree with MCP's domain-check, + * which has always used the shared range helper — so the same self-hosted + * address was localhost to one caller and a plain http URL to the other. + */ + it.concurrent('should treat the rest of the loopback range as localhost too', () => { + expect(validateExternalUrl('http://127.0.0.2/api').isValid).toBe(true) + expect(validateExternalUrl('http://127.1.2.3/api').isValid).toBe(true) + // Still only loopback — neighbouring private ranges stay rejected. + expect(validateExternalUrl('http://10.0.0.1/api').isValid).toBe(false) + expect(validateExternalUrl('http://192.168.1.1/api').isValid).toBe(false) + }) + it.concurrent('should accept https IPv6 loopback', () => { const result = validateExternalUrl('https://[::1]/api') expect(result.isValid).toBe(true) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index fb73b30056..823fa3b2ef 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isLoopbackIp, isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' @@ -401,7 +402,7 @@ export function validateHostname( } if (ipaddr.isValid(lowerHostname)) { - if (isPrivateOrReservedIP(lowerHostname)) { + if (isPrivateIp(lowerHostname)) { logger.warn('Hostname matches blocked IP range', { paramName, hostname: hostname.substring(0, 100), @@ -721,16 +722,13 @@ export function validateExternalUrl( const protocol = parsedUrl.protocol const hostname = parsedUrl.hostname.toLowerCase() - const cleanHostname = - hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const cleanHostname = unwrapIpv6Brackets(hostname) - let isLocalhost = cleanHostname === 'localhost' - if (ipaddr.isValid(cleanHostname)) { - const processedIP = ipaddr.process(cleanHostname).toString() - if (processedIP === '127.0.0.1' || processedIP === '::1') { - isLocalhost = true - } - } + // The whole loopback range, not just 127.0.0.1: 127.0.0.2 is the same + // machine, and matching two literals made this validator disagree with MCP's + // domain-check about what "localhost" means. Both directions stay coherent — + // hosted rejects the wider set, self-hosted permits http on it. + const isLocalhost = cleanHostname === 'localhost' || isLoopbackIp(cleanHostname) if (isLocalhost && isHosted) { return { @@ -754,7 +752,7 @@ export function validateExternalUrl( } if (!isLocalhost && ipaddr.isValid(cleanHostname)) { - if (isPrivateOrReservedIP(cleanHostname)) { + if (isPrivateIp(cleanHostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`, @@ -797,29 +795,6 @@ export function validateProxyUrl( return validateExternalUrl(url, paramName) } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - Various edge cases that regex patterns miss - */ -function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - return range !== 'unicast' - } catch { - return true - } -} - /** * Validates an Airtable ID (base, table, or webhook ID) * diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 166094c627..743725c558 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,3 +1,4 @@ +import { isLoopbackHostname } from '@sim/security/hostnames' import { env, getEnv } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/env-flags' @@ -128,17 +129,6 @@ export function getEmailDomain(): string { const DEFAULT_SOCKET_URL = 'http://localhost:3002' const DEFAULT_OLLAMA_URL = 'http://localhost:11434' -export const LOCALHOST_HOSTNAMES: ReadonlySet = new Set([ - 'localhost', - '127.0.0.1', - '[::1]', - '::1', -]) - -export function isLoopbackHostname(hostname: string): boolean { - return LOCALHOST_HOSTNAMES.has(hostname) -} - /** * Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env * var) into a deduped array of normalized origins. Invalid entries are dropped. @@ -177,7 +167,7 @@ export function parseOriginList( export function isLocalhostUrl(url: string): boolean { try { const { hostname } = new URL(url) - return LOCALHOST_HOSTNAMES.has(hostname) + return isLoopbackHostname(hostname) } catch { return false } @@ -233,7 +223,7 @@ export function getSocketUrl(): string { if (explicit) return explicit const browserOrigin = getBrowserOrigin() - if (browserOrigin && !LOCALHOST_HOSTNAMES.has(new URL(browserOrigin).hostname)) { + if (browserOrigin && !isLoopbackHostname(new URL(browserOrigin).hostname)) { return browserOrigin } diff --git a/apps/sim/lib/data-drains/sources/copilot-chats.ts b/apps/sim/lib/data-drains/sources/copilot-chats.ts index 11b008ee7d..773b5f2257 100644 --- a/apps/sim/lib/data-drains/sources/copilot-chats.ts +++ b/apps/sim/lib/data-drains/sources/copilot-chats.ts @@ -15,8 +15,12 @@ import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/typ * The transcript no longer lives on `copilot_chats.messages` — it is assembled * per page from the normalized `copilot_messages` table, so `messages` is the * ordered list of message `content` objects rather than the DB column. + * + * `planArtifact` is omitted too: the column still exists only until a + * follow-up migration can safely drop it, and nothing writes it any more, so + * draining it would ship a dead field to every export consumer. */ -type CopilotChatRow = Omit & { +type CopilotChatRow = Omit & { messages: unknown[] } @@ -31,10 +35,10 @@ const chatColumns = { model: copilotChats.model, conversationId: copilotChats.conversationId, previewYaml: copilotChats.previewYaml, - planArtifact: copilotChats.planArtifact, config: copilotChats.config, resources: copilotChats.resources, lastSeenAt: copilotChats.lastSeenAt, + autoAllowedTools: copilotChats.autoAllowedTools, pinned: copilotChats.pinned, deletedAt: copilotChats.deletedAt, createdAt: copilotChats.createdAt, @@ -118,7 +122,6 @@ export const copilotChatsSource: DrainSource = { model: row.model, conversationId: row.conversationId, previewYaml: row.previewYaml, - planArtifact: row.planArtifact, config: row.config, resources: row.resources, lastSeenAt: row.lastSeenAt ? row.lastSeenAt.toISOString() : null, diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts new file mode 100644 index 0000000000..050c1faa48 --- /dev/null +++ b/apps/sim/lib/desktop/index.ts @@ -0,0 +1,200 @@ +/** + * THE single detection point for the Sim desktop app. + * + * The web app is served identically to browsers and to the desktop shell; the + * only difference is the preload bridge the shell injects + * (`window.simDesktop`, typed in `@sim/desktop-bridge`). Desktop features are + * progressive enhancements: feature-detect a bridge surface here, never + * assume it. + * + * Rules for scaling desktop features without scattering gates: + * - Shared code must not touch `window.simDesktop` directly — add an accessor + * here (or a feature-scoped wrapper like `lib/browser-agent/transport.ts` + * that builds on {@link getDesktopBridge}) so "everything desktop" stays + * greppable from one module. + * - Gate on the specific bridge surface a feature needs (e.g. + * {@link hasLocalFilesystem}), not on "is desktop" — older shells may lack + * newer surfaces. + * - The browser and terminal additionally have a device switch the user can + * turn off. `has*` answers whether the shell ships the surface (what the + * settings pages need, so the switch can be turned back on); `is*Enabled` + * answers whether it may actually run (what everything else needs). + * - Anything advertised to the copilot backend must flow through + * {@link getDesktopChatCapabilities} so every chat surface reports + * capabilities consistently. + */ +import type { BrowserKnownSession } from '@sim/browser-protocol' +import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' + +/** The preload bridge, or undefined outside the desktop app (and on the server). */ +export function getDesktopBridge(): SimDesktopApi | undefined { + if (typeof window === 'undefined') return undefined + return window.simDesktop +} + +/** True when running inside the Sim desktop app. */ +export function isDesktopApp(): boolean { + return getDesktopBridge() !== undefined +} + +/** True when the shell can serve read-only local-directory grants. */ +export function hasLocalFilesystem(): boolean { + return Boolean(getDesktopBridge()?.localFilesystem) +} + +/** True when the shell hosts the embedded agent browser. */ +export function hasBrowserAgent(): boolean { + return Boolean(getDesktopBridge()?.browserAgent) +} + +/** True when the shell can run an interactive local shell for the agent. */ +export function hasTerminal(): boolean { + return Boolean(getDesktopBridge()?.terminal) +} + +/** True when the shell exposes device-level desktop preferences. */ +export function hasDesktopSettings(): boolean { + return Boolean(getDesktopBridge()?.settings) +} + +/** + * The device switches for the browser and terminal, cached because the chat UI + * reads availability synchronously while the shell only answers over async + * IPC. An unread or absent value means enabled: both surfaces predate the + * preference, and both default to on. + * + * The cache is authoritative at call time, which is what tool execution and + * capability reporting need. React trees that read it in a memo settle on the + * next mount — flipping a switch happens on a settings route, so the chat view + * has unmounted by then anyway. + */ +let devicePreferences: DesktopPreferences | null = null +let devicePreferencesLoad: Promise | null = null + +function loadDevicePreferences(): Promise { + devicePreferencesLoad ??= + getDesktopBridge() + ?.settings?.getPreferences() + .then((preferences) => { + devicePreferences = preferences + }) + .catch(() => {}) ?? Promise.resolve() + return devicePreferencesLoad +} + +function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean { + void loadDevicePreferences() + return devicePreferences?.[key] !== false +} + +/** + * Publishes a preference change from the settings pages so the synchronous + * gates below stop lagging a round trip behind the shell. + */ +export function setDesktopPreferencesSnapshot(preferences: DesktopPreferences): void { + devicePreferences = preferences + devicePreferencesLoad = Promise.resolve() +} + +/** True when the agent browser is installed and switched on for this device. */ +export function isBrowserAgentEnabled(): boolean { + return hasBrowserAgent() && isSurfaceSwitchedOn('browserEnabled') +} + +/** True when the agent terminal is installed and switched on for this device. */ +export function isTerminalEnabled(): boolean { + return hasTerminal() && isSurfaceSwitchedOn('terminalEnabled') +} + +/** + * The installed shell's semver, or undefined in a browser and on shells that + * predate version reporting. Input to the minimum-shell-version gate (see + * `lib/desktop/min-version.ts`). + */ +export function getDesktopShellVersion(): string | undefined { + return getDesktopBridge()?.version +} + +/** The shell updater surface, when the installed shell provides one. */ +export function getDesktopUpdates(): SimDesktopApi['updates'] { + return getDesktopBridge()?.updates +} + +/** + * Summary of one open shell, sent with each request so the agent knows what is + * already running without having to ask. No output and no environment: only + * enough to pick a terminal and notice when one is occupied. + */ +export interface DesktopTerminalHint { + id: string + cwd?: string + running?: string + interactive?: boolean + active?: boolean +} + +export interface DesktopChatCapabilities { + desktopCapabilities?: { + localFilesystem?: true + browser?: true + terminal?: true + browserSessions?: BrowserKnownSession[] + terminals?: DesktopTerminalHint[] + } + /** Compatibility for mothership deployments predating desktopCapabilities.browser. */ + browserCapable?: true +} + +/** + * The capability fragment spread into chat request payloads. Mothership gates + * user-local VFS guidance/routing and the browser subagent on these flags, so + * in a plain web browser the model never sees the features. + */ +export async function getDesktopChatCapabilities(): Promise { + const bridge = getDesktopBridge() + // Never advertise a surface the user switched off, even on the first + // request after launch, before the cached preferences have arrived. + await loadDevicePreferences() + const localFilesystem = hasLocalFilesystem() + const browser = isBrowserAgentEnabled() + const terminal = isTerminalEnabled() + // Sent every request so the agent knows what is already running without + // spending a tool call to ask — and, more importantly, so it notices a + // terminal that is occupied instead of launching a second copy into it. + const terminals: DesktopTerminalHint[] = + terminal && bridge?.terminal?.getTabs + ? await bridge.terminal + .getTabs() + .then((state) => + state.tabs.map((tab) => ({ + id: tab.terminalId, + ...(tab.cwd ? { cwd: tab.cwd } : {}), + ...(tab.running ? { running: tab.running } : {}), + ...(tab.interactive ? { interactive: true as const } : {}), + ...(tab.active ? { active: true as const } : {}), + })) + ) + .catch(() => []) + : [] + const browserSessions = + browser && bridge?.browserAgent?.getKnownSessions + ? await bridge.browserAgent + .getKnownSessions() + .then((state) => state.sessions) + .catch(() => []) + : [] + return { + ...(localFilesystem || browser || terminal + ? { + desktopCapabilities: { + ...(localFilesystem ? { localFilesystem: true as const } : {}), + ...(browser ? { browser: true as const } : {}), + ...(terminal ? { terminal: true as const } : {}), + ...(terminals.length > 0 ? { terminals } : {}), + ...(browserSessions.length > 0 ? { browserSessions } : {}), + }, + } + : {}), + ...(browser ? { browserCapable: true } : {}), + } +} diff --git a/apps/sim/lib/desktop/min-version.test.ts b/apps/sim/lib/desktop/min-version.test.ts new file mode 100644 index 0000000000..97bf3b7390 --- /dev/null +++ b/apps/sim/lib/desktop/min-version.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { compareVersions, isShellOutdated } from '@/lib/desktop/min-version' + +describe('compareVersions', () => { + it('orders release cores numerically', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + expect(compareVersions('1.2.3', '1.2.4')).toBe(-1) + expect(compareVersions('1.10.0', '1.9.9')).toBe(1) + expect(compareVersions('v0.5.24', '0.5.24')).toBe(0) + }) + + it('ranks prereleases below their release', () => { + expect(compareVersions('1.2.3-beta.1', '1.2.3')).toBe(-1) + expect(compareVersions('1.2.3', '1.2.3-rc.9')).toBe(1) + }) + + it('compares prerelease identifiers per semver', () => { + expect(compareVersions('1.4.0-beta.2', '1.4.0-beta.10')).toBe(-1) + expect(compareVersions('1.4.0-rc.1', '1.4.0-beta.9')).toBe(1) + expect(compareVersions('1.4.0-beta.2', '1.4.0-beta.2')).toBe(0) + expect(compareVersions('1.4.0-alpha', '1.4.0-alpha.1')).toBe(-1) + }) + + it('returns null for unparseable input', () => { + expect(compareVersions('nightly', '1.2.3')).toBeNull() + expect(compareVersions('1.2', '1.2.3')).toBeNull() + }) +}) + +describe('isShellOutdated', () => { + it('never gates while the floor is 0.0.0', () => { + expect(isShellOutdated(undefined, '0.0.0')).toBe(false) + expect(isShellOutdated('0.0.1', '0.0.0')).toBe(false) + expect(isShellOutdated('garbage', '0.0.0')).toBe(false) + }) + + it('gates shells below the floor and accepts the floor and newer', () => { + expect(isShellOutdated('0.2.9', '0.3.0')).toBe(true) + expect(isShellOutdated('0.3.0', '0.3.0')).toBe(false) + expect(isShellOutdated('0.4.0', '0.3.0')).toBe(false) + }) + + it('treats a prerelease of the floor as below it', () => { + expect(isShellOutdated('0.3.0-beta.2', '0.3.0')).toBe(true) + }) + + it('fails closed for missing or unparseable shell versions once a floor is set', () => { + expect(isShellOutdated(undefined, '0.3.0')).toBe(true) + expect(isShellOutdated('nightly', '0.3.0')).toBe(true) + }) +}) diff --git a/apps/sim/lib/desktop/min-version.ts b/apps/sim/lib/desktop/min-version.ts new file mode 100644 index 0000000000..59c94bce12 --- /dev/null +++ b/apps/sim/lib/desktop/min-version.ts @@ -0,0 +1,96 @@ +/** + * The minimum desktop shell version this web deployment supports. + * + * The desktop shell is an installed binary that users update on their own + * schedule, while the web app it loads is deployed continuously — so the + * preload bridge contract (`@sim/desktop-bridge` + `@sim/browser-protocol`) + * must stay backward compatible by default. CI enforces that with a contract + * snapshot audit (`bun run check:desktop-bridge`). + * + * When a change genuinely cannot be additive, this floor is the escape + * hatch: bump it to the desktop release the breaking shell change ships in + * and regenerate the snapshot (`bun run desktop-bridge-contract:update`). + * Shells older than the floor get a blocking "update to continue" takeover + * (see `app/_shell/desktop-update-gate.tsx`) instead of silently broken + * features. + * + * `0.0.0` means no floor — every shell is accepted. + */ +export const MIN_DESKTOP_VERSION = '0.0.0' + +interface ParsedVersion { + major: number + minor: number + patch: number + prerelease: string +} + +const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?$/ + +function parseVersion(version: string): ParsedVersion | null { + const match = SEMVER_PATTERN.exec(version) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? '', + } +} + +function comparePrerelease(a: string, b: string): number { + // A release (no prerelease) outranks any prerelease of the same core. + if (a === '' || b === '') { + return a === b ? 0 : a === '' ? 1 : -1 + } + const aParts = a.split('.') + const bParts = b.split('.') + const length = Math.max(aParts.length, bParts.length) + for (let i = 0; i < length; i++) { + const aPart = aParts[i] + const bPart = bParts[i] + if (aPart === undefined) return -1 + if (bPart === undefined) return 1 + const aNumeric = /^\d+$/.test(aPart) + const bNumeric = /^\d+$/.test(bPart) + if (aNumeric && bNumeric) { + const diff = Number(aPart) - Number(bPart) + if (diff !== 0) return diff < 0 ? -1 : 1 + } else if (aNumeric !== bNumeric) { + // Numeric identifiers rank below alphanumeric ones (semver §11). + return aNumeric ? -1 : 1 + } else if (aPart !== bPart) { + return aPart < bPart ? -1 : 1 + } + } + return 0 +} + +/** Standard semver ordering; null when either version is unparseable. */ +export function compareVersions(a: string, b: string): number | null { + const left = parseVersion(a) + const right = parseVersion(b) + if (!left || !right) return null + if (left.major !== right.major) return left.major < right.major ? -1 : 1 + if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1 + if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1 + return comparePrerelease(left.prerelease, right.prerelease) +} + +/** + * Whether a desktop shell is below the supported floor and must update. + * + * Fails closed for shells inside the desktop app: an absent version means the + * shell predates version reporting (older than any real floor), and an + * unparseable one can't be vouched for. Both count as outdated whenever a + * floor is set. With the floor at `0.0.0` nothing is ever gated. + */ +export function isShellOutdated( + shellVersion: string | undefined, + minVersion: string = MIN_DESKTOP_VERSION +): boolean { + if (minVersion === '0.0.0') return false + if (shellVersion === undefined) return true + const comparison = compareVersions(shellVersion, minVersion) + return comparison === null || comparison < 0 +} diff --git a/apps/sim/lib/desktop/panel-focus.test.ts b/apps/sim/lib/desktop/panel-focus.test.ts new file mode 100644 index 0000000000..a215dee16a --- /dev/null +++ b/apps/sim/lib/desktop/panel-focus.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it, vi } from 'vitest' +import { trackPanelFocus } from '@/lib/desktop/panel-focus' + +describe('trackPanelFocus', () => { + it('follows interaction and releases focus outside the panel or on cleanup', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + const outsideButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel, outsideButton) + const reportFocus = vi.fn() + + const cleanup = trackPanelFocus(panel, reportFocus) + // Appearing is not a claim: the agent opens these panels on the user's + // behalf, so a Cmd-W aimed at the chat must not reach a panel they have + // not touched. + expect(reportFocus).not.toHaveBeenCalled() + + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + outsideButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(false) + + panelButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + cleanup() + expect(reportFocus).toHaveBeenLastCalledWith(false) + reportFocus.mockClear() + panelButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).not.toHaveBeenCalled() + + panel.remove() + outsideButton.remove() + }) + + /** + * The regression that motivated hoisting this out of the browser panel: a + * panel with N children must report as ONE owner. Two trackers over the same + * shell flag mean the second one's cleanup erases the first one's live claim, + * which is what let Cmd-W fall through to closing the window. + */ + it('keeps the panel claim when a sibling tracker over the same panel tears down', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel) + let focused = false + const reportFocus = (next: boolean) => { + focused = next + } + + const cleanup = trackPanelFocus(panel, reportFocus) + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(focused).toBe(true) + + // One tracker per panel: the only teardown that drops the claim is the + // panel's own. + cleanup() + expect(focused).toBe(false) + + panel.remove() + }) + + /** + * The terminal panel is opened FOR the user by the agent, so merely + * appearing must not claim the accelerator: a Cmd-W aimed at the chat would + * close a shell the user never touched. It has a real signal to wait for — + * xterm focuses its textarea once the panel is on screen. + */ + it('waits for real interaction before claiming, unless told to claim on appear', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel) + const reportFocus = vi.fn() + + const cleanup = trackPanelFocus(panel, reportFocus) + expect(reportFocus).not.toHaveBeenCalled() + + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + cleanup() + panel.remove() + }) +}) diff --git a/apps/sim/lib/desktop/panel-focus.ts b/apps/sim/lib/desktop/panel-focus.ts new file mode 100644 index 0000000000..f884771b49 --- /dev/null +++ b/apps/sim/lib/desktop/panel-focus.ts @@ -0,0 +1,50 @@ +'use client' + +/** + * Reports which renderer-owned panel currently owns the user's interaction + * context, so the desktop shell can route global menu accelerators — a Cmd-W + * typed into a shell should close that shell, not the window. + * + * Tracking belongs to the PANEL, not to the widgets inside it. A panel that + * renders N children (terminal tabs) must not let each child report on its own + * unmount: the shell holds a single flag per panel, so one child tearing down + * would erase a sibling's live claim, and the accelerator would fall through to + * closing the window. + * + * The claim follows real interaction, and only that: a document-level + * `pointerdown` or `focusin` landing inside the panel. Appearing on screen is + * NOT a claim — the agent opens these panels on the user's behalf, and a panel + * that claimed on appearance would answer a Cmd-W aimed at the chat by closing + * something the user never touched. + * + * This covers only what the shell cannot see for itself — renderer-owned + * chrome. Content rendered in a native view above the renderer (the browser's + * page) emits no DOM events here, and needs none: the shell reads that focus + * directly from the view's own `webContents`. + */ +export function trackPanelFocus( + panel: HTMLElement, + reportFocus: (focused: boolean) => void +): () => void { + // Deduped: these listeners see every pointerdown and focus move in the whole + // app, and each report is an IPC hop whose handler re-registers listeners on + // the main thread. Only transitions are worth sending. + let reported: boolean | null = null + const updateFocusOwner = (target: EventTarget | null) => { + const owns = target instanceof Node && panel.contains(target) + if (owns === reported) return + reported = owns + reportFocus(owns) + } + const handlePointerDown = (event: PointerEvent) => updateFocusOwner(event.target) + const handleFocusIn = (event: FocusEvent) => updateFocusOwner(event.target) + document.addEventListener('pointerdown', handlePointerDown, true) + document.addEventListener('focusin', handleFocusIn, true) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + document.removeEventListener('focusin', handleFocusIn, true) + // Unconditional: the panel is going away, so the shell must not be left + // holding a claim for it even if nothing inside it was ever focused. + reportFocus(false) + } +} diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts new file mode 100644 index 0000000000..6d1cb2b2e8 --- /dev/null +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + channelForHostname, + channelOfVersion, + MANIFEST_ASSET_NAME, + rewriteManifestUrls, + selectReleaseForChannel, +} from '@/lib/desktop/update-feed' + +function release( + tag: string, + options?: { draft?: boolean; prerelease?: boolean; assets?: Array<{ name: string }> } +) { + return { + tag_name: tag, + draft: options?.draft ?? false, + prerelease: options?.prerelease ?? tag.includes('-'), + assets: (options?.assets ?? [{ name: MANIFEST_ASSET_NAME }]).map((asset) => ({ + ...asset, + browser_download_url: `https://example.com/${asset.name}`, + })), + } +} + +describe('channelForHostname', () => { + it('maps hosted environments to their channels', () => { + expect(channelForHostname('dev.sim.ai')).toBe('alpha') + expect(channelForHostname('www.dev.sim.ai')).toBe('alpha') + expect(channelForHostname('staging.sim.ai')).toBe('beta') + expect(channelForHostname('www.staging.sim.ai')).toBe('beta') + expect(channelForHostname('sim.ai')).toBe('latest') + expect(channelForHostname('www.sim.ai')).toBe('latest') + }) + + it('defaults self-hosted and local deployments to stable', () => { + expect(channelForHostname('sim.example.com')).toBe('latest') + expect(channelForHostname('localhost')).toBe('latest') + }) +}) + +describe('channelOfVersion', () => { + it('classifies versions by prerelease tag', () => { + expect(channelOfVersion('0.5.24')).toBe('latest') + expect(channelOfVersion('0.5.25-beta.3')).toBe('beta') + expect(channelOfVersion('0.5.25-alpha.412')).toBe('alpha') + }) +}) + +describe('selectReleaseForChannel', () => { + const releases = [ + release('v0.5.25-alpha.412'), + release('v0.5.24'), + release('v0.5.25-beta.2'), + release('v0.5.23'), + release('v0.5.26-alpha.1', { draft: true }), + ] + + it('offers stable-only to the latest channel', () => { + expect(selectReleaseForChannel(releases, 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('offers only beta builds to the beta channel', () => { + expect(selectReleaseForChannel(releases, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + }) + + it('offers only alpha builds to the alpha channel, never beta builds', () => { + // Dev and staging both cut prereleases of the same next core version; + // semver ranks beta above alpha there, so cross-channel leakage would + // put staging builds on dev clients. + expect(selectReleaseForChannel(releases, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + }) + + it('never serves stable prod-identity builds to prerelease channels', () => { + // Alpha/beta are internal channels with their own app identity (Sim Dev / + // Sim Staging); a stable Sim.app artifact can't be applied by those + // shells, so a newer stable must not shadow the channel's own builds. + const withNewStable = [...releases, release('v0.5.25')] + expect(selectReleaseForChannel(withNewStable, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + expect(selectReleaseForChannel(withNewStable, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + expect(selectReleaseForChannel(withNewStable, 'latest')?.tag_name).toBe('v0.5.25') + }) + + it('skips stable-tagged releases flagged prerelease on the latest channel', () => { + const flagged = [release('v0.5.25', { prerelease: true }), release('v0.5.24')] + expect(selectReleaseForChannel(flagged, 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('skips releases missing the updater manifest asset', () => { + // A release whose build failed (or is mid-upload) must not take the + // channel down; the previous good release keeps serving. + const withBrokenNewest = [ + release('v0.5.25-alpha.413', { assets: [{ name: 'Sim-0.5.25-alpha.413-universal.dmg' }] }), + release('v0.5.25-alpha.412'), + ] + expect(selectReleaseForChannel(withBrokenNewest, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + }) + + it('tolerates release listings without asset data', () => { + const bare = { tag_name: 'v0.5.24', draft: false, prerelease: false } + expect(selectReleaseForChannel([bare], 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('skips drafts and unparseable tags', () => { + expect(selectReleaseForChannel([release('v0.5.26-alpha.1', { draft: true })], 'alpha')).toBe( + null + ) + expect(selectReleaseForChannel([release('nightly')], 'alpha')).toBe(null) + }) +}) + +describe('rewriteManifestUrls', () => { + it('rewrites relative url and path entries to absolute asset URLs', () => { + const manifest = [ + 'version: 0.5.24', + 'files:', + ' - url: Sim-0.5.24-universal-mac.zip', + ' sha512: abc', + ' size: 123', + 'path: Sim-0.5.24-universal-mac.zip', + 'sha512: abc', + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') + const rewritten = rewriteManifestUrls(manifest, 'v0.5.24') + expect(rewritten).toContain( + ' - url: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + ) + expect(rewritten).toContain( + 'path: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + ) + expect(rewritten).toContain('sha512: abc') + }) + + it('leaves already-absolute URLs alone', () => { + const manifest = ' - url: https://cdn.example.com/Sim.zip' + expect(rewriteManifestUrls(manifest, 'v0.5.24')).toBe(manifest) + }) +}) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts new file mode 100644 index 0000000000..969477db12 --- /dev/null +++ b/apps/sim/lib/desktop/update-feed.ts @@ -0,0 +1,119 @@ +/** + * Per-environment desktop update feed resolution. + * + * Installed desktop shells ask the Sim deployment they are pointed at — + * `GET /api/desktop/update/latest-mac.yml` — instead of a global + * GitHub feed, so each environment independently controls which shell build + * its clients are offered. The environment IS the channel: + * + * - dev.sim.ai → `alpha` (per-push prerelease builds from `dev`) + * - staging.sim.ai → `beta` (per-push prerelease builds from `staging`) + * - sim.ai + self-hosted/unknown → `latest` (stable vX.Y.Z releases only) + * + * Artifacts stay on GitHub Releases (dumb storage); the feed route picks the + * right release for its channel and serves that release's electron-updater + * manifest with download URLs rewritten to absolute GitHub asset URLs. + * + * Channels are strictly isolated: alpha serves only `-alpha.` prereleases, + * beta only `-beta.` prereleases, and `latest` only stable releases. Builds + * carry per-channel app identity (Sim Dev / Sim Staging / Sim), so serving a + * stable prod-identity artifact to a dev shell would offer an update + * Squirrel.Mac cannot apply (bundle-id mismatch) — each channel only ever + * moves forward on its own artifacts. + */ +import { compareVersions } from '@/lib/desktop/min-version' + +export const DESKTOP_RELEASE_REPO = 'simstudioai/sim' + +export type DesktopUpdateChannel = 'alpha' | 'beta' | 'latest' + +/** Maps a deployment hostname to its desktop update channel. */ +export function channelForHostname(hostname: string): DesktopUpdateChannel { + const host = hostname.toLowerCase() + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) { + return 'alpha' + } + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) { + return 'beta' + } + return 'latest' +} + +/** The channel a specific version belongs to, from its prerelease tag. */ +export function channelOfVersion(version: string): DesktopUpdateChannel { + if (version.includes('-alpha.')) return 'alpha' + if (version.includes('-beta.')) return 'beta' + return 'latest' +} + +/** + * The manifest asset every desktop build uploads. electron-builder's GitHub + * provider always names it `latest-mac.yml` regardless of the version's + * prerelease tag (channels are a generic-provider concept); which channel a + * release belongs to is carried entirely by its tag. + */ +export const MANIFEST_ASSET_NAME = 'latest-mac.yml' + +/** The subset of the GitHub releases API the feed needs. */ +export interface DesktopReleaseCandidate { + tag_name: string + draft: boolean + prerelease: boolean + assets?: Array<{ name: string; browser_download_url: string }> +} + +/** + * Picks the newest release of the channel's own kind. Channels never see + * another channel's artifacts (see module docs). Releases without their + * updater manifest asset are skipped — a release created before its build + * finished (or whose build failed) must not take the channel down. Returns + * null when nothing qualifies. + */ +export function selectReleaseForChannel( + releases: DesktopReleaseCandidate[], + channel: DesktopUpdateChannel +): DesktopReleaseCandidate | null { + let best: DesktopReleaseCandidate | null = null + let bestVersion = '' + for (const release of releases) { + if (release.draft) continue + const version = release.tag_name.replace(/^v/, '') + if (channelOfVersion(version) !== channel) continue + // Defense in depth: a bare vX.Y.Z tag manually marked "pre-release" on + // GitHub must not reach stable clients. + if (channel === 'latest' && release.prerelease) continue + if (release.assets && !release.assets.some((asset) => asset.name === MANIFEST_ASSET_NAME)) { + continue + } + if (best === null) { + const valid = compareVersions(version, '0.0.0') + if (valid === null) continue + best = release + bestVersion = version + continue + } + const comparison = compareVersions(version, bestVersion) + if (comparison !== null && comparison > 0) { + best = release + bestVersion = version + } + } + return best +} + +/** + * Rewrites the manifest's relative artifact references (`url:` entries and + * the legacy top-level `path:`) to absolute GitHub release asset URLs, so + * the shell downloads artifacts (and their `.blockmap`s, resolved relative + * to the file URL) straight from GitHub while the feed itself stays served + * by this deployment. + */ +export function rewriteManifestUrls(manifest: string, tag: string): string { + const base = `https://github.com/${DESKTOP_RELEASE_REPO}/releases/download/${tag}/` + return manifest.replace(/^(\s*(?:-\s*)?(?:url|path):\s*)(\S+)\s*$/gm, (line, prefix, value) => { + if (value.startsWith('http://') || value.startsWith('https://')) { + return line + } + return `${prefix}${base}${encodeURIComponent(value)}` + }) +} diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a743765f9f..3e7bd38b19 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -799,6 +799,28 @@ export async function cancelInvitation(invitationId: string): Promise { return result.length > 0 } +/** + * Pending, unexpired invitations addressed to an email — the invitee-facing + * list (workspace-switcher Invitations section). Session-bound callers accept + * without a token, so the rows returned here must never need one. + */ +export async function listPendingInvitationsForEmail( + email: string +): Promise { + const rows = await db + .select() + .from(invitation) + .where( + and( + sql`lower(${invitation.email}) = ${normalizeEmail(email)}`, + eq(invitation.status, 'pending'), + sql`${invitation.expiresAt} > now()` + ) + ) + .orderBy(invitation.createdAt) + return Promise.all(rows.map((row) => hydrateInvitation(row))) +} + export async function listPendingInvitationsForOrganization(organizationId: string) { return db .select({ diff --git a/apps/sim/lib/invitations/error-messages.ts b/apps/sim/lib/invitations/error-messages.ts new file mode 100644 index 0000000000..629356c651 --- /dev/null +++ b/apps/sim/lib/invitations/error-messages.ts @@ -0,0 +1,32 @@ +/** + * Human-readable copy for invitation accept/decline failures. The accept and + * reject routes return `{ error: }` where `` is a machine code + * (e.g. `no-seats-available`); `requestJson` surfaces that raw code as the + * thrown error's `message`. In-app surfaces (the workspace-switcher + * invitations modal) map the code through here so users see a sentence, not a + * kebab-case token. The full-page `/invite` flow has its own richer map (with + * retry/auth affordances); this is the message-only slice for the in-app path. + */ +const INVITATION_ERROR_MESSAGES: Record = { + 'not-found': 'This invitation is invalid or no longer exists.', + 'invalid-token': 'This invitation link is invalid or has already been used.', + expired: 'This invitation has expired. Ask for a new one.', + 'already-processed': 'This invitation has already been accepted or declined.', + 'email-mismatch': 'This invitation was sent to a different email address.', + 'already-in-organization': + 'You are already in an organization. Leave it before accepting a new invitation.', + 'no-seats-available': + 'This organization has reached its seat limit. Ask an admin to add seats, then try again.', + 'upgrade-required': + 'The workspace owner needs an active paid plan before you can join. Ask them to update it, then try again.', + 'server-error': 'Something went wrong processing the invitation. Please try again.', +} + +/** + * Maps an invitation error code (the thrown message from a failed accept/reject + * request) to friendly copy, falling back to `fallback` for unknown codes such + * as network errors. + */ +export function getInvitationErrorMessage(code: string, fallback: string): string { + return INVITATION_ERROR_MESSAGES[code] ?? fallback +} diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 3e22087c8c..194755c9a0 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -8,9 +8,9 @@ import { ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js' import { createLogger } from '@sim/logger' +import { isPrivateIp } from '@sim/security/ssrf' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch' @@ -102,7 +102,7 @@ export class McpClient { // permits it) — the guarded lookup would filter it, so that case keeps the legacy pin // to the validated address (old behavior + its anti-rebinding property). const guarded = resolvedIP - ? isPrivateOrReservedIP(resolvedIP) + ? isPrivateIp(resolvedIP) ? createPinnedPrivateMcpFetch(resolvedIP) : createGuardedMcpFetch() : undefined diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index b30b2d373f..aca421d712 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -1,33 +1,13 @@ /** * @vitest-environment node */ -import { - envFlagsMockFns, - inputValidationMock, - inputValidationMockFns, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn(), })) -vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) - -inputValidationMockFns.mockIsPrivateOrReservedIP.mockImplementation((ip: string) => { - if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true - if (ip.startsWith('172.')) { - const second = Number.parseInt(ip.split('.')[1], 10) - if (second >= 16 && second <= 31) return true - } - if (ip.startsWith('169.254.')) return true - if (ip.startsWith('127.') || ip === '::1') return true - if (ip === '0.0.0.0') return true - return false -}) - vi.mock('dns/promises', () => ({ default: { lookup: mockDnsLookup }, })) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 1c4c40e3ac..94c66a1733 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -1,9 +1,8 @@ import dns from 'dns/promises' import { createLogger } from '@sim/logger' +import { isIpLiteral, isLoopbackIp, isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' -import * as ipaddr from 'ipaddr.js' import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags' -import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('McpDomainCheck') @@ -99,25 +98,13 @@ export function validateMcpDomain(url: string | undefined): void { } /** - * Returns true if the IP is a loopback address (full 127.0.0.0/8 range, or ::1). - */ -function isLoopbackIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) return false - return ipaddr.process(ip).range() === 'loopback' - } catch { - return false - } -} - -/** - * Returns true if the hostname is localhost or a loopback IP literal. - * Expects IPv6 brackets to already be stripped. + * Returns true if the hostname is localhost or a loopback IP literal (full + * 127.0.0.0/8 range, or ::1). Expects IPv6 brackets to already be stripped. */ function isLocalhostHostname(hostname: string): boolean { const clean = hostname.toLowerCase() if (clean === 'localhost') return true - return ipaddr.isValid(clean) && isLoopbackIP(clean) + return isLoopbackIp(clean) } /** @@ -165,8 +152,7 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise ({ - isPrivateOrReservedIP: (ip: string) => - ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', createSsrfGuardedFetchWithDispatcher: vi.fn(() => ({ fetch: mockUndiciFetch, dispatcher: { destroy: vi.fn(() => Promise.resolve()) }, })), })) +/** + * Stubbed so the suite's `203.0.113.10` reads as an ordinary public address. + * The real classifier treats TEST-NET-3 as reserved, which would route every + * "public IP" case down the pinned-private branch instead. + */ +vi.mock('@sim/security/ssrf', () => ({ + isPrivateIp: (ip: string) => ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', +})) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, })) diff --git a/apps/sim/lib/mcp/oauth/url-validation.ts b/apps/sim/lib/mcp/oauth/url-validation.ts index 81292bbe63..bae6b9aa2a 100644 --- a/apps/sim/lib/mcp/oauth/url-validation.ts +++ b/apps/sim/lib/mcp/oauth/url-validation.ts @@ -1,4 +1,4 @@ -import { isLoopbackHostname } from '@/lib/core/utils/urls' +import { isLoopbackHostname } from '@sim/security/hostnames' export class McpOauthInsecureUrlError extends Error { constructor(url: string) { diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 32f15a9635..3d9d8518b8 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -21,8 +21,14 @@ const { vi.mock('@/lib/core/security/input-validation.server', () => ({ createSsrfGuardedFetchWithDispatcher: mockCreateGuardedFetchWithDispatcher, createPinnedFetchWithDispatcher: mockCreatePinnedFetchWithDispatcher, - isPrivateOrReservedIP: (ip: string) => - ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', +})) +/** + * Stubbed so the suite's `203.0.113.10` reads as an ordinary public address. + * The real classifier treats TEST-NET-3 as reserved, which would route every + * "public IP" case down the pinned-private branch instead. + */ +vi.mock('@sim/security/ssrf', () => ({ + isPrivateIp: (ip: string) => ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', })) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index e739b2055f..d5169a2b69 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,10 +1,10 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' +import { isPrivateIp } from '@sim/security/ssrf' import type { Agent } from 'undici' import { createPinnedFetchWithDispatcher, createSsrfGuardedFetchWithDispatcher, - isPrivateOrReservedIP, } from '@/lib/core/security/input-validation.server' import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' import { McpError } from '@/lib/mcp/types' @@ -292,7 +292,7 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU const resolvedIP = await withDeadline(validateMcpServerSsrf(target), signal) logger.info('OAuth guarded fetch: requesting', { host, guarded: Boolean(resolvedIP) }) let response: Response - if (resolvedIP && isPrivateOrReservedIP(resolvedIP)) { + if (resolvedIP && isPrivateIp(resolvedIP)) { // Self-hosted private/loopback resolution (policy-permitted): the guarded lookup // would filter the address, and an unguarded fallback would reopen rebinding — // keep the legacy pin to the validated address for exactly this case. diff --git a/apps/sim/lib/terminal/transport.ts b/apps/sim/lib/terminal/transport.ts new file mode 100644 index 0000000000..2328c53f8a --- /dev/null +++ b/apps/sim/lib/terminal/transport.ts @@ -0,0 +1,161 @@ +/** + * Transport for the agent terminals: real PTYs in the Sim desktop app, reached + * through the preload bridge (`window.simDesktop.terminal`). + * + * The shell processes live in the Electron main process; the renderer paints + * their bytes with xterm.js and forwards keystrokes back, so the user and the + * agent share the same terminals. Availability of this bridge, plus the device + * switch on the Terminal settings page, is what gates advertising + * `terminalCapable` to the copilot — in a regular web browser there is no + * bridge and the terminal tools are never offered. + */ +import type { SimDesktopTerminalApi } from '@sim/desktop-bridge' +import type { + TerminalOperation, + TerminalStartOptions, + TerminalTabsState, + TerminalToolArgs, +} from '@sim/terminal-protocol' +import { getDesktopBridge, isTerminalEnabled } from '@/lib/desktop' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +let initialized = false + +function bridge(): SimDesktopTerminalApi | null { + return getDesktopBridge()?.terminal ?? null +} + +/** True when terminal tools can run (gates the copilot's terminalCapable flag). */ +export function isTerminalAvailable(): boolean { + return isTerminalEnabled() +} + +/** + * Idempotently wires tab and command pushes into the store. Output is + * deliberately not routed here: each xterm subscribes to it directly so bytes + * never pass through React state. + */ +export function initTerminalTransport(): void { + if (initialized) return + const terminal = bridge() + if (!terminal) return + initialized = true + + // Every surface below is optional-called. The same web app is served to + // shells of any age, and these arrived after the terminal first shipped: a + // bare call on an older shell threw a TypeError out of the mount effect that + // invokes this, taking the whole chat view to the error boundary instead of + // degrading to "no terminal". + terminal.onTabs?.((tabs) => { + useCopilotTerminalStore.getState().setTabs(tabs) + }) + terminal.onCommand?.((event) => { + useCopilotTerminalStore.getState().applyCommandEvent(event) + }) + void terminal + .getTabs?.() + ?.then((tabs) => useCopilotTerminalStore.getState().setTabs(tabs)) + .catch(() => {}) +} + +/** + * One bridge subscription for all terminals, fanned out by id. + * + * Every mounted terminal view wants only its own bytes, but each PTY message + * crosses the context bridge once per registered listener — so a listener per + * view made a single terminal's output cost O(open tabs) crossings a message, + * most of them discarded by an id check. This keeps exactly one bridge + * listener and routes each message to the view that asked for that id. + */ +const dataHandlers = new Map void>() +let dataBridgeUnsubscribe: (() => void) | null = null + +function ensureDataBridge(): void { + if (dataBridgeUnsubscribe) return + // Only latch once a real subscription exists. Caching a no-op because the + // bridge happened to be absent on the first call left every terminal in the + // session with no output and no way to recover. + const unsubscribe = bridge()?.onData?.((id, data) => dataHandlers.get(id)?.(data)) + if (unsubscribe) dataBridgeUnsubscribe = unsubscribe +} + +/** Subscribes to raw PTY output for one terminal. */ +export function onTerminalData(terminalId: string, callback: (data: string) => void): () => void { + ensureDataBridge() + dataHandlers.set(terminalId, callback) + return () => { + if (dataHandlers.get(terminalId) === callback) dataHandlers.delete(terminalId) + } +} + +/** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Empty when the desktop bridge is unavailable, which leaves the view + * blank rather than failing the mount. + */ +/** + * Tells the desktop app whether a terminal owns keyboard focus. Menu + * accelerators are global, so Cmd-W has to know whether the user is typing in + * a shell before it decides what to close. + */ +export function reportTerminalFocused(focused: boolean): void { + bridge()?.setFocused?.(focused) +} + +/** Tells a waiting handoff that the user is done in the terminal. */ +export function finishTerminalHandoff(terminalId: string): void { + bridge()?.finishHandoff?.(terminalId) +} + +export async function getTerminalScrollback(terminalId: string): Promise { + return (await bridge()?.getScrollback(terminalId)) ?? '' +} + +export async function startTerminalSession( + options: TerminalStartOptions +): Promise { + const terminal = bridge() + if (!terminal) { + throw new Error('The Sim desktop terminal is unavailable.') + } + return terminal.start(options) +} + +export function writeToTerminal(terminalId: string, data: string): void { + bridge()?.write(terminalId, data) +} + +export function resizeTerminal(terminalId: string, cols: number, rows: number): void { + bridge()?.resize(terminalId, cols, rows) +} + +export async function openTerminal(cwd?: string): Promise { + await bridge()?.openTerminal(cwd) +} + +export async function switchTerminal(terminalId: string): Promise { + await bridge()?.switchTerminal(terminalId) +} + +export async function closeTerminal(terminalId: string): Promise { + await bridge()?.closeTerminal(terminalId) +} + +/** Executes one terminal operation in the desktop main process. */ +export async function executeTerminalTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs +): Promise { + const terminal = bridge() + if (!terminal) { + throw new Error('The Sim desktop terminal is unavailable.') + } + const response = await terminal.executeTool(toolCallId, operation, args) + if (!response.ok) { + const error = new Error(response.error || 'The terminal reported an error') + if (response.code) error.name = response.code + throw error + } + return response.result +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 6e67c22657..12daaef648 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -258,16 +257,8 @@ export async function getWorkspaceFileFolderPath( export async function findWorkspaceFileFolderIdByPath( workspaceId: string, - pathSegments: string[], - options?: { includeReservedSystemFolders?: boolean } + pathSegments: string[] ): Promise { - if ( - !options?.includeReservedSystemFolders && - isReservedWorkflowAliasBackingDisplayPath(pathSegments.join('/')) - ) { - return null - } - let parentId: string | null = null for (const rawSegment of pathSegments) { @@ -288,9 +279,9 @@ export async function findWorkspaceFileFolderIdByPath( export async function listWorkspaceFileFolders( workspaceId: string, - options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean } + options?: { scope?: WorkspaceFileFolderScope } ): Promise { - const { scope = 'active', includeReservedSystemFolders = false } = options ?? {} + const { scope = 'active' } = options ?? {} const rows = await db .select() .from(workspaceFileFolder) @@ -310,12 +301,7 @@ export async function listWorkspaceFileFolders( .orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt)) const paths = buildWorkspaceFileFolderPathMap(rows) - return rows - .map((row) => mapFolder(row, paths)) - .filter( - (folder) => - includeReservedSystemFolders || !isReservedWorkflowAliasBackingDisplayPath(folder.path) - ) + return rows.map((row) => mapFolder(row, paths)) } export async function getWorkspaceFileFolder( @@ -456,9 +442,7 @@ export async function ensureWorkspaceFileFolderPath(params: { // Fast path: the whole chain already exists (the common case for repeated // writes into known folders) — per-segment indexed lookups instead of // loading the workspace's entire folder table. - const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { - includeReservedSystemFolders: true, - }) + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments) if (existing) return existing // Load all active folders once and build a lookup keyed by "name|parentId" diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index b1ae9b45da..93be39d656 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,8 +19,6 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -83,7 +81,6 @@ interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean - includeReservedSystemFiles?: boolean /** Propagate storage errors when an incomplete list would be unsafe. */ throwOnError?: boolean } @@ -756,11 +753,7 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { - scope = 'active', - hydrateFolderPaths = true, - includeReservedSystemFiles = false, - } = options ?? {} + const { scope = 'active', hydrateFolderPaths = true } = options ?? {} const files = await db .select() .from(workspaceFiles) @@ -784,24 +777,13 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = - files.some((file) => file.folderId) && (hydrateFolderPaths || !includeReservedSystemFiles) + const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths const folders = needsFolderPaths - ? includeReservedSystemFiles && options?.folders - ? options.folders - : await listWorkspaceFileFolders(workspaceId, { - scope: 'all', - includeReservedSystemFolders: true, - }) + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) : [] const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - return files - .map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) - .filter((file) => { - if (includeReservedSystemFiles) return true - return !isReservedWorkflowAliasBackingDisplayPath(file.folderPath) - }) + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -895,9 +877,7 @@ async function getWorkspaceFileByExactReference( return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null }) } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1), { - includeReservedSystemFolders: true, - }) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null } @@ -908,12 +888,6 @@ export async function resolveWorkspaceFileReference( workspaceId: string, fileReference: string ): Promise { - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: fileReference }) - if (alias) { - if (alias.kind === 'plans_dir') return null - return resolveWorkspaceFileReference(workspaceId, alias.backingPath) - } - const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { const file = await getWorkspaceFile(workspaceId, normalizedReference) @@ -926,7 +900,7 @@ export async function resolveWorkspaceFileReference( ) if (exactReferenceFile) return exactReferenceFile - const files = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) + const files = await listWorkspaceFiles(workspaceId) return findWorkspaceFileRecord(files, fileReference) } diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 1bc82ac626..6543dd4509 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -23,10 +23,21 @@ export const defaultUserSettings: UserSettingsApi = { errorNotificationsEnabled: true, snapToGridSize: 0, showActionBar: true, + copilotAutoAllowedTools: [], timezone: null, lastActiveWorkspaceId: null, } +/** + * The auto-allowed tool list is a jsonb column, so the driver hands back + * `unknown`. Anything that is not an array of strings is treated as an empty + * list: a malformed value must not widen what the copilot may run unprompted. + */ +function normalizeAutoAllowedTools(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === 'string') +} + /** * Loads a user's settings, falling back to {@link defaultUserSettings} when the * user is unauthenticated or has no persisted settings row. @@ -49,6 +60,7 @@ export async function getUserSettings(userId: string | null): Promise 0) { diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 682a736d95..ed25cd352d 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -6,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server' import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -459,8 +458,6 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { throw new Error(saveResult.error || 'Failed to save workflow state') } - await ensureWorkflowAliasBacking({ workspaceId, userId, workflowId, workflowName: name }) - return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now } } diff --git a/apps/sim/package.json b/apps/sim/package.json index 2de8ab923e..d2f851743a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -106,12 +106,15 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", @@ -132,6 +135,11 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "ajv": "8.18.0", "archiver": "8.0.0", "better-auth": "1.6.23", diff --git a/apps/sim/stores/browser-session/store.test.ts b/apps/sim/stores/browser-session/store.test.ts new file mode 100644 index 0000000000..03629366ba --- /dev/null +++ b/apps/sim/stores/browser-session/store.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +describe('browser session store', () => { + beforeEach(() => { + useBrowserSessionStore.setState({ + pageState: null, + tabs: [], + activeTabId: null, + tabsSupported: false, + panelSnapshot: null, + sessionAlive: true, + }) + }) + + it('restores the active page summary from an initial tab-list read', () => { + useBrowserSessionStore.getState().setTabsState({ + activeTabId: '2', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: false, + }, + { + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: true, + active: true, + }, + ], + }) + + expect(useBrowserSessionStore.getState().pageState).toEqual({ + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: true, + canGoBack: false, + canGoForward: false, + }) + }) + + it('clears page state when the last tab closes', () => { + useBrowserSessionStore.setState({ + pageState: { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + canGoBack: false, + canGoForward: false, + }, + }) + + useBrowserSessionStore.getState().setTabsState({ tabs: [], activeTabId: null }) + + expect(useBrowserSessionStore.getState().pageState).toBeNull() + expect(useBrowserSessionStore.getState().sessionAlive).toBe(false) + }) +}) diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts new file mode 100644 index 0000000000..7dc70aa11b --- /dev/null +++ b/apps/sim/stores/browser-session/store.ts @@ -0,0 +1,151 @@ +import type { + BrowserPageState, + BrowserPanelSnapshot, + BrowserTabState, + BrowserTabsState, +} from '@sim/browser-protocol' +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface BrowserSessionState { + /** Live state of the agent browser's active page, pushed by the desktop app. */ + pageState: BrowserPageState | null + /** All live tabs, available on desktop versions with multi-tab support. */ + tabs: BrowserTabState[] + activeTabId: string | null + tabsSupported: boolean + /** Last browser frame captured for display beneath renderer overlays. */ + panelSnapshot: BrowserPanelSnapshot | null + /** False after the browser session ends; true again when a new one starts. */ + sessionAlive: boolean + setPageState: (state: BrowserPageState) => void + setTabsState: (state: BrowserTabsState) => void + setTabsSupported: (supported: boolean) => void + setPanelSnapshot: (snapshot: BrowserPanelSnapshot) => void + setSessionAlive: (alive: boolean) => void +} + +function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean { + return ( + a.tabId === b.tabId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.active === b.active && + a.pinned === b.pinned + ) +} + +/** True when two tab lists carry the same values, so the old array can be kept. */ +function tabsEqual(a: BrowserTabState[], b: BrowserTabState[]): boolean { + return a.length === b.length && a.every((tab, index) => tabFieldsEqual(tab, b[index])) +} + +function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null): boolean { + if (a === b) return true + if (!a || !b) return false + return ( + a.tabId === b.tabId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward + ) +} + +const initialState = { + pageState: null as BrowserPageState | null, + tabs: [] as BrowserTabState[], + activeTabId: null as string | null, + tabsSupported: false, + panelSnapshot: null as BrowserPanelSnapshot | null, + sessionAlive: true, +} + +export const useBrowserSessionStore = create()( + devtools( + (set) => ({ + ...initialState, + setPageState: (pageState) => + set((state) => { + if (!pageState.tabId) { + // No tab dimension to fold in; only touch pageState if it changed. + return pageStateEqual(state.pageState, pageState) + ? { sessionAlive: true } + : { pageState, sessionAlive: true } + } + const nextTabs = state.tabs.map((tab) => + tab.tabId === pageState.tabId + ? { + ...tab, + url: pageState.url, + title: pageState.title, + loading: pageState.loading, + active: true, + } + : tab.active + ? { ...tab, active: false } + : tab + ) + // Keep the old array when nothing moved, so subscribers keyed on tab + // identity do not re-render on a page-state push that changed nothing. + const tabs = tabsEqual(state.tabs, nextTabs) ? state.tabs : nextTabs + if ( + tabs === state.tabs && + state.activeTabId === pageState.tabId && + pageStateEqual(state.pageState, pageState) + ) { + return { sessionAlive: true } + } + return { pageState, sessionAlive: true, activeTabId: pageState.tabId, tabs } + }), + setTabsState: ({ tabs: incomingTabs, activeTabId }) => + set((state) => { + // Reuse the existing array when the values match, so an identical + // push (a background tab's title event, say) is inert for React. + const tabs = tabsEqual(state.tabs, incomingTabs) ? state.tabs : incomingTabs + const activeTab = tabs.find((tab) => tab.tabId === activeTabId) + const hasCurrentPageState = + state.pageState?.tabId !== undefined && state.pageState.tabId === activeTabId + return { + tabs, + activeTabId, + // A tabs-capable shell reporting an empty list is authoritative: + // there is no page to snapshot or act on. Older single-tab shells + // never call this setter, so their compatibility default remains. + sessionAlive: tabs.length > 0, + ...(!activeTab + ? { pageState: null } + : hasCurrentPageState + ? {} + : { + pageState: { + tabId: activeTab.tabId, + url: activeTab.url, + title: activeTab.title, + loading: activeTab.loading, + canGoBack: false, + canGoForward: false, + }, + }), + } + }), + setTabsSupported: (tabsSupported) => set({ tabsSupported }), + setPanelSnapshot: (panelSnapshot) => set({ panelSnapshot }), + setSessionAlive: (alive) => + set( + alive + ? { sessionAlive: true } + : { + sessionAlive: false, + pageState: null, + tabs: [], + activeTabId: null, + panelSnapshot: null, + } + ), + }), + { name: 'browser-session-store' } + ) +) diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index e905109dcd..1046d3d231 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -65,6 +65,23 @@ export const MOTHERSHIP_WIDTH = { MIN: 280, /** Maximum is 65% of viewport, enforced dynamically */ MAX_PERCENTAGE: 0.65, + /** + * Narrowest the chat column beside the panel may be laid out at — the + * `min-w-[320px]` class on that column in home.tsx, so the two must agree. + * + * The panel is what yields to it: the chat's flex-basis is 0, so the whole of + * any negative free space is taken out of the panel. A width written past + * `container - CHAT_MIN` therefore renders clamped while the inline style + * still reads what was asked for, and anything deriving the divider from that + * value follows an edge that is not on screen. + */ + CHAT_MIN: 320, + /** + * Share of the viewport the panel takes while unpinned — the `w-1/2` class in + * mothership-view. Also reported to the desktop shell so it can re-derive the + * panel rect mid-resize, so the two must agree. + */ + DEFAULT_RATIO: 0.5, } as const /** Terminal block column width - minimum width for the logs column */ diff --git a/apps/sim/stores/copilot-terminal/store.test.ts b/apps/sim/stores/copilot-terminal/store.test.ts new file mode 100644 index 0000000000..33fbd0b846 --- /dev/null +++ b/apps/sim/stores/copilot-terminal/store.test.ts @@ -0,0 +1,96 @@ +import type { TerminalTabState } from '@sim/terminal-protocol' +import { beforeEach, describe, expect, it } from 'vitest' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +function tab(overrides: Partial = {}): TerminalTabState { + return { + terminalId: 't1', + title: 'code', + cwd: '/Users/me/code', + running: null, + interactive: false, + active: true, + ...overrides, + } +} + +describe('copilot terminal store', () => { + beforeEach(() => { + useCopilotTerminalStore.setState({ + tabs: { tabs: [], activeTerminalId: null }, + agentCommandIds: [], + }) + }) + + /** + * The desktop app re-pushes the whole tab list on a timer, so an identical + * push has to keep its identity or the panel and every terminal in it + * re-render once a second for nothing. + */ + it('keeps state identity when a push says nothing new', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).toBe(first) + }) + + it.each([ + ['a command starts', { running: 'bun test' }], + ['the directory changes', { cwd: '/tmp' }], + ['the title changes', { title: 'tmp' }], + ['a full-screen program takes over', { interactive: true }], + ['the tab stops being active', { active: false }], + ['tmux attaches', { tmuxSession: 'main' }], + ])('takes the update when %s', (_case, change) => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab(change)], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + expect(useCopilotTerminalStore.getState().tabs.tabs[0]).toMatchObject(change) + }) + + it('takes the update when the active terminal changes', () => { + const { setTabs } = useCopilotTerminalStore.getState() + const tabs = [tab(), tab({ terminalId: 't2', active: false })] + setTabs({ tabs, activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs, activeTerminalId: 't2' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + }) + + it('takes the update when a tab opens or closes', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab(), tab({ terminalId: 't2' })], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + expect(useCopilotTerminalStore.getState().tabs.tabs).toHaveLength(2) + }) + + /** + * The comparator walks keys rather than a written-out field list precisely so + * that a field added to the protocol cannot quietly stop reaching the UI. + */ + it('takes the update when a tab carries a field the comparator never named', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ + tabs: [{ ...tab(), somethingNew: true } as TerminalTabState], + activeTerminalId: 't1', + }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + }) +}) diff --git a/apps/sim/stores/copilot-terminal/store.ts b/apps/sim/stores/copilot-terminal/store.ts new file mode 100644 index 0000000000..7008718e11 --- /dev/null +++ b/apps/sim/stores/copilot-terminal/store.ts @@ -0,0 +1,75 @@ +import type { + TerminalCommandEvent, + TerminalTabState, + TerminalTabsState, +} from '@sim/terminal-protocol' +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** + * Renderer-side view of the agent terminals. Deliberately holds no PTY output: + * each xterm instance owns its own byte stream and scrollback, and pushing + * hundreds of chunks a second through React state would stall the UI. + * + * Named `copilot-terminal` because `stores/terminal` is the workflow editor's + * execution-log panel, which is unrelated. + */ +interface CopilotTerminalState { + tabs: TerminalTabsState + /** Tool call ids whose commands the agent is currently running. */ + agentCommandIds: string[] + setTabs: (tabs: TerminalTabsState) => void + applyCommandEvent: (event: TerminalCommandEvent) => void + reset: () => void +} + +const initialState = { + tabs: { tabs: [], activeTerminalId: null } as TerminalTabsState, + agentCommandIds: [] as string[], +} + +/** + * The desktop app pushes the whole tab list whenever any one tab's metadata + * moves — the cwd poll alone repeats it once a second. Storing a fresh object + * each time re-renders the panel and every terminal in it for a push that + * usually says nothing new, so unchanged state keeps its identity. + */ +function tabsEqual(a: TerminalTabsState, b: TerminalTabsState): boolean { + if (a.activeTerminalId !== b.activeTerminalId) return false + if (a.tabs.length !== b.tabs.length) return false + return a.tabs.every((tab, index) => tabEqual(tab, b.tabs[index])) +} + +/** + * Compares by key rather than by a written-out field list, so a field added to + * the protocol cannot quietly stop reaching the UI. Every field is a primitive, + * which makes this total. + */ +function tabEqual(a: TerminalTabState, b: TerminalTabState): boolean { + const keys = Object.keys(a) as Array + if (keys.length !== Object.keys(b).length) return false + return keys.every((key) => a[key] === b[key]) +} + +export const useCopilotTerminalStore = create()( + devtools( + (set) => ({ + ...initialState, + setTabs: (tabs) => set((state) => (tabsEqual(state.tabs, tabs) ? {} : { tabs })), + applyCommandEvent: (event) => + set((state) => { + if (!event.toolCallId) return {} + const toolCallId = event.toolCallId + return event.phase === 'start' + ? { + agentCommandIds: state.agentCommandIds.includes(toolCallId) + ? state.agentCommandIds + : [...state.agentCommandIds, toolCallId], + } + : { agentCommandIds: state.agentCommandIds.filter((id) => id !== toolCallId) } + }), + reset: () => set({ ...initialState }), + }), + { name: 'copilot-terminal-store' } + ) +) diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index a46bc93adc..dbf91a502a 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -29,6 +29,14 @@ export type ChatContext = | { kind: 'filefolder'; fileFolderId: string; label: string } | { kind: 'scheduledtask'; scheduleId: string; label: string } | { kind: 'docs'; label: string } + /** + * A tab in the desktop browser or terminal panel, dragged into the input to + * say "this one". A pointer rather than a snapshot: the agent reads the tab + * with its own tools, so what it sees is the live state at the moment it + * looks rather than whatever was on screen when the message was sent. + */ + | { kind: 'browser_tab'; tabId: string; label: string } + | { kind: 'terminal_tab'; terminalId: string; label: string } | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index 9d78900c49..bf63a663d3 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -24,6 +24,15 @@ function applySidebarWidth(width: number) { document.documentElement.style.setProperty('--sidebar-width', `${value}px`) } +/** Reads the host-specific collapsed width established by the pre-paint layout script. */ +function getCollapsedSidebarWidth(): number { + if (typeof document === 'undefined') return SIDEBAR_WIDTH.COLLAPSED + const value = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-collapsed-width') + ) + return Number.isFinite(value) ? value : SIDEBAR_WIDTH.COLLAPSED +} + /** * The `sidebar_collapsed` cookie is the single source of truth for collapse: the * server layout reads it to render the correct structure on the first paint @@ -61,12 +70,14 @@ export const useSidebarStore = create()( const nextCollapsed = !isCollapsed set({ isCollapsed: nextCollapsed }) applyCollapsedCookie(nextCollapsed) - applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth)) + applySidebarWidth( + nextCollapsed ? getCollapsedSidebarWidth() : clampSidebarWidth(sidebarWidth) + ) }, syncWidth: () => { const { isCollapsed, sidebarWidth } = get() if (isCollapsed) { - applySidebarWidth(SIDEBAR_WIDTH.COLLAPSED) + applySidebarWidth(getCollapsedSidebarWidth()) return } const clampedWidth = clampSidebarWidth(sidebarWidth) @@ -89,7 +100,7 @@ export const useSidebarStore = create()( if (state) { state.setHasHydrated(true) const width = state.isCollapsed - ? SIDEBAR_WIDTH.COLLAPSED + ? getCollapsedSidebarWidth() : clampSidebarWidth(state.sidebarWidth) applySidebarWidth(width) } diff --git a/apps/sim/stores/tool-permission/store.ts b/apps/sim/stores/tool-permission/store.ts new file mode 100644 index 0000000000..9ad81774af --- /dev/null +++ b/apps/sim/stores/tool-permission/store.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** + * Tracks which tool calls are currently sitting on a permission prompt. + * + * The cards themselves are rendered independently down the message tree, so + * they need somewhere shared to see each other: that is what makes an + * "Allow all" affordance possible when a turn gates several tools at once, and + * what stops every card from rendering its own copy of that affordance. + * + * Decisions are recorded here too, so a card stays settled during the round + * trip rather than flickering back to actionable before the stream catches up. + */ +interface ToolPermissionState { + /** Awaiting tool call ids, in the order their cards mounted. */ + awaitingIds: string[] + /** Ids with an answer already sent, cleared when the card unmounts. */ + submittedIds: string[] + register: (toolCallId: string) => void + unregister: (toolCallId: string) => void + markSubmitted: (toolCallIds: string[]) => void + clearSubmitted: (toolCallIds: string[]) => void + reset: () => void +} + +const initialState = { + awaitingIds: [] as string[], + submittedIds: [] as string[], +} + +export const useToolPermissionStore = create()( + devtools( + (set) => ({ + ...initialState, + register: (toolCallId) => + set((state) => + state.awaitingIds.includes(toolCallId) + ? {} + : { awaitingIds: [...state.awaitingIds, toolCallId] } + ), + unregister: (toolCallId) => + set((state) => ({ + awaitingIds: state.awaitingIds.filter((id) => id !== toolCallId), + submittedIds: state.submittedIds.filter((id) => id !== toolCallId), + })), + markSubmitted: (toolCallIds) => + set((state) => { + const next = toolCallIds.filter((id) => !state.submittedIds.includes(id)) + return next.length === 0 ? {} : { submittedIds: [...state.submittedIds, ...next] } + }), + clearSubmitted: (toolCallIds) => + set((state) => ({ + submittedIds: state.submittedIds.filter((id) => !toolCallIds.includes(id)), + })), + reset: () => set({ ...initialState }), + }), + { name: 'tool-permission-store' } + ) +) diff --git a/apps/sim/types/sim-desktop.d.ts b/apps/sim/types/sim-desktop.d.ts new file mode 100644 index 0000000000..902bc65d35 --- /dev/null +++ b/apps/sim/types/sim-desktop.d.ts @@ -0,0 +1,7 @@ +import type { SimDesktopApi } from '@sim/desktop-bridge' + +declare global { + interface Window { + simDesktop?: SimDesktopApi + } +} diff --git a/biome.json b/biome.json index 271e701c8b..9249402d96 100644 --- a/biome.json +++ b/biome.json @@ -27,10 +27,13 @@ "!**/public/worker-*.js", "!**/public/fallback-*.js", "!**/apps/docs/.source", + "!**/apps/desktop/release", "!**/venv", "!**/.venv", "!**/uploads", - "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs" + "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", + "!**/test-results", + "!**/playwright-report" ] }, "formatter": { diff --git a/bun.lock b/bun.lock index 79db5a4d44..3322e25481 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,36 @@ "@next/swc-linux-x64-gnu": "16.2.11", }, }, + "apps/desktop": { + "name": "@sim/desktop", + "version": "0.0.0", + "dependencies": { + "@lydell/node-pty": "1.2.0-beta.12", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", + "@sim/utils": "workspace:*", + "@xterm/headless": "6.0.0", + "electron-updater": "6.8.9", + "micromatch": "4.0.8", + "safe-regex2": "5.1.0", + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@sim/tsconfig": "workspace:*", + "@types/micromatch": "4.0.10", + "@types/node": "24.2.1", + "electron": "43.1.1", + "electron-builder": "26.15.3", + "esbuild": "0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.0", + }, + }, "apps/docs": { "name": "docs", "version": "0.0.0", @@ -180,12 +210,15 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", @@ -206,6 +239,11 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "ajv": "8.18.0", "archiver": "8.0.0", "better-auth": "1.6.23", @@ -361,6 +399,15 @@ "typescript": "^7.0.2", }, }, + "packages/browser-protocol": { + "name": "@sim/browser-protocol", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + }, + }, "packages/cli": { "name": "simstudio", "version": "0.1.19", @@ -394,6 +441,18 @@ "typescript": "^7.0.2", }, }, + "packages/desktop-bridge": { + "name": "@sim/desktop-bridge", + "version": "0.1.0", + "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/terminal-protocol": "workspace:*", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^7.0.2", + }, + }, "packages/emcn": { "name": "@sim/emcn", "version": "0.1.0", @@ -511,6 +570,9 @@ "packages/security": { "name": "@sim/security", "version": "0.1.0", + "dependencies": { + "ipaddr.js": "2.3.0", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", @@ -518,6 +580,15 @@ "vitest": "^4.1.0", }, }, + "packages/terminal-protocol": { + "name": "@sim/terminal-protocol", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + }, + }, "packages/testing": { "name": "@sim/testing", "version": "0.1.0", @@ -1004,6 +1075,24 @@ "@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], + + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + + "@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="], + + "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="], + + "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -1014,57 +1103,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -1186,6 +1275,24 @@ "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], + + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="], + + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="], + + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="], + + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="], + + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="], + + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="], + + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], @@ -1370,10 +1477,20 @@ "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@posthog/core": ["@posthog/core@1.24.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg=="], "@posthog/types": ["@posthog/types@1.364.4", "", {}, "sha512-U7NpIy9XWrzz1q/66xyDu8Wm12a7avNRKRn5ISPT5kuCJQRaeAaHuf+dpgrFnuqjCCgxg+oIY/ReJdlZ+8/z4Q=="], @@ -1618,8 +1735,14 @@ "@sim/auth": ["@sim/auth@workspace:packages/auth"], + "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/db": ["@sim/db@workspace:packages/db"], + "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], + + "@sim/desktop-bridge": ["@sim/desktop-bridge@workspace:packages/desktop-bridge"], + "@sim/emcn": ["@sim/emcn@workspace:packages/emcn"], "@sim/logger": ["@sim/logger@workspace:packages/logger"], @@ -1636,6 +1759,8 @@ "@sim/security": ["@sim/security@workspace:packages/security"], + "@sim/terminal-protocol": ["@sim/terminal-protocol@workspace:packages/terminal-protocol"], + "@sim/testing": ["@sim/testing@workspace:packages/testing"], "@sim/tsconfig": ["@sim/tsconfig@workspace:packages/tsconfig"], @@ -1648,6 +1773,8 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="], "@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], @@ -1742,6 +1869,8 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.4", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-zVOiYO0+CF7EnBScz8s0O5JnJLPTU0lrUi8qhKXfIxIJXvI/jcppSiXXsEJwfB4A6XZawY/Wg/EQGKANi/aPmQ=="], "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.4", "", { "dependencies": { "@t3-oss/env-core": "0.13.4" }, "peerDependencies": { "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-6ecXR7SH7zJKVcBODIkB7wV9QLMU23uV8D9ec6P+ULHJ5Ea/YXEHo+Z/2hSYip5i9ptD/qZh8VuOXyldspvTTg=="], @@ -1894,6 +2023,8 @@ "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1974,18 +2105,24 @@ "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/html-to-text": ["@types/html-to-text@9.0.4", "", {}, "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -2014,6 +2151,8 @@ "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], @@ -2132,8 +2271,22 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], + + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -2158,7 +2311,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -2166,6 +2319,8 @@ "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], + "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], @@ -2184,6 +2339,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2192,16 +2349,22 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], "axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="], @@ -2250,6 +2413,8 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -2274,12 +2439,22 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], + + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -2314,6 +2489,10 @@ "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -2330,6 +2509,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], @@ -2350,6 +2531,8 @@ "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], @@ -2392,6 +2575,8 @@ "cronstrue": ["cronstrue@3.3.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], @@ -2520,6 +2705,12 @@ "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], @@ -2536,6 +2727,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], @@ -2548,8 +2741,12 @@ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], "docs": ["docs@workspace:apps/docs"], @@ -2574,6 +2771,8 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], @@ -2582,6 +2781,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], "e2b": ["e2b@2.36.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@connectrpc/connect": "^2.1.2", "@connectrpc/connect-web": "^2.1.2", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.19", "undici": "^7.28.0" }, "optionalDependencies": { "undici8": "npm:undici@8.8.0" } }, "sha512-ERDTKfIM14mQX0cizRpfLCWDX4A83rGvbAbnUISDExM26WuMS4Jd9v2EWrY03LXfEKBst954SzFvKIZZEiKwng=="], @@ -2596,8 +2797,22 @@ "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + + "electron": ["electron@43.1.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-I5c5vfuVvaXpWx3IZdwvXgxQW44+e7OP1wXGVQkogLeSFSkUZ6sLCcWV05AdEcs65AO5tAIJJwbp7ixw+LdarA=="], + + "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="], + + "electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.373", "", {}, "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA=="], + "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], + + "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], @@ -2624,6 +2839,8 @@ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -2636,11 +2853,13 @@ "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -2692,6 +2911,8 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -2744,6 +2965,8 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -2772,6 +2995,10 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fumadocs-core": ["fumadocs-core@16.8.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-4MRqh/KWtR5Q5+LJd2SFv3nLDHtuZw3q8rwApd9nAWkunHVU30U17fUVq6nY+IDoLs7bSLnvDGvoE+Ynelrn3A=="], @@ -2816,6 +3043,10 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], @@ -2824,6 +3055,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphql": ["graphql@15.10.2", "", {}, "sha512-1PRqdDPAmViWr4h1GVBT8RoPZfWSGZa7kDzleTilOfVIslsgf+cia3Nl95v1KDmR4iERPaT7WzQ+tN4MJmbg3w=="], @@ -2838,6 +3071,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -2900,10 +3135,14 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2936,6 +3175,8 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -2968,7 +3209,7 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -2994,6 +3235,8 @@ "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isolated-vm": ["isolated-vm@6.0.2", "", { "dependencies": { "prebuild-install": "^7.1.3" } }, "sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg=="], @@ -3010,6 +3253,8 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -3028,6 +3273,8 @@ "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -3038,8 +3285,12 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], @@ -3052,6 +3303,8 @@ "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -3062,6 +3315,8 @@ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -3118,10 +3373,14 @@ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], @@ -3144,6 +3403,8 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], @@ -3170,6 +3431,8 @@ "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -3322,6 +3585,8 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -3378,6 +3643,8 @@ "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-ensure": ["node-ensure@0.0.0", "", {}, "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="], @@ -3386,16 +3653,24 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], "nodemailer": ["nodemailer@9.0.1", "", {}, "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="], "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], @@ -3414,6 +3689,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -3448,6 +3725,8 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], @@ -3496,6 +3775,8 @@ "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], @@ -3528,8 +3809,16 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -3554,6 +3843,8 @@ "posthog-node": ["posthog-node@5.28.9", "", { "dependencies": { "@posthog/core": "1.24.4" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-iZWyAYkIAq5QqcYz4q2nXOX+Ivn04Yh8AuKqfFVw0SvBpfli49bNAjyE97qbRTLr+irrzRUELgGIkDC14NgugA=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], @@ -3564,14 +3855,20 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], @@ -3616,6 +3913,10 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3626,6 +3927,8 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -3660,6 +3963,8 @@ "reactflow": ["reactflow@11.11.4", "", { "dependencies": { "@reactflow/background": "11.3.14", "@reactflow/controls": "11.2.14", "@reactflow/core": "11.11.4", "@reactflow/minimap": "11.7.14", "@reactflow/node-resizer": "2.2.14", "@reactflow/node-toolbar": "1.3.14" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -3728,14 +4033,22 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], @@ -3746,6 +4059,8 @@ "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], @@ -3772,12 +4087,16 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], + "satori": ["satori@0.12.2", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-wasm-web": "^0.3.3" } }, "sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA=="], "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], @@ -3798,10 +4117,14 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], @@ -3838,6 +4161,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "simstudio": ["simstudio@workspace:packages/cli"], "simstudio-ts-sdk": ["simstudio-ts-sdk@workspace:packages/ts-sdk"], @@ -3886,6 +4211,8 @@ "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -3906,7 +4233,7 @@ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -3952,6 +4279,8 @@ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -3986,6 +4315,10 @@ "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + + "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -3998,10 +4331,14 @@ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -4014,6 +4351,10 @@ "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + + "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -4028,6 +4369,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], @@ -4104,10 +4447,14 @@ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -4116,6 +4463,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], @@ -4154,6 +4503,8 @@ "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], @@ -4324,6 +4675,24 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@electron/fuses/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/rebuild/node-abi": ["node-abi@4.33.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA=="], + + "@electron/universal/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], @@ -4332,6 +4701,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4538,6 +4909,10 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/browser-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@sim/terminal-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4608,8 +4983,14 @@ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@types/cacheable-request/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + + "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4620,6 +5001,8 @@ "@types/request/form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="], + "@types/responselike/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4632,6 +5015,22 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -4646,6 +5045,8 @@ "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -4656,14 +5057,18 @@ "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -4690,6 +5095,12 @@ "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "e2b/@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], @@ -4698,6 +5109,18 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "engine.io/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4728,8 +5151,6 @@ "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], - "fumadocs-mdx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], @@ -4826,6 +5247,12 @@ "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], @@ -4842,6 +5269,12 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -4854,6 +5287,8 @@ "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "pptxgenjs/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], @@ -4868,8 +5303,6 @@ "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "react-email/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -4886,12 +5319,20 @@ "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4902,9 +5343,11 @@ "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4924,12 +5367,14 @@ "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tsconfck/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "twilio/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "twilio/xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], @@ -4938,11 +5383,15 @@ "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "unzipper/bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + + "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4950,8 +5399,6 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -5070,6 +5517,8 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -5172,8 +5621,14 @@ "@types/archiver/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5184,26 +5639,32 @@ "@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@types/responselike/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -5214,64 +5675,74 @@ "docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "fumadocs-mdx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "fumadocs-mdx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "fumadocs-mdx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "fumadocs-mdx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "fumadocs-mdx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "fumadocs-mdx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "fumadocs-mdx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "fumadocs-mdx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "fumadocs-mdx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "fumadocs-mdx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "fumadocs-mdx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "fumadocs-mdx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "fumadocs-mdx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "fumadocs-mdx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "fumadocs-mdx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -5294,6 +5765,8 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -5350,6 +5823,8 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -5370,58 +5845,6 @@ "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "react-email/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "react-email/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -5432,80 +5855,22 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "twilio/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], @@ -5548,6 +5913,12 @@ "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "giget/nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], @@ -5562,8 +5933,6 @@ "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], diff --git a/package.json b/package.json index 29b3d72db2..3367e07051 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "check:bare-icons": "bun run scripts/check-bare-icons.ts", "check:icon-paths": "bun run scripts/check-icon-paths.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", + "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", + "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", + "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "billing-protocol-contract:generate": "bun run scripts/sync-billing-protocol-contract.ts", diff --git a/packages/browser-protocol/package.json b/packages/browser-protocol/package.json new file mode 100644 index 0000000000..486787e417 --- /dev/null +++ b/packages/browser-protocol/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sim/browser-protocol", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": {}, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts new file mode 100644 index 0000000000..7367aa00ba --- /dev/null +++ b/packages/browser-protocol/src/index.ts @@ -0,0 +1,248 @@ +/** + * Shared types for the Sim browser agent — the agent browser built into the + * Sim desktop app. + * + * The Sim web app (renderer) invokes browser tools through the desktop + * preload bridge (`window.simDesktop.browserAgent`); the Electron main + * process executes them against a dedicated, persistent-profile browser view + * that is embedded INSIDE the main Sim window, positioned exactly over the + * chat's browser panel. The panel is therefore natively interactive — the + * user clicks and types into the real page, no frame streaming or synthetic + * input. Both sides consume this package so tool names, parameter shapes, + * and result shapes cannot drift. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/browser` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +export const BROWSER_TOOL_NAMES = [ + 'browser_navigate', + 'browser_open_url', + 'browser_go_back', + 'browser_go_forward', + 'browser_open_tab', + 'browser_switch_tab', + 'browser_close_tab', + 'browser_list_tabs', + 'browser_list_sessions', + 'browser_wait_for', + 'browser_snapshot', + 'browser_read_text', + 'browser_screenshot', + 'browser_extract', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_option', + 'browser_hover', + 'browser_request_takeover', +] as const + +export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] + +/** Hard cap shared by the desktop browser session and its renderer chrome. */ +export const MAX_BROWSER_TABS = 8 + +export const BROWSER_THEMES = ['system', 'light', 'dark'] as const + +/** Sim appearance preference mirrored into browser-tab media queries. */ +export type BrowserTheme = (typeof BROWSER_THEMES)[number] + +/** How a native browser shortcut should focus Sim's renderer-owned omnibox. */ +export type BrowserOmniboxFocusMode = 'select' | 'clear' + +const BROWSER_TOOL_NAME_SET: ReadonlySet = new Set(BROWSER_TOOL_NAMES) +const BROWSER_THEME_SET: ReadonlySet = new Set(BROWSER_THEMES) + +export function isBrowserToolName(name: string): name is BrowserToolName { + return BROWSER_TOOL_NAME_SET.has(name) +} + +export function isBrowserTheme(value: unknown): value is BrowserTheme { + return typeof value === 'string' && BROWSER_THEME_SET.has(value) +} + +/** The result of one browser tool invocation, as returned over the bridge. */ +export interface BrowserToolResponse { + ok: boolean + result?: unknown + error?: string +} + +/** + * Where the browser panel currently sits inside the Sim window, in CSS + * pixels relative to the page viewport. The main process positions the + * embedded browser view over this rect; null means the panel is not visible + * and the view should be hidden. + */ +export interface BrowserPanelBounds { + x: number + y: number + width: number + height: number +} + +/** + * How the panel's rect derives from the window's viewport, so the shell can + * re-evaluate it during a live window resize instead of holding a measured + * rect that is one frame stale. + * + * The renderer declares the rule; the shell only evaluates it. That direction + * matters: the shell once *assumed* a rule (right-anchored at constant width) + * and was wrong by half the window's travel whenever the panel was fractional. + * + * `widthRatio` is the only thing the shell cannot work out for itself, so it is + * the only rule carried here. Everything else — the width residual, the right + * inset, the top and bottom insets — the shell derives from the rect reported + * alongside this, measured at exactly the viewport below. + */ +export interface BrowserPanelAnchor { + /** Viewport size (CSS px) the companion rect was measured at. */ + viewportWidth: number + viewportHeight: number + /** + * How much the panel's width changes per pixel of viewport width: 0.5 while a + * half-width class governs it, 0 once a divider drag pins a fixed width. + * + * A rate, deliberately, not a share of the viewport — the panel is half of a + * parent box that excludes the sidebar, so its width is not 0.5 * viewport. + * The rate is what holds regardless, because that sidebar is a constant across + * a window resize, and the residual the shell derives absorbs the difference. + */ + widthRatio: number +} + +/** Last captured frame used while renderer overlays occlude the native view. */ +export interface BrowserPanelSnapshot { + dataUrl: string + tabId: string +} + +/** + * Browser-chrome commands from the panel header (URL bar, back/forward, + * reload) plus `takeover-done`, sent by the Done chip on the chat's + * `browser_request_takeover` tool row when the user finishes a + * hand-control-back request. Page interactions need no protocol — the user + * acts on the real embedded page directly, and its right-click menu is native + * and lives entirely in the shell. + */ +export interface BrowserPanelAction { + action: + | 'navigate' + | 'reload' + | 'back' + | 'forward' + | 'new-tab' + | 'duplicate-tab' + | 'switch-tab' + | 'close-tab' + | 'takeover-done' + /** Absolute URL for `navigate` (typed into the panel's URL bar). */ + url?: string + /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + tabId?: string +} + +/** Live state of the active page, pushed to the panel header. */ +export interface BrowserPageState { + /** Present on desktop versions with multi-tab UI support. */ + tabId?: string + url: string + title: string + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +/** + * One find-in-page request against the active tab. Backed by Chromium's own + * find, so behaviour matches Chrome exactly — this only carries the query and + * which way to step. + */ +export interface BrowserFindRequest { + query: string + /** + * False starts a fresh search and highlights every match; true steps to the + * next/previous match of the search already running. Typing re-searches; + * Enter steps. + */ + findNext: boolean + /** Direction for a `findNext` step. Ignored when starting a fresh search. */ + forward: boolean +} + +/** + * Match counts for the running find, pushed as Chromium resolves them. The + * counts are asynchronous and arrive in several updates per request, so the + * renderer must not expect one reply per {@link BrowserFindRequest}. + */ +export interface BrowserFindResult { + /** 1-based index of the highlighted match, or 0 before one is chosen. */ + activeMatchOrdinal: number + /** Total matches on the page; 0 means the query is not present. */ + matches: number + /** + * Whether the find has settled. Chromium streams provisional counts while a + * long page is still being scanned; only a final update is worth showing as + * a definitive "no results". + */ + final: boolean +} + +/** Summary of one live page in the desktop agent browser. */ +export interface BrowserTabState { + tabId: string + url: string + title: string + loading: boolean + active: boolean + /** Pinned tabs are ordered before regular tabs and cannot be closed. */ + pinned?: boolean +} + +/** Complete live tab list pushed by the desktop shell. */ +export interface BrowserTabsState { + tabs: BrowserTabState[] + activeTabId: string | null +} + +/** + * Why the desktop shell believes a website may have an authenticated session. + * Neither signal is proof: the live page must always be checked before acting. + */ +export type BrowserSessionEvidence = 'sign-in-completed' | 'cookies' + +/** + * Privacy-preserving summary of one possible authenticated website. Cookie + * names, values, paths, account identifiers, and page history never cross the + * desktop bridge. + */ +export interface BrowserKnownSession { + hostname: string + evidence: BrowserSessionEvidence + lastObservedAt: string +} + +export interface BrowserKnownSessionsState { + sessions: BrowserKnownSession[] +} + +export const BROWSER_DATA_KINDS = ['cookies', 'site-data', 'cache'] as const + +/** + * A kind of browsing data the user can clear independently. + * + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear and offering the option would be a lie. + * Saved passwords are absent too — they are a separate, explicit action. + */ +export type BrowserDataKind = (typeof BROWSER_DATA_KINDS)[number] + +const BROWSER_DATA_KIND_SET: ReadonlySet = new Set(BROWSER_DATA_KINDS) + +export function isBrowserDataKind(value: unknown): value is BrowserDataKind { + return typeof value === 'string' && BROWSER_DATA_KIND_SET.has(value) +} diff --git a/packages/browser-protocol/tsconfig.json b/packages/browser-protocol/tsconfig.json new file mode 100644 index 0000000000..1ffa3d2e84 --- /dev/null +++ b/packages/browser-protocol/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/db/migrations/0273_copilot_tool_permission_decision.sql b/packages/db/migrations/0273_copilot_tool_permission_decision.sql new file mode 100644 index 0000000000..5fdc3ebab0 --- /dev/null +++ b/packages/db/migrations/0273_copilot_tool_permission_decision.sql @@ -0,0 +1,4 @@ +CREATE TYPE "public"."copilot_tool_permission_decision" AS ENUM('allow', 'allow_chat', 'always_allow', 'skip');--> statement-breakpoint +ALTER TABLE "copilot_async_tool_calls" ADD COLUMN "permission_decision" "copilot_tool_permission_decision";--> statement-breakpoint +ALTER TABLE "copilot_async_tool_calls" ADD COLUMN "permission_decided_at" timestamp;--> statement-breakpoint +ALTER TABLE "copilot_chats" ADD COLUMN "auto_allowed_tools" jsonb DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0273_snapshot.json b/packages/db/migrations/meta/0273_snapshot.json new file mode 100644 index 0000000000..d52dff0743 --- /dev/null +++ b/packages/db/migrations/meta/0273_snapshot.json @@ -0,0 +1,18236 @@ +{ + "id": "8e11cbd9-0014-4685-b6eb-4aa80bc847d4", + "prevId": "b1418eb5-f9bb-4788-9414-f7269a514acd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 02a53a0df1..f36d0c740c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1905,6 +1905,13 @@ "when": 1785267616623, "tag": "0272_generic_folders_and_pinning", "breakpoints": true + }, + { + "idx": 273, + "version": "7", + "when": 1785281897201, + "tag": "0273_copilot_tool_permission_decision", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d5f083cd00..614157916f 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2582,9 +2582,18 @@ export const copilotChats = pgTable( model: text('model').notNull().default('claude-3-7-sonnet-latest'), conversationId: text('conversation_id'), previewYaml: text('preview_yaml'), + /** + * @deprecated Nothing reads or writes this any more — the plan artifact + * moved into the message transcript. Kept only so the column survives the + * deploy that removes its last readers; drop it in a follow-up migration + * once that deploy has fully rolled out (expand/contract). + */ planArtifact: text('plan_artifact'), config: jsonb('config'), resources: jsonb('resources').notNull().default('[]'), + // Copilot tool ids the user allowed for the rest of this chat only, as + // opposed to the account-wide list on `settings.copilotAutoAllowedTools`. + autoAllowedTools: jsonb('auto_allowed_tools').notNull().default('[]'), lastSeenAt: timestamp('last_seen_at'), pinned: boolean('pinned').notNull().default(false), deletedAt: timestamp('deleted_at'), @@ -2745,8 +2754,17 @@ export const copilotAsyncToolStatusEnum = pgEnum('copilot_async_tool_status', [ 'delivered', ]) +export const copilotToolPermissionDecisionEnum = pgEnum('copilot_tool_permission_decision', [ + 'allow', + 'allow_chat', + 'always_allow', + 'skip', +]) + export type CopilotRunStatus = (typeof copilotRunStatusEnum.enumValues)[number] export type CopilotAsyncToolStatus = (typeof copilotAsyncToolStatusEnum.enumValues)[number] +export type CopilotToolPermissionDecision = + (typeof copilotToolPermissionDecisionEnum.enumValues)[number] export const copilotRuns = pgTable( 'copilot_runs', @@ -2838,6 +2856,11 @@ export const copilotAsyncToolCalls = pgTable( status: copilotAsyncToolStatusEnum('status').notNull().default('pending'), result: jsonb('result'), error: text('error'), + // Set only for tools declaring requiresApproval in the mothership tool + // catalog. A null decision on such a tool means the prompt is still + // outstanding, which is what lets it survive a reload. + permissionDecision: copilotToolPermissionDecisionEnum('permission_decision'), + permissionDecidedAt: timestamp('permission_decided_at'), claimedAt: timestamp('claimed_at'), claimedBy: text('claimed_by'), completedAt: timestamp('completed_at'), diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts new file mode 100644 index 0000000000..80e1bd143b --- /dev/null +++ b/packages/desktop-bridge/contract-snapshot.ts @@ -0,0 +1,1365 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Frozen snapshot of the desktop preload bridge type surface + * (@sim/browser-protocol + @sim/terminal-protocol inlined into + * @sim/desktop-bridge) as of the last accepted contract change. + * CI type-checks that a shell built from this + * snapshot still satisfies the current SimDesktopApi, so bridge changes + * stay backward compatible with already-installed shells. + * + * Regenerate with: bun run desktop-bridge-contract:update + * Full rules: scripts/check-desktop-bridge-contract.ts + * + * min-desktop-version: 0.0.0 + */ +/** + * Shared types for the Sim browser agent — the agent browser built into the + * Sim desktop app. + * + * The Sim web app (renderer) invokes browser tools through the desktop + * preload bridge (`window.simDesktop.browserAgent`); the Electron main + * process executes them against a dedicated, persistent-profile browser view + * that is embedded INSIDE the main Sim window, positioned exactly over the + * chat's browser panel. The panel is therefore natively interactive — the + * user clicks and types into the real page, no frame streaming or synthetic + * input. Both sides consume this package so tool names, parameter shapes, + * and result shapes cannot drift. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/browser` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +export const BROWSER_TOOL_NAMES = [ + 'browser_navigate', + 'browser_open_url', + 'browser_go_back', + 'browser_go_forward', + 'browser_open_tab', + 'browser_switch_tab', + 'browser_close_tab', + 'browser_list_tabs', + 'browser_list_sessions', + 'browser_wait_for', + 'browser_snapshot', + 'browser_read_text', + 'browser_screenshot', + 'browser_extract', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_option', + 'browser_hover', + 'browser_request_takeover', +] as const + +export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] + +/** Hard cap shared by the desktop browser session and its renderer chrome. */ +export const MAX_BROWSER_TABS = 8 + +export const BROWSER_THEMES = ['system', 'light', 'dark'] as const + +/** Sim appearance preference mirrored into browser-tab media queries. */ +export type BrowserTheme = (typeof BROWSER_THEMES)[number] + +/** How a native browser shortcut should focus Sim's renderer-owned omnibox. */ +export type BrowserOmniboxFocusMode = 'select' | 'clear' + +const BROWSER_TOOL_NAME_SET: ReadonlySet = new Set(BROWSER_TOOL_NAMES) +const BROWSER_THEME_SET: ReadonlySet = new Set(BROWSER_THEMES) + +export function isBrowserToolName(name: string): name is BrowserToolName { + return BROWSER_TOOL_NAME_SET.has(name) +} + +export function isBrowserTheme(value: unknown): value is BrowserTheme { + return typeof value === 'string' && BROWSER_THEME_SET.has(value) +} + +/** The result of one browser tool invocation, as returned over the bridge. */ +export interface BrowserToolResponse { + ok: boolean + result?: unknown + error?: string +} + +/** + * Where the browser panel currently sits inside the Sim window, in CSS + * pixels relative to the page viewport. The main process positions the + * embedded browser view over this rect; null means the panel is not visible + * and the view should be hidden. + */ +export interface BrowserPanelBounds { + x: number + y: number + width: number + height: number +} + +/** + * How the panel's rect derives from the window's viewport, so the shell can + * re-evaluate it during a live window resize instead of holding a measured + * rect that is one frame stale. + * + * The renderer declares the rule; the shell only evaluates it. That direction + * matters: the shell once *assumed* a rule (right-anchored at constant width) + * and was wrong by half the window's travel whenever the panel was fractional. + * + * `widthRatio` is the only thing the shell cannot work out for itself, so it is + * the only rule carried here. Everything else — the width residual, the right + * inset, the top and bottom insets — the shell derives from the rect reported + * alongside this, measured at exactly the viewport below. + */ +export interface BrowserPanelAnchor { + /** Viewport size (CSS px) the companion rect was measured at. */ + viewportWidth: number + viewportHeight: number + /** + * How much the panel's width changes per pixel of viewport width: 0.5 while a + * half-width class governs it, 0 once a divider drag pins a fixed width. + * + * A rate, deliberately, not a share of the viewport — the panel is half of a + * parent box that excludes the sidebar, so its width is not 0.5 * viewport. + * The rate is what holds regardless, because that sidebar is a constant across + * a window resize, and the residual the shell derives absorbs the difference. + */ + widthRatio: number +} + +/** Last captured frame used while renderer overlays occlude the native view. */ +export interface BrowserPanelSnapshot { + dataUrl: string + tabId: string +} + +/** + * Browser-chrome commands from the panel header (URL bar, back/forward, + * reload) plus `takeover-done`, sent by the Done chip on the chat's + * `browser_request_takeover` tool row when the user finishes a + * hand-control-back request. Page interactions need no protocol — the user + * acts on the real embedded page directly, and its right-click menu is native + * and lives entirely in the shell. + */ +export interface BrowserPanelAction { + action: + | 'navigate' + | 'reload' + | 'back' + | 'forward' + | 'new-tab' + | 'duplicate-tab' + | 'switch-tab' + | 'close-tab' + | 'takeover-done' + /** Absolute URL for `navigate` (typed into the panel's URL bar). */ + url?: string + /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + tabId?: string +} + +/** Live state of the active page, pushed to the panel header. */ +export interface BrowserPageState { + /** Present on desktop versions with multi-tab UI support. */ + tabId?: string + url: string + title: string + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +/** + * One find-in-page request against the active tab. Backed by Chromium's own + * find, so behaviour matches Chrome exactly — this only carries the query and + * which way to step. + */ +export interface BrowserFindRequest { + query: string + /** + * False starts a fresh search and highlights every match; true steps to the + * next/previous match of the search already running. Typing re-searches; + * Enter steps. + */ + findNext: boolean + /** Direction for a `findNext` step. Ignored when starting a fresh search. */ + forward: boolean +} + +/** + * Match counts for the running find, pushed as Chromium resolves them. The + * counts are asynchronous and arrive in several updates per request, so the + * renderer must not expect one reply per {@link BrowserFindRequest}. + */ +export interface BrowserFindResult { + /** 1-based index of the highlighted match, or 0 before one is chosen. */ + activeMatchOrdinal: number + /** Total matches on the page; 0 means the query is not present. */ + matches: number + /** + * Whether the find has settled. Chromium streams provisional counts while a + * long page is still being scanned; only a final update is worth showing as + * a definitive "no results". + */ + final: boolean +} + +/** Summary of one live page in the desktop agent browser. */ +export interface BrowserTabState { + tabId: string + url: string + title: string + loading: boolean + active: boolean + /** Pinned tabs are ordered before regular tabs and cannot be closed. */ + pinned?: boolean +} + +/** Complete live tab list pushed by the desktop shell. */ +export interface BrowserTabsState { + tabs: BrowserTabState[] + activeTabId: string | null +} + +/** + * Why the desktop shell believes a website may have an authenticated session. + * Neither signal is proof: the live page must always be checked before acting. + */ +export type BrowserSessionEvidence = 'sign-in-completed' | 'cookies' + +/** + * Privacy-preserving summary of one possible authenticated website. Cookie + * names, values, paths, account identifiers, and page history never cross the + * desktop bridge. + */ +export interface BrowserKnownSession { + hostname: string + evidence: BrowserSessionEvidence + lastObservedAt: string +} + +export interface BrowserKnownSessionsState { + sessions: BrowserKnownSession[] +} + +export const BROWSER_DATA_KINDS = ['cookies', 'site-data', 'cache'] as const + +/** + * A kind of browsing data the user can clear independently. + * + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear and offering the option would be a lie. + * Saved passwords are absent too — they are a separate, explicit action. + */ +export type BrowserDataKind = (typeof BROWSER_DATA_KINDS)[number] + +const BROWSER_DATA_KIND_SET: ReadonlySet = new Set(BROWSER_DATA_KINDS) + +export function isBrowserDataKind(value: unknown): value is BrowserDataKind { + return typeof value === 'string' && BROWSER_DATA_KIND_SET.has(value) +} + +/** + * Shared types for the Sim agent terminal — the interactive shells built into + * the Sim desktop app. + * + * The Sim web app (renderer) drives real PTYs through the desktop preload + * bridge (`window.simDesktop.terminal`); the Electron main process owns the + * `node-pty` processes and streams their bytes back for xterm.js to render. + * The user and the agent share the same shells, so `cd`, exported variables, + * and scrollback are common to both. + * + * Several terminals can be open at once, each its own shell with its own + * working directory and scrollback, exactly like tabs in a terminal app. One is + * active at a time; agent tools act on the active one unless they name another. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/terminal` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +/** The single tool the model calls; what it does is in `operation`. */ +export const TERMINAL_TOOL_NAME = 'terminal' + +/** + * Names this surface used to expose, one tool per operation. Kept so rows in + * conversations recorded before the consolidation still render with a real + * title instead of a humanized tool name. + */ +export const LEGACY_TERMINAL_TOOL_NAMES = [ + 'terminal_run', + 'terminal_input', + 'terminal_read', + 'terminal_kill', + 'terminal_cwd', + 'terminal_list', + 'terminal_new', + 'terminal_switch', + 'terminal_close', +] as const + +export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] + +/** + * What one `terminal` call does. + * + * The first group acts on a shell — or, when that shell has tmux attached, on + * a pane inside it. The second group manages Sim's own tabs. `panes` is the + * one tmux-only operation: tmux owns its windows and splits, so the agent + * inspects them rather than Sim mirroring them into the tab strip. `handoff` + * gives the terminal to the user and waits. + */ +export const TERMINAL_OPERATIONS = [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', +] as const + +export type TerminalOperation = (typeof TERMINAL_OPERATIONS)[number] + +const TERMINAL_OPERATION_SET: ReadonlySet = new Set(TERMINAL_OPERATIONS) + +export function isTerminalOperation(value: unknown): value is TerminalOperation { + return typeof value === 'string' && TERMINAL_OPERATION_SET.has(value) +} + +export function isTerminalToolName(name: string): boolean { + return name === TERMINAL_TOOL_NAME +} + +/** + * Ceiling on concurrently open terminals. Each is a live shell process with its + * own emulator and scrollback, so the cap bounds both memory and the number of + * things the user has to keep track of. + */ +export const MAX_TERMINALS = 8 + +/** + * Largest command output handed back to the model, in characters. Output past + * this is middle-elided (head and tail kept) because the interesting parts of + * a long build log are the command echo and the failure at the end. + */ +export const MAX_TOOL_OUTPUT_CHARS = 30_000 + +/** Scrollback the main process retains per terminal for reads and repaints. */ +export const MAX_SCROLLBACK_CHARS = 256_000 + +/** + * Ceiling on the raw bytes buffered while capturing one command's output. A + * full-screen program repaints continuously and can emit megabytes a second, + * so capture keeps a capped head plus a rolling tail rather than growing until + * the command ends. + */ +export const MAX_CAPTURE_CHARS = 512_000 + +/** + * How long `terminal_run` waits for a command before handing control back. + * + * Deliberately short. A long blocking call would leave the user watching + * nothing and the agent unable to react, so anything still running comes back + * as `running` with the output so far; the agent then polls it with `wait` and + * `terminal_read`. Successive reads are also how it tells progress from a + * stall — output that stops changing is a command waiting on input or wedged. + */ +export const DEFAULT_RUN_WAIT_MS = 30_000 + +export const MAX_RUN_WAIT_MS = 120_000 + +/** + * How long output must be silent, with the cursor left mid-line, before the + * command is treated as sitting on a prompt and handed straight back. + * + * Waiting out the full window for something as obvious as `[y/n]` reads as a + * hang. A command that stops mid-line has written a prompt and is waiting for + * an answer; one that is merely working either keeps printing or has ended its + * last line properly, so neither trips this. + */ +export const PROMPT_IDLE_MS = 2_500 + +/** + * Ceiling on one batch of keystrokes. Long enough to cross a menu, short + * enough that a mistaken batch cannot run away with the program — every key + * after the first is sent without seeing what the last one did. + */ +export const MAX_INPUT_KEYS = 20 + +/** Control keys the agent may send to a running foreground process. */ +export const TERMINAL_CONTROL_KEYS = [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', +] as const + +export type TerminalControlKey = (typeof TERMINAL_CONTROL_KEYS)[number] + +const TERMINAL_CONTROL_KEY_SET: ReadonlySet = new Set(TERMINAL_CONTROL_KEYS) + +export function isTerminalControlKey(value: unknown): value is TerminalControlKey { + return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) +} + +export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' + +/** + * Arguments for every operation, flattened into one object. + * + * A flat bag rather than a discriminated union because it has to survive a + * round trip through a JSON tool schema, where the model supplies whichever + * fields its chosen operation needs. Each operation validates the ones it + * requires and ignores the rest. + */ +export interface TerminalToolArgs { + /** `run`: the command line to execute. */ + command?: string + /** `input`: literal text to type. */ + text?: string + /** `input`: a key to press instead of text. */ + key?: TerminalControlKey + /** + * `input`: several keys pressed in order, for stepping through a menu + * ("down", "down", "enter") without a round trip per keystroke. Each is a + * real keypress with a pause between, so the program redraws as it would + * under a person's hands. Capped at {@link MAX_INPUT_KEYS}. + */ + keys?: TerminalControlKey[] + /** `read`: trailing lines to return. */ + lines?: number + /** `kill`: which signal. Defaults to SIGINT. */ + signal?: TerminalSignal + /** `new`: directory to open in. Defaults to the active terminal's cwd. */ + cwd?: string + /** `run`: how long to wait before handing back a still-running command. */ + waitSeconds?: number + /** + * Which terminal to act on. Omitting it targets the active one, which is + * what the user is looking at and what a single-terminal conversation + * always means. + */ + terminalId?: string + /** + * Which tmux pane to act on, as a tmux target (`session:window.pane`), for + * a terminal that has tmux attached. Omitting it uses that session's active + * pane. Ignored when the terminal is a plain shell. + */ + pane?: string + /** `handoff`: what the user needs to do, shown on the chip they click. */ + reason?: string +} + +export interface TerminalToolCall { + operation: TerminalOperation + args?: TerminalToolArgs +} + +/** + * How a `terminal_run` ended. Only `completed` means the command is finished + * and the terminal is free; in every other case it is still running and still + * holds the foreground. + */ +export type TerminalRunStatus = + /** Exited on its own. `exitCode` is set. */ + | 'completed' + /** + * Still going when the wait window elapsed. Not an error and not a stall — + * poll it rather than re-running or giving up. + */ + | 'running' + /** + * Took over the screen (an editor, pager, or interactive CLI). Its output is + * redraws rather than text and it will not exit unaided. + */ + | 'interactive' + +export interface TerminalRunResult { + command: string + output: string + status: TerminalRunStatus + /** Null unless `status` is `completed`. */ + exitCode: number | null + durationMs: number + cwd: string | null + terminalId: string + /** Set when the command ran in tmux: the target it ran under. */ + pane?: string + /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ + truncated: boolean + /** + * Set when the command looks like it is blocked on a prompt: it printed + * something, stopped mid-line, and went quiet. Answer it with terminal_input + * rather than waiting — it will not proceed on its own. + */ + awaitingInput?: boolean +} + +export interface TerminalReadResult { + /** + * The screen as text. When a row is highlighted the way a menu marks its + * selection, it is prefixed `[selected] ` — a TUI that indicates the current + * row with colour alone is otherwise invisible in plain text, leaving the + * agent unable to tell where it is before it starts pressing keys. + */ + output: string + cwd: string | null + terminalId: string + /** Set when the read came from tmux: the pane it captured. */ + pane?: string + truncated: boolean + /** + * The command still holding the terminal, or null when the shell is back at + * a prompt. This is the definitive "is it done" signal for a poll loop — + * seeing expected text in the output is not, because a command can print its + * last line well before it exits. + */ + running: string | null +} + +export interface TerminalCwdResult { + cwd: string | null + shellName: string | null + home: string | null + terminalId: string +} + +/** One open terminal, as shown in the tab strip. */ +export interface TerminalTabState { + terminalId: string + /** Short label for the tab: the running command, else the cwd's basename. */ + title: string + cwd: string | null + /** Command holding the foreground, when one is running. */ + running: string | null + /** + * True while a full-screen program owns the terminal. Distinct from merely + * `running`: a build is transient work, an editor or coding agent is an open + * application that will sit there until it is quit. + */ + interactive: boolean + active: boolean + /** + * The tmux session attached in this terminal, when one is. Its windows and + * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a + * count of the shells Sim opened. + */ + tmuxSession?: string | null +} + +/** One tmux pane, as reported by the `panes` operation. */ +export interface TerminalPaneState { + /** tmux target (`session:window.pane`), usable as the `pane` argument. */ + target: string + windowName: string + /** The process tmux reports in the pane; a bare shell means it is idle. */ + command: string + cwd: string | null + active: boolean +} + +/** + * The outcome of handing a terminal to the user. + * + * Resolves when the command that was blocking finishes, so the agent picks up + * where it left off rather than having to guess whether the user is done. A + * command still going after the user says they have finished comes back with + * `running` set, which is the same poll-it signal a long `run` returns. + */ +export interface TerminalHandoffResult { + terminalId: string + reason: string + /** True when the user pressed the hand-back button rather than the command just ending. */ + handedBack: boolean + /** The command still holding the terminal, or null when it is back at a prompt. */ + running: string | null + /** The screen as it stands now. */ + output: string + cwd: string | null +} + +export interface TerminalPanesResult { + terminalId: string + session: string + panes: TerminalPaneState[] +} + +export interface TerminalTabsState { + tabs: TerminalTabState[] + activeTerminalId: string | null +} + +/** The result of one terminal tool invocation, as returned over the bridge. */ +export interface TerminalToolResponse { + ok: boolean + result?: unknown + error?: string + code?: TerminalErrorCode +} + +export type TerminalErrorCode = + | 'SESSION_CLOSED' + /** Another command already holds the foreground in that terminal. */ + | 'BUSY' + | 'TIMEOUT' + /** + * The shell never emitted integration markers, so command boundaries and + * exit codes are unknowable and `terminal_run` must refuse rather than guess. + */ + | 'NO_SHELL_INTEGRATION' + | 'SPAWN_FAILED' + /** No terminal with that id — the ids come from terminal_list. */ + | 'NO_SUCH_TERMINAL' + /** Already at {@link MAX_TERMINALS}. */ + | 'TOO_MANY_TERMINALS' + /** The operation needs tmux, and this terminal has no tmux attached. */ + | 'NO_TMUX' + /** No pane with that target — the targets come from the `panes` operation. */ + | 'NO_SUCH_PANE' + | 'INVALID_REQUEST' + +export interface TerminalStartOptions { + cols: number + rows: number +} + +/** One batch of PTY bytes, tagged with the terminal that produced it. */ +export interface TerminalOutputEvent { + terminalId: string + data: string +} + +/** + * Command lifecycle, used by the panel to attribute rows to the agent and to + * show a running indicator. Emitted for user-typed commands too (no + * `toolCallId`), so the agent's `terminal_read` and the user's view agree. + */ +export interface TerminalCommandEvent { + terminalId: string + phase: 'start' | 'end' + command: string + /** Set when the agent initiated this command rather than the user. */ + toolCallId?: string + exitCode?: number + durationMs?: number +} + +/** + * The agent-terminal surface of the preload bridge. Real PTYs run in the + * Electron main process; the renderer paints their bytes with xterm.js and + * forwards keystrokes back. Several terminals can be open at once, each its own + * shell, and the user and the agent share them — so working directory and + * environment stay consistent between the two. + */ +/** + * The agent-terminal surface of the preload bridge. + * + * Members added after this surface first shipped are `optional?`, matching the + * browser-agent surface beside it and the "feature-detect, never assume" rule + * in apps/desktop/README.md: one web app is served to shells of every age, and + * `MIN_DESKTOP_VERSION` is still `0.0.0` (no floor), so an older shell reaches + * this code. Declaring such a member required makes the type disagree with the + * renderer, which has to `?.` it anyway — and the contract audit cannot catch + * that, because it compares against a snapshot the same PR regenerates. + */ +export interface SimDesktopTerminalApi { + /** Open the first terminal, or adopt the ones already running. */ + start(options: TerminalStartOptions): Promise + /** + * Execute one terminal operation. Resolves with the outcome; never rejects + * for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise + /** Forward the user's keystrokes to one terminal's PTY. */ + write(terminalId: string, data: string): void + resize(terminalId: string, cols: number, rows: number): void + /** Open an additional terminal and make it active. */ + openTerminal(cwd?: string): Promise + switchTerminal(terminalId: string): Promise + closeTerminal(terminalId: string): Promise + getTabs?(): Promise + /** End every shell. A new one starts on the next `start`. */ + dispose(): void + /** Subscribe to PTY output batches. Returns an unsubscribe function. */ + onData?(callback: (terminalId: string, data: string) => void): () => void + /** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Pulled per view so the repaint cannot be aimed at the wrong set of + * subscribers, or at none at all. + */ + getScrollback(terminalId: string): Promise + /** + * Reports whether the terminal panel owns keyboard focus, so global menu + * accelerators can tell a Cmd-W meant for a terminal from one meant for the + * window. + */ + setFocused?(focused: boolean): void + /** + * The user finishing a handoff — the hand-back chip on the waiting tool row. + */ + finishHandoff?(terminalId: string): void + /** Subscribe to the open-terminal list and which one is active. */ + onTabs?(callback: (state: TerminalTabsState) => void): () => void + /** Subscribe to command start/end, used for agent attribution in the panel. */ + onCommand?(callback: (event: TerminalCommandEvent) => void): () => void +} + +/** + * The browser-agent surface of the preload bridge. Tools execute in the + * Electron main process against the desktop app's built-in agent browser — a + * persistent-profile browser view embedded in the main Sim window, positioned + * over the chat's browser panel so the user interacts with the real page. + */ +export interface SimDesktopBrowserAgentApi { + /** + * Execute one browser tool. Resolves with the tool's outcome; never + * rejects for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise + /** Browser-chrome commands from the panel (URL bar, back, reload, takeover Done). */ + panelAction(action: BrowserPanelAction): void + /** + * Pin or unpin a live browser tab. Optional for compatibility with desktop + * builds predating durable pinned tabs. + */ + setTabPinned?(tabId: string, pinned: boolean): void + /** + * Move a live tab to a final list index. Optional for compatibility with + * desktop builds predating tab reordering. + */ + reorderTab?(tabId: string, targetIndex: number): void + /** + * Report where the browser panel sits in the window (CSS pixels relative + * to the viewport), or null when the panel is hidden/unmounted. The main + * process keeps the embedded view glued to this rect. + * + * `anchor` declares how that rect derives from the viewport so the shell can + * re-evaluate it mid-resize rather than hold a stale rect; omit it and the + * shell falls back to the measured rect alone. Shells predating it ignore the + * argument. + */ + setPanelBounds(bounds: BrowserPanelBounds | null, anchor?: BrowserPanelAnchor | null): void + /** + * Report whether renderer-owned browser chrome currently owns the user's + * interaction context. Optional for compatibility with older desktop builds. + */ + setPanelFocused?(focused: boolean): void + /** + * Hide or reveal the native browser surface without detaching it. Optional + * so newer web deployments remain compatible with older desktop builds. + */ + setPanelOccluded?(occluded: boolean): void + /** + * Mirror Sim's light/dark/system preference into the embedded pages. + * Optional for compatibility with desktop builds predating theme sync. + */ + setTheme?(theme: BrowserTheme): void + /** + * Focus requests emitted by native tabs for browser-level keyboard + * shortcuts such as Mod+L and Mod+T. + */ + onFocusOmnibox?(callback: (mode: BrowserOmniboxFocusMode) => void): () => void + /** + * Run Chromium's find-in-page against the active tab. Results do not come + * back from this call — they stream through {@link onFindResult}. Optional + * for compatibility with desktop builds predating find-in-page. + */ + find?(request: BrowserFindRequest): void + /** + * Stop the running find and clear its highlights. `focusPage` hands keyboard + * focus back to the page, for the user dismissing the bar; omit it when the + * bar is going away because the panel is. + */ + stopFind?(focusPage?: boolean): void + /** + * Mod+F pressed while the embedded page had focus, which the renderer never + * sees as a key event. Opening the find bar is the renderer's job either + * way, so both entry paths land on the same handler. + */ + onOpenFind?(callback: () => void): () => void + /** + * The shell dismissing the find bar — the active tab navigated away from the + * document the find was run against, or the user switched tabs. + */ + onCloseFind?(callback: () => void): () => void + /** Match counts for the running find, as Chromium resolves them. */ + onFindResult?(callback: (result: BrowserFindResult) => void): () => void + /** + * Subscribe to captured browser frames used beneath renderer overlays. + * Optional for compatibility with desktop builds predating occlusion. + */ + onPanelSnapshot?(callback: (snapshot: BrowserPanelSnapshot) => void): () => void + /** Subscribe to live page state for the panel header. Returns an unsubscribe function. */ + onPageState(callback: (state: BrowserPageState) => void): () => void + /** + * Read the current live tab list. Optional so a newer web deployment remains + * compatible with installed desktop versions that only support one visible tab. + */ + getTabsState?(): Promise + /** + * Read a privacy-preserving hint of websites that may have a usable session + * in the dedicated profile. Optional for compatibility with older shells. + */ + getKnownSessions?(): Promise + /** + * Erase browsing data from the dedicated profile and resolve the resulting + * session list. Pass the kinds to clear; omit for all of them. Saved + * passwords are never included — deleting those is a separate action. + * Optional for compatibility with older shells, which ignore the argument + * and clear everything. + */ + clearBrowsingData?(kinds?: readonly BrowserDataKind[]): Promise + /** + * Subscribe to live tab-list changes. Optional for compatibility with older + * installed desktop versions. + */ + onTabsState?(callback: (state: BrowserTabsState) => void): () => void + /** + * Subscribe to session liveness changes (false when the browser session + * ends). Returns an unsubscribe function. + */ + onSessionStatus(callback: (alive: boolean) => void): () => void +} + +/** + * One browser profile found on this device. + * + * `id` is an opaque handle used to name the same profile back to the shell; + * the shell resolves it against the profiles it discovered rather than + * building a path from it. Host paths never cross this bridge. + * + * `browserId` and `browserLabel` are optional because shells that only + * supported Chrome did not report them — treat their absence as Chrome. + */ +export interface BrowserImportProfile { + id: string + /** Browser and profile together, e.g. `Arc · Microtrades`. */ + label: string + /** Stable browser identifier, e.g. `chrome`, `arc`, `brave`. */ + browserId?: string + /** The browser's product name, e.g. `Arc`. */ + browserLabel?: string + /** The profile on its own, e.g. `Microtrades` or `Default`. */ + profileLabel?: string +} + +/** + * Why an import could not run, as a coarse category. Deliberately free of + * specifics: no host paths, profile paths, domains, or underlying OS errors. + */ +export type BrowserImportError = + | 'unsupported-platform' + | 'chrome-not-found' + | 'keychain-unavailable' + | 'profile-unreadable' + | 'unsupported-schema' + | 'nothing-imported' + | 'vault-unavailable' + | 'unknown' + +/** + * Outcome of a Chrome import: counts and a coarse error category only. Cookie + * names, values, domains, and full URLs never cross the bridge — they never + * leave the Electron main process at all. + */ +export interface BrowserImportResult { + cookiesImported: number + cookiesSkipped: number + /** Present only when the import could not complete. */ + error?: BrowserImportError +} + +/** + * Local, user-initiated import of Chrome data into the built-in browser's + * dedicated profile. macOS-only today, and optional in two senses: older + * shells lack the surface entirely, and shells on platforms without a + * supported importer omit it too — so always feature-detect before rendering. + * + * The agent cannot reach this. Both methods are gated in the main process to + * the Sim app origin, `importChromeCookies` additionally requires a live user + * gesture, and no browser tool maps to either channel. Reading Chrome is + * strictly read-only, and decrypted material stays in the main process. + */ +/** A site a previous import brought over, keyed by the hostname visited there. */ +export interface BrowserSiteInfo { + hostname: string + /** Learned from the source browser's own page titles, not a built-in list. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from — an aggregate for + * ordering suggestions, never a visit time, a URL, or a sequence. Absent on + * a record written before imports started counting. + */ + visits?: number + /** When Sim imported it; never the source browser's own last-visit time. */ + importedAt?: string +} + +export interface SimDesktopBrowserImportApi { + /** Chrome profiles detected on this device; empty when none are readable. */ + listChromeProfiles(): Promise + /** + * The sites previous imports brought over, so the omnibox has somewhere to + * start on a browser that keeps no history of its own — and can offer + * "Gmail" instead of `mail.google.com`. + * + * Optional — feature-detect before calling, so a newer web deployment keeps + * working against an installed shell that predates it. + */ + listSites?(): Promise + /** + * Copy one Chrome profile's cookies into the built-in browser, preserving + * each cookie's security attributes. Requires an active user gesture in the + * calling page. Resolves a count-only report; never rejects for import-level + * failures (those ride the `error` category). + */ + importChromeCookies(profileId?: string): Promise + /** + * Copy cookies and saved passwords in one action. + * + * A single call rather than two, because the macOS Keychain prompt can + * outlive the page's transient user activation and a second gated call would + * then be refused for a user who did nothing wrong. Each half reports its + * own outcome, so one failing does not hide the other. + * + * Optional: shells that predate saved passwords expose only + * {@link importChromeCookies}, so feature-detect before offering it. + */ + importFromChrome?( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise +} + +/** Both halves of a combined Chrome import, each with its own outcome. */ +export interface BrowserChromeImportResult { + cookies: BrowserImportResult + passwords: BrowserPasswordImportResult +} + +/** How an import should treat a credential that already exists for a site. */ +export type BrowserCredentialConflictPolicy = 'keep-existing' | 'replace' + +/** + * Outcome of a password import. Counts and a coarse category only, exactly + * like the cookie import — no origins, usernames, or passwords. + */ +export interface BrowserPasswordImportResult { + passwordsAdded: number + passwordsUpdated: number + passwordsSkipped: number + error?: BrowserImportError +} + +/** + * One saved credential as the management UI sees it. The password is + * deliberately absent, and there is no bridge method that can produce it: + * plaintext only ever travels from the vault to an authorized fill, inside the + * main process. + */ +export interface BrowserCredentialMetadata { + id: string + origin: string + username: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' + /** + * The site's icon as a `data:` URL, copied from the source browser's own + * favicon store during import. Absent when that browser had no icon for the + * site. Never fetched over the network — doing so would disclose the list of + * sites the user has passwords for. + */ + icon?: string +} + +/** + * Whether the active browser tab is showing a login form that Sim holds a + * credential for — just enough to decide whether to offer the fill affordance. + * + * Intentionally a bare boolean. The renderer learns nothing about which + * accounts exist, and the chooser itself is a native main-process surface, so + * no credential identifier crosses this bridge on the fill path at all. + */ +export interface BrowserFillAvailability { + available: boolean +} + +/** + * The saved-password surface for the built-in browser: an OS-encrypted local + * vault plus a user-driven fill. + * + * Optional so newer web deployments keep working against shells that lack it, + * and absent where secure storage is unavailable — there is no plaintext + * fallback. The agent has no path to any of it: management calls require the + * Sim app origin, filling additionally requires a real user gesture and is + * completed by a native menu the renderer cannot drive, and no browser tool + * maps to these channels. + */ +export interface SimDesktopBrowserCredentialsApi { + /** False when OS-backed encryption is unavailable and passwords are disabled. */ + isAvailable(): Promise + /** Saved credentials, without passwords. */ + list(): Promise + /** Forget one credential; resolves the remaining list. */ + forget(id: string): Promise + /** + * Delete every saved password. Requires an active user gesture; resolves the + * resulting (empty) list. Optional — feature-detect before offering it. + */ + forgetAll?(): Promise + /** + * Reveal one saved password so the user can read it. + * + * This is the only method on the entire bridge that can produce password + * plaintext, and it is heavily conditioned: it requires an active user + * gesture, the shell prompts for Touch ID (or a native confirmation where + * Touch ID is unavailable) on every call, and it returns exactly one + * password. Resolves null when the user declines or the credential is gone. + * + * Optional — shells that predate the password manager omit it. + */ + reveal?(id: string): Promise + /** + * Copy one saved password to the clipboard. Same authorization as + * {@link reveal}, but the password never enters the renderer: the shell + * writes the clipboard itself and clears it again shortly after. + */ + copy?(id: string): Promise + /** Copy saved passwords out of a Chrome profile into the vault. */ + importFromChrome( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise + /** + * Ask the shell to show its native credential chooser near a point in the + * window. Requires a user gesture. The shell performs the fill itself when + * the user picks an account — no password or credential id comes back here. + */ + showChooser(anchor: { x: number; y: number }): Promise + /** Subscribe to whether the active tab can be filled. */ + onFillAvailability(callback: (state: BrowserFillAvailability) => void): () => void +} + +export interface LocalFilesystemMount { + id: string + name: string + uri: string + /** True when the encrypted grant will be restored after restarting the desktop app. */ + remembered: boolean +} + +export type LocalFilesystemEntryKind = 'file' | 'directory' | 'symlink' | 'other' + +export interface LocalFilesystemEntry { + name: string + uri: string + kind: LocalFilesystemEntryKind + size?: number + modifiedAt?: string +} + +export interface LocalFilesystemStat { + name: string + uri: string + kind: LocalFilesystemEntryKind + size: number + modifiedAt: string +} + +export interface LocalFilesystemReadResult { + uri: string + content: string + startLine: number + endLine: number + totalLines: number +} + +export interface LocalFilesystemGrepMatch { + uri: string + line: number + text: string +} + +export type LocalFilesystemRequest = + | { operation: 'mount_directory' } + | { operation: 'list_mounts' } + | { operation: 'forget_mount'; uri: string } + | { operation: 'reveal_mount'; uri: string } + | { operation: 'list'; uri: string; requestId?: string } + | { + operation: 'glob' + uri: string + pattern: string + pathPrefix?: string + requestId?: string + } + | { + operation: 'read' + uri: string + startLine?: number + lineCount?: number + requestId?: string + } + | { + operation: 'grep' + uri: string + query?: string + pattern?: string + include?: string + caseSensitive?: boolean + maxResults?: number + outputMode?: 'content' | 'files_with_matches' | 'count' + lineNumbers?: boolean + context?: number + requestId?: string + } + | { operation: 'stat'; uri: string; requestId?: string } + | { operation: 'cancel'; requestId: string } + +export type LocalFilesystemData = + | { mount: LocalFilesystemMount | null; cancelled: boolean } + | { mounts: LocalFilesystemMount[] } + | { forgotten: boolean } + | { revealed: boolean } + | { entries: LocalFilesystemEntry[]; truncated: boolean } + | { matches: LocalFilesystemGrepMatch[]; truncated: boolean } + | { files: string[]; truncated: boolean } + | { counts: Array<{ uri: string; count: number }>; truncated: boolean } + | { cancelled: boolean } + | LocalFilesystemReadResult + | LocalFilesystemStat + +export type LocalFilesystemResponse = + | { ok: true; data: LocalFilesystemData } + | { + ok: false + code: + | 'INVALID_REQUEST' + | 'INVALID_URI' + | 'MOUNT_NOT_FOUND' + | 'NOT_FOUND' + | 'NOT_A_FILE' + | 'NOT_A_DIRECTORY' + | 'FILE_TOO_LARGE' + | 'BINARY_FILE' + | 'ACCESS_DENIED' + | 'CANCELLED' + | 'IO_ERROR' + error: string + } + +/** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ +export interface DesktopOAuthConnectResult { + ok: boolean + /** OAuth error slug forwarded from the provider callback, when the flow failed. */ + error?: string +} + +/** + * Optional scope for an OAuth connect handoff. Chip-initiated connects carry + * the workspace (the browser flow creates the workspace connect draft + * server-side) and, for reconnects, the credential to rebind. Modal-initiated + * connects omit both — the app already created the draft. + */ +export interface DesktopOAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface DesktopPreferences { + notificationsEnabled: boolean + notificationSounds: boolean + notificationsOnlyWhenUnfocused: boolean + launchAtLogin: boolean + autoDownloadUpdates: boolean + /** + * Show the Sim status item (recent chats menu) in the macOS menu bar. + * Optional because shells predating the preference don't report it. + */ + trayEnabled?: boolean + /** + * Let Chat drive the built-in agent browser on this device. Optional + * because shells predating the preference don't report it; absent means the + * surface is simply always on, which is how those shells behave. + */ + browserEnabled?: boolean + /** Let Chat run commands in local shells. Same compatibility caveat. */ + terminalEnabled?: boolean +} + +/** + * The keys settable through {@link SimDesktopSettingsApi.setPreference}. A + * closed union frozen at the first shell release: widening it would demand a + * capability installed shells lack (their setPreference is typed over fewer + * keys), which the bridge contract audit rejects. Preferences added later get + * their own optional setter (e.g. {@link SimDesktopSettingsApi.setTrayEnabled}) + * so the web app can feature-detect them — and must be excluded here, or they + * widen this union right back. + */ +export type DesktopPreferenceKey = Exclude< + keyof DesktopPreferences, + 'trayEnabled' | 'browserEnabled' | 'terminalEnabled' +> + +export interface DesktopNotificationPayload { + title: string + body: string + /** Optional in-app route opened when the notification is clicked. */ + route?: string +} + +/** + * Device-level settings owned by the desktop shell. This surface is optional + * so a newer web deployment remains compatible with older installed shells. + */ +export interface SimDesktopSettingsApi { + getPreferences(): Promise + setPreference( + key: K, + value: DesktopPreferences[K] + ): Promise + notify(payload: DesktopNotificationPayload): Promise + /** + * Shows or hides the Sim menu-bar status item. Optional: only shells that + * support the tray preference expose it — feature-detect before rendering + * a toggle. + */ + setTrayEnabled?(enabled: boolean): Promise + /** + * Turns the agent browser on or off for this device; disabling it also ends + * the running session. Optional — feature-detect before rendering a toggle. + */ + setBrowserEnabled?(enabled: boolean): Promise + /** + * Turns the agent terminal on or off for this device; disabling it also + * ends every open shell. Optional, like {@link setBrowserEnabled}. + */ + setTerminalEnabled?(enabled: boolean): Promise +} + +/** + * Where the shell's update pipeline currently is. `available` only occurs + * when automatic downloads are disabled; with them enabled the shell moves + * straight to `downloading`. + */ +export type DesktopUpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' + +export interface DesktopUpdateState { + status: DesktopUpdateStatus + /** Version of the update being offered/downloaded/ready, when known. */ + version?: string + /** Whole-number download progress (0-100) while `downloading`. */ + percent?: number + /** + * True when this shell cannot apply updates in place (a build without a + * Developer ID signature — local installs and pre-signing CI prereleases; + * Squirrel.Mac refuses to swap unsigned bundles). `available` is then the + * pipeline's terminal state and the advance action opens the download in + * the browser instead of downloading in the background. + */ + manual?: boolean +} + +/** + * The shell updater surface. Optional so a newer web deployment remains + * compatible with older installed shells. + */ +export interface SimDesktopUpdatesApi { + getState(): Promise + /** + * Advance the pipeline: checks for an update, or starts the download when + * one is already known to be available (auto-download off). + */ + check(): void + /** Quit and install a `ready` update. No-op in any other state. */ + install(): void + /** Subscribe to pipeline state changes. Returns an unsubscribe function. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +export type DesktopCommand = 'toggle-sidebar' + +export interface DesktopWindowState { + isFullScreen: boolean +} + +export interface SimDesktopWindowStateApi { + getState(): Promise + onStateChange(callback: (state: DesktopWindowState) => void): () => void +} + +export interface SimDesktopApi { + /** + * Installed shell version (plain semver, e.g. `0.3.1`). Optional because + * shells predating version reporting don't set it — the web app's minimum + * shell version gate treats an absent version as older than any floor. + */ + version?: string + openExternal(url: string): Promise + /** + * Start the OAuth connect handoff for a provider: the whole flow runs in + * the system browser and returns via loopback. Resolves false when the + * browser could not be opened. + */ + beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** + * Subscribe to connect-handoff completions (the app is refocused just + * before this fires). Returns an unsubscribe function. + */ + onOAuthConnectComplete(callback: (result: DesktopOAuthConnectResult) => void): () => void + offlineRetry(): void + localFilesystem(request: LocalFilesystemRequest): Promise + /** Subscribe to commands initiated by the native application menu. */ + onCommand?(callback: (command: DesktopCommand) => void): () => void + windowState?: SimDesktopWindowStateApi + settings?: SimDesktopSettingsApi + updates?: SimDesktopUpdatesApi + browserAgent?: SimDesktopBrowserAgentApi + /** + * Local Chrome import for the built-in browser. Absent on shells predating + * it and on platforms without a supported importer. + */ + browserImport?: SimDesktopBrowserImportApi + /** + * Saved passwords and user-driven fill for the built-in browser. Absent on + * older shells and wherever OS-backed encryption is unavailable. + */ + browserCredentials?: SimDesktopBrowserCredentialsApi + /** + * Optional so a newer web deployment stays compatible with installed shells + * that predate the agent terminal. + */ + terminal?: SimDesktopTerminalApi +} diff --git a/packages/desktop-bridge/package.json b/packages/desktop-bridge/package.json new file mode 100644 index 0000000000..cbff5f8d59 --- /dev/null +++ b/packages/desktop-bridge/package.json @@ -0,0 +1,37 @@ +{ + "name": "@sim/desktop-bridge", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./local-filesystem-limits": { + "types": "./src/local-filesystem-limits.ts", + "default": "./src/local-filesystem-limits.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/terminal-protocol": "workspace:*" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^7.0.2" + } +} diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts new file mode 100644 index 0000000000..dd2e3cecaa --- /dev/null +++ b/packages/desktop-bridge/src/index.ts @@ -0,0 +1,726 @@ +import type { + BrowserDataKind, + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSessionsState, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserPanelSnapshot, + BrowserTabsState, + BrowserTheme, + BrowserToolName, + BrowserToolResponse, +} from '@sim/browser-protocol' +import type { + TerminalCommandEvent, + TerminalOperation, + TerminalStartOptions, + TerminalTabsState, + TerminalToolArgs, + TerminalToolResponse, +} from '@sim/terminal-protocol' + +/** + * The agent-terminal surface of the preload bridge. Real PTYs run in the + * Electron main process; the renderer paints their bytes with xterm.js and + * forwards keystrokes back. Several terminals can be open at once, each its own + * shell, and the user and the agent share them — so working directory and + * environment stay consistent between the two. + * + * Members added after this surface first shipped are `optional?`, matching the + * browser-agent surface beside it and the "feature-detect, never assume" rule + * in apps/desktop/README.md: one web app is served to shells of every age, and + * `MIN_DESKTOP_VERSION` is still `0.0.0` (no floor), so an older shell reaches + * this code. Declaring such a member required makes the type disagree with the + * renderer, which has to `?.` it anyway — and the contract audit cannot catch + * that, because it compares against a snapshot the same PR regenerates. + */ +export interface SimDesktopTerminalApi { + /** Open the first terminal, or adopt the ones already running. */ + start(options: TerminalStartOptions): Promise + /** + * Execute one terminal operation. Resolves with the outcome; never rejects + * for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise + /** Forward the user's keystrokes to one terminal's PTY. */ + write(terminalId: string, data: string): void + resize(terminalId: string, cols: number, rows: number): void + /** Open an additional terminal and make it active. */ + openTerminal(cwd?: string): Promise + switchTerminal(terminalId: string): Promise + closeTerminal(terminalId: string): Promise + getTabs?(): Promise + /** End every shell. A new one starts on the next `start`. */ + dispose(): void + /** Subscribe to PTY output batches. Returns an unsubscribe function. */ + onData?(callback: (terminalId: string, data: string) => void): () => void + /** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Pulled per view so the repaint cannot be aimed at the wrong set of + * subscribers, or at none at all. + */ + getScrollback(terminalId: string): Promise + /** + * Reports whether the terminal panel owns keyboard focus, so global menu + * accelerators can tell a Cmd-W meant for a terminal from one meant for the + * window. + */ + setFocused?(focused: boolean): void + /** + * The user finishing a handoff — the hand-back chip on the waiting tool row. + */ + finishHandoff?(terminalId: string): void + /** Subscribe to the open-terminal list and which one is active. */ + onTabs?(callback: (state: TerminalTabsState) => void): () => void + /** Subscribe to command start/end, used for agent attribution in the panel. */ + onCommand?(callback: (event: TerminalCommandEvent) => void): () => void +} + +/** + * The browser-agent surface of the preload bridge. Tools execute in the + * Electron main process against the desktop app's built-in agent browser — a + * persistent-profile browser view embedded in the main Sim window, positioned + * over the chat's browser panel so the user interacts with the real page. + */ +export interface SimDesktopBrowserAgentApi { + /** + * Execute one browser tool. Resolves with the tool's outcome; never + * rejects for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise + /** Browser-chrome commands from the panel (URL bar, back, reload, takeover Done). */ + panelAction(action: BrowserPanelAction): void + /** + * Pin or unpin a live browser tab. Optional for compatibility with desktop + * builds predating durable pinned tabs. + */ + setTabPinned?(tabId: string, pinned: boolean): void + /** + * Move a live tab to a final list index. Optional for compatibility with + * desktop builds predating tab reordering. + */ + reorderTab?(tabId: string, targetIndex: number): void + /** + * Report where the browser panel sits in the window (CSS pixels relative + * to the viewport), or null when the panel is hidden/unmounted. The main + * process keeps the embedded view glued to this rect. + * + * `anchor` declares how that rect derives from the viewport so the shell can + * re-evaluate it mid-resize rather than hold a stale rect; omit it and the + * shell falls back to the measured rect alone. Shells predating it ignore the + * argument. + */ + setPanelBounds(bounds: BrowserPanelBounds | null, anchor?: BrowserPanelAnchor | null): void + /** + * Report whether renderer-owned browser chrome currently owns the user's + * interaction context. Optional for compatibility with older desktop builds. + */ + setPanelFocused?(focused: boolean): void + /** + * Hide or reveal the native browser surface without detaching it. Optional + * so newer web deployments remain compatible with older desktop builds. + */ + setPanelOccluded?(occluded: boolean): void + /** + * Mirror Sim's light/dark/system preference into the embedded pages. + * Optional for compatibility with desktop builds predating theme sync. + */ + setTheme?(theme: BrowserTheme): void + /** + * Focus requests emitted by native tabs for browser-level keyboard + * shortcuts such as Mod+L and Mod+T. + */ + onFocusOmnibox?(callback: (mode: BrowserOmniboxFocusMode) => void): () => void + /** + * Run Chromium's find-in-page against the active tab. Results do not come + * back from this call — they stream through {@link onFindResult}. Optional + * for compatibility with desktop builds predating find-in-page. + */ + find?(request: BrowserFindRequest): void + /** + * Stop the running find and clear its highlights. `focusPage` hands keyboard + * focus back to the page, for the user dismissing the bar; omit it when the + * bar is going away because the panel is. + */ + stopFind?(focusPage?: boolean): void + /** + * Mod+F pressed while the embedded page had focus, which the renderer never + * sees as a key event. Opening the find bar is the renderer's job either + * way, so both entry paths land on the same handler. + */ + onOpenFind?(callback: () => void): () => void + /** + * The shell dismissing the find bar — the active tab navigated away from the + * document the find was run against, or the user switched tabs. + */ + onCloseFind?(callback: () => void): () => void + /** Match counts for the running find, as Chromium resolves them. */ + onFindResult?(callback: (result: BrowserFindResult) => void): () => void + /** + * Subscribe to captured browser frames used beneath renderer overlays. + * Optional for compatibility with desktop builds predating occlusion. + */ + onPanelSnapshot?(callback: (snapshot: BrowserPanelSnapshot) => void): () => void + /** Subscribe to live page state for the panel header. Returns an unsubscribe function. */ + onPageState(callback: (state: BrowserPageState) => void): () => void + /** + * Read the current live tab list. Optional so a newer web deployment remains + * compatible with installed desktop versions that only support one visible tab. + */ + getTabsState?(): Promise + /** + * Read a privacy-preserving hint of websites that may have a usable session + * in the dedicated profile. Optional for compatibility with older shells. + */ + getKnownSessions?(): Promise + /** + * Erase browsing data from the dedicated profile and resolve the resulting + * session list. Pass the kinds to clear; omit for all of them. Saved + * passwords are never included — deleting those is a separate action. + * Optional for compatibility with older shells, which ignore the argument + * and clear everything. + */ + clearBrowsingData?(kinds?: readonly BrowserDataKind[]): Promise + /** + * Subscribe to live tab-list changes. Optional for compatibility with older + * installed desktop versions. + */ + onTabsState?(callback: (state: BrowserTabsState) => void): () => void + /** + * Subscribe to session liveness changes (false when the browser session + * ends). Returns an unsubscribe function. + */ + onSessionStatus(callback: (alive: boolean) => void): () => void +} + +/** + * One browser profile found on this device. + * + * `id` is an opaque handle used to name the same profile back to the shell; + * the shell resolves it against the profiles it discovered rather than + * building a path from it. Host paths never cross this bridge. + * + * `browserId` and `browserLabel` are optional because shells that only + * supported Chrome did not report them — treat their absence as Chrome. + */ +export interface BrowserImportProfile { + id: string + /** Browser and profile together, e.g. `Arc · Microtrades`. */ + label: string + /** Stable browser identifier, e.g. `chrome`, `arc`, `brave`. */ + browserId?: string + /** The browser's product name, e.g. `Arc`. */ + browserLabel?: string + /** The profile on its own, e.g. `Microtrades` or `Default`. */ + profileLabel?: string +} + +/** + * Why an import could not run, as a coarse category. Deliberately free of + * specifics: no host paths, profile paths, domains, or underlying OS errors. + */ +export type BrowserImportError = + | 'unsupported-platform' + | 'chrome-not-found' + | 'keychain-unavailable' + | 'profile-unreadable' + | 'unsupported-schema' + | 'nothing-imported' + | 'vault-unavailable' + | 'unknown' + +/** + * Outcome of a Chrome import: counts and a coarse error category only. Cookie + * names, values, domains, and full URLs never cross the bridge — they never + * leave the Electron main process at all. + */ +export interface BrowserImportResult { + cookiesImported: number + cookiesSkipped: number + /** Present only when the import could not complete. */ + error?: BrowserImportError +} + +/** + * Local, user-initiated import of Chrome data into the built-in browser's + * dedicated profile. macOS-only today, and optional in two senses: older + * shells lack the surface entirely, and shells on platforms without a + * supported importer omit it too — so always feature-detect before rendering. + * + * The agent cannot reach this. Both methods are gated in the main process to + * the Sim app origin, `importChromeCookies` additionally requires a live user + * gesture, and no browser tool maps to either channel. Reading Chrome is + * strictly read-only, and decrypted material stays in the main process. + */ +/** A site a previous import brought over, keyed by the hostname visited there. */ +export interface BrowserSiteInfo { + hostname: string + /** Learned from the source browser's own page titles, not a built-in list. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from — an aggregate for + * ordering suggestions, never a visit time, a URL, or a sequence. Absent on + * a record written before imports started counting. + */ + visits?: number + /** When Sim imported it; never the source browser's own last-visit time. */ + importedAt?: string +} + +export interface SimDesktopBrowserImportApi { + /** Chrome profiles detected on this device; empty when none are readable. */ + listChromeProfiles(): Promise + /** + * The sites previous imports brought over, so the omnibox has somewhere to + * start on a browser that keeps no history of its own — and can offer + * "Gmail" instead of `mail.google.com`. + * + * Optional — feature-detect before calling, so a newer web deployment keeps + * working against an installed shell that predates it. + */ + listSites?(): Promise + /** + * Copy one Chrome profile's cookies into the built-in browser, preserving + * each cookie's security attributes. Requires an active user gesture in the + * calling page. Resolves a count-only report; never rejects for import-level + * failures (those ride the `error` category). + */ + importChromeCookies(profileId?: string): Promise + /** + * Copy cookies and saved passwords in one action. + * + * A single call rather than two, because the macOS Keychain prompt can + * outlive the page's transient user activation and a second gated call would + * then be refused for a user who did nothing wrong. Each half reports its + * own outcome, so one failing does not hide the other. + * + * Optional: shells that predate saved passwords expose only + * {@link importChromeCookies}, so feature-detect before offering it. + */ + importFromChrome?( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise +} + +/** Both halves of a combined Chrome import, each with its own outcome. */ +export interface BrowserChromeImportResult { + cookies: BrowserImportResult + passwords: BrowserPasswordImportResult +} + +/** How an import should treat a credential that already exists for a site. */ +export type BrowserCredentialConflictPolicy = 'keep-existing' | 'replace' + +/** + * Outcome of a password import. Counts and a coarse category only, exactly + * like the cookie import — no origins, usernames, or passwords. + */ +export interface BrowserPasswordImportResult { + passwordsAdded: number + passwordsUpdated: number + passwordsSkipped: number + error?: BrowserImportError +} + +/** + * One saved credential as the management UI sees it. The password is + * deliberately absent, and there is no bridge method that can produce it: + * plaintext only ever travels from the vault to an authorized fill, inside the + * main process. + */ +export interface BrowserCredentialMetadata { + id: string + origin: string + username: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' + /** + * The site's icon as a `data:` URL, copied from the source browser's own + * favicon store during import. Absent when that browser had no icon for the + * site. Never fetched over the network — doing so would disclose the list of + * sites the user has passwords for. + */ + icon?: string +} + +/** + * Whether the active browser tab is showing a login form that Sim holds a + * credential for — just enough to decide whether to offer the fill affordance. + * + * Intentionally a bare boolean. The renderer learns nothing about which + * accounts exist, and the chooser itself is a native main-process surface, so + * no credential identifier crosses this bridge on the fill path at all. + */ +export interface BrowserFillAvailability { + available: boolean +} + +/** + * The saved-password surface for the built-in browser: an OS-encrypted local + * vault plus a user-driven fill. + * + * Optional so newer web deployments keep working against shells that lack it, + * and absent where secure storage is unavailable — there is no plaintext + * fallback. The agent has no path to any of it: management calls require the + * Sim app origin, filling additionally requires a real user gesture and is + * completed by a native menu the renderer cannot drive, and no browser tool + * maps to these channels. + */ +export interface SimDesktopBrowserCredentialsApi { + /** False when OS-backed encryption is unavailable and passwords are disabled. */ + isAvailable(): Promise + /** Saved credentials, without passwords. */ + list(): Promise + /** Forget one credential; resolves the remaining list. */ + forget(id: string): Promise + /** + * Delete every saved password. Requires an active user gesture; resolves the + * resulting (empty) list. Optional — feature-detect before offering it. + */ + forgetAll?(): Promise + /** + * Reveal one saved password so the user can read it. + * + * This is the only method on the entire bridge that can produce password + * plaintext, and it is heavily conditioned: it requires an active user + * gesture, the shell prompts for Touch ID (or a native confirmation where + * Touch ID is unavailable) on every call, and it returns exactly one + * password. Resolves null when the user declines or the credential is gone. + * + * Optional — shells that predate the password manager omit it. + */ + reveal?(id: string): Promise + /** + * Copy one saved password to the clipboard. Same authorization as + * {@link reveal}, but the password never enters the renderer: the shell + * writes the clipboard itself and clears it again shortly after. + */ + copy?(id: string): Promise + /** Copy saved passwords out of a Chrome profile into the vault. */ + importFromChrome( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise + /** + * Ask the shell to show its native credential chooser near a point in the + * window. Requires a user gesture. The shell performs the fill itself when + * the user picks an account — no password or credential id comes back here. + */ + showChooser(anchor: { x: number; y: number }): Promise + /** Subscribe to whether the active tab can be filled. */ + onFillAvailability(callback: (state: BrowserFillAvailability) => void): () => void +} + +export interface LocalFilesystemMount { + id: string + name: string + uri: string + /** True when the encrypted grant will be restored after restarting the desktop app. */ + remembered: boolean +} + +export type LocalFilesystemEntryKind = 'file' | 'directory' | 'symlink' | 'other' + +export interface LocalFilesystemEntry { + name: string + uri: string + kind: LocalFilesystemEntryKind + size?: number + modifiedAt?: string +} + +export interface LocalFilesystemStat { + name: string + uri: string + kind: LocalFilesystemEntryKind + size: number + modifiedAt: string +} + +export interface LocalFilesystemReadResult { + uri: string + content: string + startLine: number + endLine: number + totalLines: number +} + +export interface LocalFilesystemGrepMatch { + uri: string + line: number + text: string +} + +export type LocalFilesystemRequest = + | { operation: 'mount_directory' } + | { operation: 'list_mounts' } + | { operation: 'forget_mount'; uri: string } + | { operation: 'reveal_mount'; uri: string } + | { operation: 'list'; uri: string; requestId?: string } + | { + operation: 'glob' + uri: string + pattern: string + pathPrefix?: string + requestId?: string + } + | { + operation: 'read' + uri: string + startLine?: number + lineCount?: number + requestId?: string + } + | { + operation: 'grep' + uri: string + query?: string + pattern?: string + include?: string + caseSensitive?: boolean + maxResults?: number + outputMode?: 'content' | 'files_with_matches' | 'count' + lineNumbers?: boolean + context?: number + requestId?: string + } + | { operation: 'stat'; uri: string; requestId?: string } + | { operation: 'cancel'; requestId: string } + +export type LocalFilesystemData = + | { mount: LocalFilesystemMount | null; cancelled: boolean } + | { mounts: LocalFilesystemMount[] } + | { forgotten: boolean } + | { revealed: boolean } + | { entries: LocalFilesystemEntry[]; truncated: boolean } + | { matches: LocalFilesystemGrepMatch[]; truncated: boolean } + | { files: string[]; truncated: boolean } + | { counts: Array<{ uri: string; count: number }>; truncated: boolean } + | { cancelled: boolean } + | LocalFilesystemReadResult + | LocalFilesystemStat + +export type LocalFilesystemResponse = + | { ok: true; data: LocalFilesystemData } + | { + ok: false + code: + | 'INVALID_REQUEST' + | 'INVALID_URI' + | 'MOUNT_NOT_FOUND' + | 'NOT_FOUND' + | 'NOT_A_FILE' + | 'NOT_A_DIRECTORY' + | 'FILE_TOO_LARGE' + | 'BINARY_FILE' + | 'ACCESS_DENIED' + | 'CANCELLED' + | 'IO_ERROR' + error: string + } + +/** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ +export interface DesktopOAuthConnectResult { + ok: boolean + /** OAuth error slug forwarded from the provider callback, when the flow failed. */ + error?: string +} + +/** + * Optional scope for an OAuth connect handoff. Chip-initiated connects carry + * the workspace (the browser flow creates the workspace connect draft + * server-side) and, for reconnects, the credential to rebind. Modal-initiated + * connects omit both — the app already created the draft. + */ +export interface DesktopOAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface DesktopPreferences { + notificationsEnabled: boolean + notificationSounds: boolean + notificationsOnlyWhenUnfocused: boolean + launchAtLogin: boolean + autoDownloadUpdates: boolean + /** + * Show the Sim status item (recent chats menu) in the macOS menu bar. + * Optional because shells predating the preference don't report it. + */ + trayEnabled?: boolean + /** + * Let Chat drive the built-in agent browser on this device. Optional + * because shells predating the preference don't report it; absent means the + * surface is simply always on, which is how those shells behave. + */ + browserEnabled?: boolean + /** Let Chat run commands in local shells. Same compatibility caveat. */ + terminalEnabled?: boolean +} + +/** + * The keys settable through {@link SimDesktopSettingsApi.setPreference}. A + * closed union frozen at the first shell release: widening it would demand a + * capability installed shells lack (their setPreference is typed over fewer + * keys), which the bridge contract audit rejects. Preferences added later get + * their own optional setter (e.g. {@link SimDesktopSettingsApi.setTrayEnabled}) + * so the web app can feature-detect them — and must be excluded here, or they + * widen this union right back. + */ +export type DesktopPreferenceKey = Exclude< + keyof DesktopPreferences, + 'trayEnabled' | 'browserEnabled' | 'terminalEnabled' +> + +export interface DesktopNotificationPayload { + title: string + body: string + /** Optional in-app route opened when the notification is clicked. */ + route?: string +} + +/** + * Device-level settings owned by the desktop shell. This surface is optional + * so a newer web deployment remains compatible with older installed shells. + */ +export interface SimDesktopSettingsApi { + getPreferences(): Promise + setPreference( + key: K, + value: DesktopPreferences[K] + ): Promise + notify(payload: DesktopNotificationPayload): Promise + /** + * Shows or hides the Sim menu-bar status item. Optional: only shells that + * support the tray preference expose it — feature-detect before rendering + * a toggle. + */ + setTrayEnabled?(enabled: boolean): Promise + /** + * Turns the agent browser on or off for this device; disabling it also ends + * the running session. Optional — feature-detect before rendering a toggle. + */ + setBrowserEnabled?(enabled: boolean): Promise + /** + * Turns the agent terminal on or off for this device; disabling it also + * ends every open shell. Optional, like {@link setBrowserEnabled}. + */ + setTerminalEnabled?(enabled: boolean): Promise +} + +/** + * Where the shell's update pipeline currently is. `available` only occurs + * when automatic downloads are disabled; with them enabled the shell moves + * straight to `downloading`. + */ +export type DesktopUpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' + +export interface DesktopUpdateState { + status: DesktopUpdateStatus + /** Version of the update being offered/downloaded/ready, when known. */ + version?: string + /** Whole-number download progress (0-100) while `downloading`. */ + percent?: number + /** + * True when this shell cannot apply updates in place (a build without a + * Developer ID signature — local installs and pre-signing CI prereleases; + * Squirrel.Mac refuses to swap unsigned bundles). `available` is then the + * pipeline's terminal state and the advance action opens the download in + * the browser instead of downloading in the background. + */ + manual?: boolean +} + +/** + * The shell updater surface. Optional so a newer web deployment remains + * compatible with older installed shells. + */ +export interface SimDesktopUpdatesApi { + getState(): Promise + /** + * Advance the pipeline: checks for an update, or starts the download when + * one is already known to be available (auto-download off). + */ + check(): void + /** Quit and install a `ready` update. No-op in any other state. */ + install(): void + /** Subscribe to pipeline state changes. Returns an unsubscribe function. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +export type DesktopCommand = 'toggle-sidebar' + +export interface DesktopWindowState { + isFullScreen: boolean +} + +export interface SimDesktopWindowStateApi { + getState(): Promise + onStateChange(callback: (state: DesktopWindowState) => void): () => void +} + +export interface SimDesktopApi { + /** + * Installed shell version (plain semver, e.g. `0.3.1`). Optional because + * shells predating version reporting don't set it — the web app's minimum + * shell version gate treats an absent version as older than any floor. + */ + version?: string + openExternal(url: string): Promise + /** + * Start the OAuth connect handoff for a provider: the whole flow runs in + * the system browser and returns via loopback. Resolves false when the + * browser could not be opened. + */ + beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** + * Subscribe to connect-handoff completions (the app is refocused just + * before this fires). Returns an unsubscribe function. + */ + onOAuthConnectComplete(callback: (result: DesktopOAuthConnectResult) => void): () => void + offlineRetry(): void + localFilesystem(request: LocalFilesystemRequest): Promise + /** Subscribe to commands initiated by the native application menu. */ + onCommand?(callback: (command: DesktopCommand) => void): () => void + windowState?: SimDesktopWindowStateApi + settings?: SimDesktopSettingsApi + updates?: SimDesktopUpdatesApi + browserAgent?: SimDesktopBrowserAgentApi + /** + * Local Chrome import for the built-in browser. Absent on shells predating + * it and on platforms without a supported importer. + */ + browserImport?: SimDesktopBrowserImportApi + /** + * Saved passwords and user-driven fill for the built-in browser. Absent on + * older shells and wherever OS-backed encryption is unavailable. + */ + browserCredentials?: SimDesktopBrowserCredentialsApi + /** + * Optional so a newer web deployment stays compatible with installed shells + * that predate the agent terminal. + */ + terminal?: SimDesktopTerminalApi +} diff --git a/packages/desktop-bridge/src/local-filesystem-limits.ts b/packages/desktop-bridge/src/local-filesystem-limits.ts new file mode 100644 index 0000000000..a6d7eefd52 --- /dev/null +++ b/packages/desktop-bridge/src/local-filesystem-limits.ts @@ -0,0 +1,33 @@ +/** + * Bounds and defaults for local-filesystem tool calls. + * + * These are a wire agreement, not a preference. The main process authorizes a + * request by RESOLVING the model's tool args itself and comparing the result + * to the request for exact equality — so if the renderer and the shell + * disagree about what an omitted `maxResults` means, a legitimate call is + * silently DENIED rather than failing loudly. Three copies of this table + * existed and two had already drifted on the read limit. + * + * Only the values the authorizer compares live here. Caps that each side + * applies to its own output (glob result limits, scan budgets) are genuinely + * independent and stay local — hoisting them would assert a coupling that does + * not exist. + * + * Changing a value here is a shell-compatibility change. Each side compiles + * its own copy, so a continuously-deployed web app carrying a new default + * still faces installed shells authorizing against the old one. Treat an edit + * the way you would a bridge signature change: additive, or floored by + * `MIN_DESKTOP_VERSION`. + */ + +/** Ceiling on an explicit `read` limit, and the value used when it is absent. */ +export const MAX_READ_LINES = 2_000 +export const DEFAULT_READ_LINES = MAX_READ_LINES + +/** Ceiling on an explicit `grep` maxResults, and the value used when absent. */ +export const MAX_GREP_RESULTS = 200 +export const DEFAULT_GREP_RESULTS = 50 + +/** Ceiling on explicit `grep` context lines, and the value used when absent. */ +export const MAX_GREP_CONTEXT = 20 +export const DEFAULT_GREP_CONTEXT = 0 diff --git a/packages/desktop-bridge/tsconfig.json b/packages/desktop-bridge/tsconfig.json new file mode 100644 index 0000000000..f24122d580 --- /dev/null +++ b/packages/desktop-bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@sim/tsconfig/library.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx index 080da46cc7..42d83aa545 100644 --- a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx +++ b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx @@ -137,6 +137,7 @@ const ChipDatePicker = forwardRef( align={align} sideOffset={6} collisionPadding={8} + data-native-surface-overlay='' className={cn( POPOVER_ANIMATION_CLASSES, 'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm' diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index eaac04246f..87cf859516 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -110,6 +110,7 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[280px] rounded-lg', className)} {...props} + data-native-surface-overlay='' /> )) @@ -143,6 +144,7 @@ const DropdownMenuContent = React.forwardRef< sideOffset={sideOffset} className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[220px] rounded-xl', className)} {...props} + data-native-surface-overlay='' /> )) diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index b9b3d37165..a0ad1930f3 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -167,6 +167,13 @@ export { SecretReveal } from './secret-reveal/secret-reveal' export { Skeleton } from './skeleton/skeleton' export { Slider } from './slider/slider' export { Switch } from './switch/switch' +export { + isTabTitleTruncated, + TabStrip, + type TabStripItem, + type TabStripProps, + tabDropIndex, +} from './tab-strip/tab-strip' export { Table, TableBody, diff --git a/packages/emcn/src/components/modal/modal.tsx b/packages/emcn/src/components/modal/modal.tsx index ae78693064..4229c3779b 100644 --- a/packages/emcn/src/components/modal/modal.tsx +++ b/packages/emcn/src/components/modal/modal.tsx @@ -137,6 +137,7 @@ const ModalOverlay = React.forwardRef< )} style={style} {...props} + data-native-surface-overlay='' /> ) }) diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx index 6efb2c52cb..9d332d8ebc 100644 --- a/packages/emcn/src/components/popover/popover.tsx +++ b/packages/emcn/src/components/popover/popover.tsx @@ -605,6 +605,7 @@ const PopoverContent = React.forwardRef< onOpenAutoFocus={handleOpenAutoFocus} onCloseAutoFocus={handleCloseAutoFocus} {...restProps} + data-native-surface-overlay='' className={cn( 'z-[var(--z-popover)] flex flex-col outline-none', showArrow ? 'overflow-visible' : 'overflow-auto', @@ -1015,6 +1016,7 @@ const PopoverFolder = React.forwardRef( typeof document !== 'undefined' && createPortal( { + it('shows title help only after a meaningful amount of text is clipped', () => { + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 140 })).toBe(true) + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 131 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 199 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 200 })).toBe(true) + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 100 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 120, scrollWidth: 80 })).toBe(false) + }) +}) + +describe('tabDropIndex', () => { + const tabs: TabStripItem[] = [ + { id: 'pinned-1', title: 'pinned-1', pinned: true }, + { id: 'pinned-2', title: 'pinned-2', pinned: true }, + { id: 'regular-1', title: 'regular-1' }, + { id: 'regular-2', title: 'regular-2' }, + ] + + it('calculates final indices from insertion gaps', () => { + expect(tabDropIndex(tabs, 'pinned-1', 2)).toBe(1) + expect(tabDropIndex(tabs, 'regular-1', 4)).toBe(3) + expect(tabDropIndex(tabs, 'regular-1', 3)).toBeNull() + }) + + it('keeps pinned and regular tabs inside their respective groups', () => { + expect(tabDropIndex(tabs, 'pinned-1', 4)).toBe(1) + expect(tabDropIndex(tabs, 'regular-2', 0)).toBe(2) + expect(tabDropIndex(tabs, 'missing', 0)).toBeNull() + }) + + it('treats a strip with no pinned tabs as one group', () => { + const plain: TabStripItem[] = [ + { id: 'a', title: 'a' }, + { id: 'b', title: 'b' }, + { id: 'c', title: 'c' }, + ] + expect(tabDropIndex(plain, 'a', 3)).toBe(2) + expect(tabDropIndex(plain, 'c', 0)).toBe(0) + expect(tabDropIndex(plain, 'b', 1)).toBeNull() + }) +}) + +describe('tab tooltips', () => { + /** Mirrors the render condition: tooltip text, and whether it is shown. */ + function tooltipFor(tab: TabStripItem, titleTruncated: boolean) { + const shown = Boolean(tab.tooltip || tab.pinned || titleTruncated) + return shown ? (tab.tooltip ?? tab.title) : null + } + + it('prefers the fuller detail a tab supplies over its label', () => { + // A terminal labelled with a basename can say where it actually is. + const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim — bun test' } + + expect(tooltipFor(tab, false)).toBe('/Users/me/code/sim — bun test') + }) + + it('shows that detail even when the label fits', () => { + // It says something the tab cannot, so there is always a reason to hover. + const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim' } + + expect(tooltipFor(tab, false)).not.toBeNull() + }) + + it('falls back to the title, and only when the title is clipped', () => { + const tab: TabStripItem = { id: '1', title: 'a very long tab title' } + + expect(tooltipFor(tab, true)).toBe('a very long tab title') + expect(tooltipFor(tab, false)).toBeNull() + }) + + it('still explains a pinned tab, which renders with no label at all', () => { + const tab: TabStripItem = { id: '1', title: 'GitHub', pinned: true } + + expect(tooltipFor(tab, false)).toBe('GitHub') + }) +}) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx new file mode 100644 index 0000000000..d2ff02ea8f --- /dev/null +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -0,0 +1,378 @@ +'use client' + +import { + type DragEvent as ReactDragEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { Plus, X } from '../../icons' +import { cn } from '../../lib/cn' +import { Button } from '../button/button' +import { Tooltip } from '../tooltip/tooltip' + +/** One tab in a {@link TabStrip}. */ +export interface TabStripItem { + id: string + title: string + /** + * Leading glyph. The caller owns what this is — a favicon, a spinner, a + * status icon — because only it knows what the tab represents. + */ + icon?: ReactNode + active?: boolean + /** + * Pinned tabs render icon-only and cannot be closed. Ordering them first is + * the caller's job, since only it knows the underlying list. + */ + pinned?: boolean + /** + * Fuller detail for the hover tooltip — a path the label abbreviates, the + * command a tab is running. Shown whenever present, not only when the label + * is clipped: it says something the tab cannot, so there is always a reason + * to hover. Without it the tooltip falls back to the title, and only appears + * when the title is actually cut off. + */ + tooltip?: string +} + +export interface TabStripProps { + tabs: TabStripItem[] + onSelect: (id: string) => void + /** Omit to make tabs uncloseable. Never offered for a pinned tab. */ + onClose?: (id: string) => void + /** Omit to hide the new-tab button. */ + onNew?: () => void + /** Enables drag reordering. Receives the tab's final index. */ + onReorder?: (id: string, targetIndex: number) => void + onTabContextMenu?: (event: ReactMouseEvent, id: string) => void + /** + * Called as a tab starts being dragged, to add whatever that tab means + * outside the strip to the drag. Supplying it also makes tabs draggable in a + * strip that cannot be reordered. + */ + onTabDragStart?: (event: ReactDragEvent, id: string) => void + /** Disables the new-tab button, with a tooltip explaining why. */ + maxTabs?: number + newTabLabel?: string + /** Rendered after the new-tab button, for menus and overlays. */ + children?: ReactNode +} + +/** + * Whether a title is clipped enough to be worth a tooltip. A couple of hidden + * pixels is not, and a tooltip on every tab is noise. + */ +export function isTabTitleTruncated( + element: Pick +): boolean { + const hiddenWidth = element.scrollWidth - element.clientWidth + const tooltipThreshold = Math.max(32, element.clientWidth * 0.25) + return hiddenWidth >= tooltipThreshold +} + +/** + * Resolves a drop gap to a final index, or null when the move is a no-op. + * + * Pinned tabs occupy a leading partition: a pinned tab cannot be dragged past + * the boundary and an unpinned one cannot be dragged before it, so dropping + * across it clamps rather than reorders. + */ +export function tabDropIndex( + tabs: TabStripItem[], + draggedId: string, + gapIndex: number +): number | null { + const fromIndex = tabs.findIndex((tab) => tab.id === draggedId) + if (fromIndex < 0 || !Number.isFinite(gapIndex)) return null + + const pinnedCount = tabs.filter((tab) => tab.pinned).length + const dragged = tabs[fromIndex] + const minGapIndex = dragged.pinned ? 0 : pinnedCount + const maxGapIndex = dragged.pinned ? pinnedCount : tabs.length + const boundedGapIndex = Math.max(minGapIndex, Math.min(maxGapIndex, Math.trunc(gapIndex))) + const targetIndex = boundedGapIndex > fromIndex ? boundedGapIndex - 1 : boundedGapIndex + return targetIndex === fromIndex ? null : targetIndex +} + +interface TabProps { + tab: TabStripItem + index: number + onSelect: (id: string) => void + onClose?: (id: string) => void + onContextMenu?: (event: ReactMouseEvent, id: string) => void + draggable: boolean + dragging: boolean + showDropBefore: boolean + showDropAfter: boolean + onDragStart: (event: ReactDragEvent, id: string) => void + onDragOver: (event: ReactDragEvent, index: number) => void + onDragLeave: (event: ReactDragEvent) => void + onDragEnd: () => void +} + +function Tab({ + tab, + index, + onSelect, + onClose, + onContextMenu, + draggable, + dragging, + showDropBefore, + showDropAfter, + onDragStart, + onDragOver, + onDragLeave, + onDragEnd, +}: TabProps) { + const titleRef = useRef(null) + const [titleTruncated, setTitleTruncated] = useState(false) + const closeable = Boolean(onClose) && !tab.pinned + + useLayoutEffect(() => { + const element = titleRef.current + if (!element) return + const update = () => setTitleTruncated(isTabTitleTruncated(element)) + update() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, [tab.title]) + + return ( +
onDragStart(event, tab.id)} + onDragOver={(event) => onDragOver(event, index)} + onDragLeave={onDragLeave} + onDragEnd={onDragEnd} + onContextMenu={(event) => onContextMenu?.(event, tab.id)} + > + {showDropBefore && ( +
+ )} + {showDropAfter && ( +
+ )} + + + + + {(tab.tooltip || tab.pinned || titleTruncated) && ( + {tab.tooltip || tab.title} + )} + + {closeable && ( + + )} +
+ ) +} + +/** + * Chrome-style tab strip, shared by every panel that hosts multiple live + * surfaces (the agent browser's pages, the agent terminal's shells). + * + * The strip owns interaction — selection, closing, drag reordering, the + * new-tab affordance, tooltips on clipped titles — and nothing about what a tab + * contains. Callers map their own state onto {@link TabStripItem} and supply + * the icon, which is why a favicon and a spinning shell indicator can share + * one component. + */ +export function TabStrip({ + tabs, + onSelect, + onClose, + onNew, + onReorder, + onTabContextMenu, + onTabDragStart, + maxTabs, + newTabLabel = 'New tab', + children, +}: TabStripProps) { + const atLimit = maxTabs !== undefined && tabs.length >= maxTabs + const draggedIdRef = useRef(null) + const dropTargetIndexRef = useRef(null) + const [draggedId, setDraggedId] = useState(null) + const [dropTargetIndex, setDropTargetIndex] = useState(null) + const reorderable = Boolean(onReorder) + + const resetDrag = useCallback(() => { + draggedIdRef.current = null + dropTargetIndexRef.current = null + setDraggedId(null) + setDropTargetIndex(null) + }, []) + + const handleDragStart = useCallback( + (event: ReactDragEvent, id: string) => { + if (!reorderable && !onTabDragStart) { + event.preventDefault() + return + } + if (reorderable) { + draggedIdRef.current = id + setDraggedId(id) + // `move` while the tab can also be dropped elsewhere would forbid the + // copy that dropping outside the strip is; the owner widens it below. + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', id) + } + // The strip knows about ordering and nothing else. Anything a tab means + // outside it — the page it holds, the shell it runs — belongs to whoever + // owns the tabs, so they attach it. + onTabDragStart?.(event, id) + }, + [reorderable, onTabDragStart] + ) + + const handleDragOver = useCallback( + (event: ReactDragEvent, index: number) => { + const id = draggedIdRef.current + if (!reorderable || !id) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + const rect = event.currentTarget.getBoundingClientRect() + const gapIndex = event.clientX < rect.left + rect.width / 2 ? index : index + 1 + const targetIndex = tabDropIndex(tabs, id, gapIndex) + dropTargetIndexRef.current = targetIndex + setDropTargetIndex(targetIndex) + }, + [reorderable, tabs] + ) + + const handleDrop = useCallback( + (event: ReactDragEvent) => { + event.preventDefault() + const id = draggedIdRef.current + const targetIndex = dropTargetIndexRef.current + if (id && targetIndex !== null) onReorder?.(id, targetIndex) + resetDrag() + }, + [onReorder, resetDrag] + ) + + const draggedIndex = tabs.findIndex((tab) => tab.id === draggedId) + + return ( +
+ {/* + The row is sized by its tabs rather than filling the strip, so the new-tab + button that follows sits beside the last tab instead of against the far + edge. Once the tabs no longer fit, the row shrinks (min-w-0 permits it) + and scrolls horizontally instead of growing, which pins the button back + at the right edge rather than pushing it out of view. + */} +
{ + if (draggedIdRef.current) event.preventDefault() + }} + onDrop={handleDrop} + > + {tabs.map((tab, index) => ( + = 0 && draggedIndex > index} + showDropAfter={dropTargetIndex === index && draggedIndex >= 0 && draggedIndex < index} + onSelect={onSelect} + {...(onClose ? { onClose } : {})} + {...(onTabContextMenu ? { onContextMenu: onTabContextMenu } : {})} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDragLeave={(event) => { + if ( + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return + } + dropTargetIndexRef.current = null + setDropTargetIndex(null) + }} + onDragEnd={resetDrag} + /> + ))} +
+ {onNew && ( + + + + + + {atLimit ? `Maximum of ${maxTabs} tabs` : newTabLabel} + + + )} + {children} +
+ ) +} diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index f5778e2e68..5f8cb5a8a8 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -590,6 +590,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { key='toast-stack' aria-live='polite' aria-label='Notifications' + data-native-surface-overlay='' className='fixed z-[var(--z-toast)] m-0 list-none p-0' exit={{ opacity: 0, diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 0c60055333..6dc3401cf1 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -246,6 +246,7 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ id={id} role={role} aria-hidden={role ? undefined : 'true'} + data-native-surface-overlay='' className={cn( 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out', 'motion-reduce:transition-none', diff --git a/packages/security/package.json b/packages/security/package.json index 754e354b98..afa673f035 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -26,6 +26,14 @@ "types": "./src/hmac.ts", "default": "./src/hmac.ts" }, + "./hostnames": { + "types": "./src/hostnames.ts", + "default": "./src/hostnames.ts" + }, + "./ssrf": { + "types": "./src/ssrf.ts", + "default": "./src/ssrf.ts" + }, "./tokens": { "types": "./src/tokens.ts", "default": "./src/tokens.ts" @@ -40,7 +48,9 @@ "test": "vitest run", "test:watch": "vitest" }, - "dependencies": {}, + "dependencies": { + "ipaddr.js": "2.3.0" + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", diff --git a/packages/security/src/hostnames.test.ts b/packages/security/src/hostnames.test.ts new file mode 100644 index 0000000000..e653c13f54 --- /dev/null +++ b/packages/security/src/hostnames.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames' + +describe('isLoopbackHostname', () => { + it('matches localhost and the loopback literals, brackets optional', () => { + expect(isLoopbackHostname('localhost')).toBe(true) + expect(isLoopbackHostname('127.0.0.1')).toBe(true) + expect(isLoopbackHostname('::1')).toBe(true) + expect(isLoopbackHostname('[::1]')).toBe(true) + }) + + it('does not match other loopback-range IPs or public hosts (exact-set only)', () => { + expect(isLoopbackHostname('127.0.0.5')).toBe(false) + expect(isLoopbackHostname('example.com')).toBe(false) + expect(isLoopbackHostname('10.0.0.1')).toBe(false) + }) +}) + +describe('unwrapIpv6Brackets', () => { + it('strips brackets from IPv6 authorities', () => { + expect(unwrapIpv6Brackets('[::1]')).toBe('::1') + expect(unwrapIpv6Brackets('[2606:4700::1111]')).toBe('2606:4700::1111') + }) + + it('leaves bare hostnames untouched', () => { + expect(unwrapIpv6Brackets('example.com')).toBe('example.com') + expect(unwrapIpv6Brackets('127.0.0.1')).toBe('127.0.0.1') + }) +}) diff --git a/packages/security/src/hostnames.ts b/packages/security/src/hostnames.ts new file mode 100644 index 0000000000..cd029da8d9 --- /dev/null +++ b/packages/security/src/hostnames.ts @@ -0,0 +1,28 @@ +/** + * Pure host-string helpers with no `ipaddr.js` dependency, so client bundles can + * share them without pulling the IP library. The ipaddr-backed classification + * lives in `./ssrf`, which re-exports these for its own consumers. + */ + +/** + * Strips the brackets the WHATWG URL parser puts around IPv6 authorities so the + * result can be matched or handed to an IP classifier directly. + */ +export function unwrapIpv6Brackets(host: string): string { + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host +} + +/** + * Loopback host identifiers permitted to use plain HTTP: `localhost` and the + * canonical loopback IP literals. Compared after stripping IPv6 brackets. + */ +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1']) + +/** + * True when a host (name or IP literal, IPv6 brackets optional) is loopback by + * exact match — `localhost`, `127.0.0.1`, or `::1`. For full-range loopback-IP + * classification (e.g. `127.0.0.5`) use `isLoopbackIp` from `./ssrf`. + */ +export function isLoopbackHostname(host: string): boolean { + return LOOPBACK_HOSTNAMES.has(unwrapIpv6Brackets(host)) +} diff --git a/packages/security/src/ssrf.test.ts b/packages/security/src/ssrf.test.ts new file mode 100644 index 0000000000..dfa7d30102 --- /dev/null +++ b/packages/security/src/ssrf.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from './ssrf' + +describe('isPrivateIp', () => { + describe('IPv4 private/reserved ranges', () => { + it.each([ + ['192.168.1.1'], + ['192.168.0.0'], + ['10.0.0.1'], + ['10.255.255.255'], + ['172.16.0.1'], + ['172.31.255.255'], + ['127.0.0.1'], + ['127.255.255.255'], + ['169.254.169.254'], + ['0.0.0.0'], + ['224.0.0.1'], + ])('blocks IPv4 %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('IPv6 reserved ranges', () => { + it.each([['::1'], ['::'], ['fe80::1'], ['fc00::1'], ['fd00::1'], ['ff02::1'], ['2001:db8::1']])( + 'blocks IPv6 %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { + it.each([ + ['::ffff:192.168.1.1'], + ['::ffff:127.0.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:c0a8:101'], + ['::ffff:0:0'], + ])('blocks mapped private/reserved %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it('allows mapped public IPv4 ::ffff:8.8.8.8', () => { + expect(isPrivateIp('::ffff:8.8.8.8')).toBe(false) + }) + }) + + describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { + it('blocks NAT64-encoded private IPv4', () => { + expect(isPrivateIp('64:ff9b::192.168.1.1')).toBe(true) + }) + }) + + describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { + it.each([ + ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], + ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], + ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], + ['::7f00:1', '127.0.0.1 (loopback)'], + ['::7f00:0001', '127.0.0.1 (zero-padded)'], + ['::a00:1', '10.0.0.1 (RFC1918)'], + ['::ac10:1', '172.16.0.1 (RFC1918)'], + ['::e000:1', '224.0.0.1 (multicast)'], + ['::192.168.1.1', 'dotted form ::192.168.1.1'], + ['::169.254.169.254', 'dotted form ::169.254.169.254'], + ['::127.0.0.1', 'dotted form ::127.0.0.1'], + ['::10.0.0.1', 'dotted form ::10.0.0.1'], + ])('blocks %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it.each([ + ['::8.8.8.8', 'dotted form embedding public IPv4'], + ['::808:808', 'hex form embedding 8.8.8.8'], + ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], + ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + + it.each([ + ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], + ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], + ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], + ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], + ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { + it.each([ + ['2606:4700:4700::1111'], + ['2001:4860:4860::8888'], + ['::1:c0a8:101'], + ['1::c0a8:101'], + ['1:2:3:4:5:6:c0a8:101'], + ])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 public addresses', () => { + it.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 alternate notations', () => { + it.each([['0177.0.0.1'], ['0x7f000001'], ['2130706433']])( + 'blocks loopback notation %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('invalid input fails closed', () => { + it.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g'], ['example.com']])( + 'rejects %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('URL-parser normalized IPv6 forms', () => { + it('blocks Node-normalized [::192.168.1.1] → ::c0a8:101', () => { + const hostname = new URL('http://[::192.168.1.1]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::c0a8:101') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + + it('blocks Node-normalized [::169.254.169.254] → ::a9fe:a9fe', () => { + const hostname = new URL('http://[::169.254.169.254]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::a9fe:a9fe') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + }) +}) + +describe('isPrivateIpHost', () => { + it('blocks private/reserved IP literals (IPv4 and IPv6, bracketed or bare)', () => { + expect(isPrivateIpHost('10.0.0.1')).toBe(true) + expect(isPrivateIpHost('169.254.169.254')).toBe(true) + expect(isPrivateIpHost('127.0.0.1')).toBe(true) + expect(isPrivateIpHost('[::1]')).toBe(true) + expect(isPrivateIpHost('[fd00:ec2::254]')).toBe(true) + expect(isPrivateIpHost('[::ffff:127.0.0.1]')).toBe(true) + }) + + it('allows public IP literals', () => { + expect(isPrivateIpHost('8.8.8.8')).toBe(false) + expect(isPrivateIpHost('[2606:4700:4700::1111]')).toBe(false) + }) + + it('fails open on DNS names (resolution handled separately)', () => { + expect(isPrivateIpHost('example.com')).toBe(false) + expect(isPrivateIpHost('api.zoominfo.com')).toBe(false) + expect(isPrivateIpHost('localhost')).toBe(false) + }) +}) + +describe('isLoopbackIp', () => { + it('matches the full loopback range and ::1', () => { + expect(isLoopbackIp('127.0.0.1')).toBe(true) + expect(isLoopbackIp('127.0.0.5')).toBe(true) + expect(isLoopbackIp('::1')).toBe(true) + }) + + it('rejects non-loopback and other private ranges', () => { + expect(isLoopbackIp('10.0.0.1')).toBe(false) + expect(isLoopbackIp('8.8.8.8')).toBe(false) + expect(isLoopbackIp('169.254.169.254')).toBe(false) + expect(isLoopbackIp('not-an-ip')).toBe(false) + expect(isLoopbackIp('localhost')).toBe(false) + }) +}) diff --git a/packages/security/src/ssrf.ts b/packages/security/src/ssrf.ts new file mode 100644 index 0000000000..930fe3eb70 --- /dev/null +++ b/packages/security/src/ssrf.ts @@ -0,0 +1,92 @@ +import * as ipaddr from 'ipaddr.js' +import { unwrapIpv6Brackets } from './hostnames' + +// Re-export the pure host helpers so existing `@sim/security/ssrf` consumers +// keep one import site; client code that must avoid ipaddr imports `./hostnames`. +export { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames' + +/** + * True when the (bracket-free) host is an IP literal rather than a DNS name — + * i.e. it can be classified with {@link isPrivateIp} directly, no DNS lookup. + */ +export function isIpLiteral(host: string): boolean { + return ipaddr.isValid(host) +} + +/** + * True when an IP address is loopback (127.0.0.0/8 or ::1). Narrower than + * {@link isPrivateIp}: callers that treat loopback differently from other + * private ranges (e.g. allowing local dev servers on self-host) use this. + */ +export function isLoopbackIp(ip: string): boolean { + try { + return ipaddr.isValid(ip) && ipaddr.process(ip).range() === 'loopback' + } catch { + return false + } +} + +/** + * Classifies an IP address as private or otherwise not routable on the public + * internet — the core SSRF primitive shared by every app that resolves a user- + * or model-supplied host before connecting to it. + * + * Uses ipaddr.js for robust handling of forms that regex checks miss: + * - Octal (`0177.0.0.1`) and hex (`0x7f000001`) IPv4 + * - IPv4-mapped IPv6 (`::ffff:127.0.0.1`) + * - IPv4-compatible IPv6 (`::a.b.c.d` / `::xxxx:xxxx`, RFC 4291 §2.5.5.1, deprecated) + * - Loopback, link-local (incl. the `169.254.169.254` cloud-metadata endpoint), + * unique-local, multicast, and every other non-`unicast` range + * + * Expects a bare IP (brackets already stripped). Returns `true` (blocked) for + * anything that is not a valid, publicly routable unicast address — including + * unparseable input, so callers fail closed. + */ +export function isPrivateIp(ip: string): boolean { + try { + if (!ipaddr.isValid(ip)) { + return true + } + + const addr = ipaddr.process(ip) + const range = addr.range() + + if (range !== 'unicast') { + return true + } + + if (addr.kind() === 'ipv6') { + const v6 = addr as ipaddr.IPv6 + const parts = v6.parts + const firstSixZero = parts.slice(0, 6).every((p) => p === 0) + if (firstSixZero) { + const embedded = ipaddr.fromByteArray([ + (parts[6] >> 8) & 0xff, + parts[6] & 0xff, + (parts[7] >> 8) & 0xff, + parts[7] & 0xff, + ]) + return embedded.range() !== 'unicast' + } + } + + return false + } catch { + return true + } +} + +/** + * Classifies a URL/host hostname string that may be an IP literal. Returns + * `true` only when the host is a **literal** IP that {@link isPrivateIp} blocks; + * a DNS name (which needs resolution to classify) returns `false`. + * + * Use this for the synchronous "is this host a private IP literal" guard — a + * pre-navigation check, or a per-request subresource filter — where hostnames + * are handled separately by a DNS-resolving check. IPv6 brackets are stripped + * automatically. + */ +export function isPrivateIpHost(host: string): boolean { + const clean = unwrapIpv6Brackets(host) + return isIpLiteral(clean) && isPrivateIp(clean) +} diff --git a/packages/terminal-protocol/package.json b/packages/terminal-protocol/package.json new file mode 100644 index 0000000000..b7ecad0f01 --- /dev/null +++ b/packages/terminal-protocol/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sim/terminal-protocol", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": {}, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts new file mode 100644 index 0000000000..320841c98d --- /dev/null +++ b/packages/terminal-protocol/src/index.ts @@ -0,0 +1,396 @@ +/** + * Shared types for the Sim agent terminal — the interactive shells built into + * the Sim desktop app. + * + * The Sim web app (renderer) drives real PTYs through the desktop preload + * bridge (`window.simDesktop.terminal`); the Electron main process owns the + * `node-pty` processes and streams their bytes back for xterm.js to render. + * The user and the agent share the same shells, so `cd`, exported variables, + * and scrollback are common to both. + * + * Several terminals can be open at once, each its own shell with its own + * working directory and scrollback, exactly like tabs in a terminal app. One is + * active at a time; agent tools act on the active one unless they name another. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/terminal` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +/** The single tool the model calls; what it does is in `operation`. */ +export const TERMINAL_TOOL_NAME = 'terminal' + +/** + * Names this surface used to expose, one tool per operation. Kept so rows in + * conversations recorded before the consolidation still render with a real + * title instead of a humanized tool name. + */ +export const LEGACY_TERMINAL_TOOL_NAMES = [ + 'terminal_run', + 'terminal_input', + 'terminal_read', + 'terminal_kill', + 'terminal_cwd', + 'terminal_list', + 'terminal_new', + 'terminal_switch', + 'terminal_close', +] as const + +export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] + +/** + * What one `terminal` call does. + * + * The first group acts on a shell — or, when that shell has tmux attached, on + * a pane inside it. The second group manages Sim's own tabs. `panes` is the + * one tmux-only operation: tmux owns its windows and splits, so the agent + * inspects them rather than Sim mirroring them into the tab strip. `handoff` + * gives the terminal to the user and waits. + */ +export const TERMINAL_OPERATIONS = [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', +] as const + +export type TerminalOperation = (typeof TERMINAL_OPERATIONS)[number] + +const TERMINAL_OPERATION_SET: ReadonlySet = new Set(TERMINAL_OPERATIONS) + +export function isTerminalOperation(value: unknown): value is TerminalOperation { + return typeof value === 'string' && TERMINAL_OPERATION_SET.has(value) +} + +export function isTerminalToolName(name: string): boolean { + return name === TERMINAL_TOOL_NAME +} + +/** + * Ceiling on concurrently open terminals. Each is a live shell process with its + * own emulator and scrollback, so the cap bounds both memory and the number of + * things the user has to keep track of. + */ +export const MAX_TERMINALS = 8 + +/** + * Largest command output handed back to the model, in characters. Output past + * this is middle-elided (head and tail kept) because the interesting parts of + * a long build log are the command echo and the failure at the end. + */ +export const MAX_TOOL_OUTPUT_CHARS = 30_000 + +/** Scrollback the main process retains per terminal for reads and repaints. */ +export const MAX_SCROLLBACK_CHARS = 256_000 + +/** + * Ceiling on the raw bytes buffered while capturing one command's output. A + * full-screen program repaints continuously and can emit megabytes a second, + * so capture keeps a capped head plus a rolling tail rather than growing until + * the command ends. + */ +export const MAX_CAPTURE_CHARS = 512_000 + +/** + * How long `terminal_run` waits for a command before handing control back. + * + * Deliberately short. A long blocking call would leave the user watching + * nothing and the agent unable to react, so anything still running comes back + * as `running` with the output so far; the agent then polls it with `wait` and + * `terminal_read`. Successive reads are also how it tells progress from a + * stall — output that stops changing is a command waiting on input or wedged. + */ +export const DEFAULT_RUN_WAIT_MS = 30_000 + +export const MAX_RUN_WAIT_MS = 120_000 + +/** + * How long output must be silent, with the cursor left mid-line, before the + * command is treated as sitting on a prompt and handed straight back. + * + * Waiting out the full window for something as obvious as `[y/n]` reads as a + * hang. A command that stops mid-line has written a prompt and is waiting for + * an answer; one that is merely working either keeps printing or has ended its + * last line properly, so neither trips this. + */ +export const PROMPT_IDLE_MS = 2_500 + +/** + * Ceiling on one batch of keystrokes. Long enough to cross a menu, short + * enough that a mistaken batch cannot run away with the program — every key + * after the first is sent without seeing what the last one did. + */ +export const MAX_INPUT_KEYS = 20 + +/** Control keys the agent may send to a running foreground process. */ +export const TERMINAL_CONTROL_KEYS = [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', +] as const + +export type TerminalControlKey = (typeof TERMINAL_CONTROL_KEYS)[number] + +const TERMINAL_CONTROL_KEY_SET: ReadonlySet = new Set(TERMINAL_CONTROL_KEYS) + +export function isTerminalControlKey(value: unknown): value is TerminalControlKey { + return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) +} + +export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' + +/** + * Arguments for every operation, flattened into one object. + * + * A flat bag rather than a discriminated union because it has to survive a + * round trip through a JSON tool schema, where the model supplies whichever + * fields its chosen operation needs. Each operation validates the ones it + * requires and ignores the rest. + */ +export interface TerminalToolArgs { + /** `run`: the command line to execute. */ + command?: string + /** `input`: literal text to type. */ + text?: string + /** `input`: a key to press instead of text. */ + key?: TerminalControlKey + /** + * `input`: several keys pressed in order, for stepping through a menu + * ("down", "down", "enter") without a round trip per keystroke. Each is a + * real keypress with a pause between, so the program redraws as it would + * under a person's hands. Capped at {@link MAX_INPUT_KEYS}. + */ + keys?: TerminalControlKey[] + /** `read`: trailing lines to return. */ + lines?: number + /** `kill`: which signal. Defaults to SIGINT. */ + signal?: TerminalSignal + /** `new`: directory to open in. Defaults to the active terminal's cwd. */ + cwd?: string + /** `run`: how long to wait before handing back a still-running command. */ + waitSeconds?: number + /** + * Which terminal to act on. Omitting it targets the active one, which is + * what the user is looking at and what a single-terminal conversation + * always means. + */ + terminalId?: string + /** + * Which tmux pane to act on, as a tmux target (`session:window.pane`), for + * a terminal that has tmux attached. Omitting it uses that session's active + * pane. Ignored when the terminal is a plain shell. + */ + pane?: string + /** `handoff`: what the user needs to do, shown on the chip they click. */ + reason?: string +} + +export interface TerminalToolCall { + operation: TerminalOperation + args?: TerminalToolArgs +} + +/** + * How a `terminal_run` ended. Only `completed` means the command is finished + * and the terminal is free; in every other case it is still running and still + * holds the foreground. + */ +export type TerminalRunStatus = + /** Exited on its own. `exitCode` is set. */ + | 'completed' + /** + * Still going when the wait window elapsed. Not an error and not a stall — + * poll it rather than re-running or giving up. + */ + | 'running' + /** + * Took over the screen (an editor, pager, or interactive CLI). Its output is + * redraws rather than text and it will not exit unaided. + */ + | 'interactive' + +export interface TerminalRunResult { + command: string + output: string + status: TerminalRunStatus + /** Null unless `status` is `completed`. */ + exitCode: number | null + durationMs: number + cwd: string | null + terminalId: string + /** Set when the command ran in tmux: the target it ran under. */ + pane?: string + /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ + truncated: boolean + /** + * Set when the command looks like it is blocked on a prompt: it printed + * something, stopped mid-line, and went quiet. Answer it with terminal_input + * rather than waiting — it will not proceed on its own. + */ + awaitingInput?: boolean +} + +export interface TerminalReadResult { + /** + * The screen as text. When a row is highlighted the way a menu marks its + * selection, it is prefixed `[selected] ` — a TUI that indicates the current + * row with colour alone is otherwise invisible in plain text, leaving the + * agent unable to tell where it is before it starts pressing keys. + */ + output: string + cwd: string | null + terminalId: string + /** Set when the read came from tmux: the pane it captured. */ + pane?: string + truncated: boolean + /** + * The command still holding the terminal, or null when the shell is back at + * a prompt. This is the definitive "is it done" signal for a poll loop — + * seeing expected text in the output is not, because a command can print its + * last line well before it exits. + */ + running: string | null +} + +export interface TerminalCwdResult { + cwd: string | null + shellName: string | null + home: string | null + terminalId: string +} + +/** One open terminal, as shown in the tab strip. */ +export interface TerminalTabState { + terminalId: string + /** Short label for the tab: the running command, else the cwd's basename. */ + title: string + cwd: string | null + /** Command holding the foreground, when one is running. */ + running: string | null + /** + * True while a full-screen program owns the terminal. Distinct from merely + * `running`: a build is transient work, an editor or coding agent is an open + * application that will sit there until it is quit. + */ + interactive: boolean + active: boolean + /** + * The tmux session attached in this terminal, when one is. Its windows and + * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a + * count of the shells Sim opened. + */ + tmuxSession?: string | null +} + +/** One tmux pane, as reported by the `panes` operation. */ +export interface TerminalPaneState { + /** tmux target (`session:window.pane`), usable as the `pane` argument. */ + target: string + windowName: string + /** The process tmux reports in the pane; a bare shell means it is idle. */ + command: string + cwd: string | null + active: boolean +} + +/** + * The outcome of handing a terminal to the user. + * + * Resolves when the command that was blocking finishes, so the agent picks up + * where it left off rather than having to guess whether the user is done. A + * command still going after the user says they have finished comes back with + * `running` set, which is the same poll-it signal a long `run` returns. + */ +export interface TerminalHandoffResult { + terminalId: string + reason: string + /** True when the user pressed the hand-back button rather than the command just ending. */ + handedBack: boolean + /** The command still holding the terminal, or null when it is back at a prompt. */ + running: string | null + /** The screen as it stands now. */ + output: string + cwd: string | null +} + +export interface TerminalPanesResult { + terminalId: string + session: string + panes: TerminalPaneState[] +} + +export interface TerminalTabsState { + tabs: TerminalTabState[] + activeTerminalId: string | null +} + +/** The result of one terminal tool invocation, as returned over the bridge. */ +export interface TerminalToolResponse { + ok: boolean + result?: unknown + error?: string + code?: TerminalErrorCode +} + +export type TerminalErrorCode = + | 'SESSION_CLOSED' + /** Another command already holds the foreground in that terminal. */ + | 'BUSY' + | 'TIMEOUT' + /** + * The shell never emitted integration markers, so command boundaries and + * exit codes are unknowable and `terminal_run` must refuse rather than guess. + */ + | 'NO_SHELL_INTEGRATION' + | 'SPAWN_FAILED' + /** No terminal with that id — the ids come from terminal_list. */ + | 'NO_SUCH_TERMINAL' + /** Already at {@link MAX_TERMINALS}. */ + | 'TOO_MANY_TERMINALS' + /** The operation needs tmux, and this terminal has no tmux attached. */ + | 'NO_TMUX' + /** No pane with that target — the targets come from the `panes` operation. */ + | 'NO_SUCH_PANE' + | 'INVALID_REQUEST' + +export interface TerminalStartOptions { + cols: number + rows: number +} + +/** One batch of PTY bytes, tagged with the terminal that produced it. */ +export interface TerminalOutputEvent { + terminalId: string + data: string +} + +/** + * Command lifecycle, used by the panel to attribute rows to the agent and to + * show a running indicator. Emitted for user-typed commands too (no + * `toolCallId`), so the agent's `terminal_read` and the user's view agree. + */ +export interface TerminalCommandEvent { + terminalId: string + phase: 'start' | 'end' + command: string + /** Set when the agent initiated this command rather than the user. */ + toolCallId?: string + exitCode?: number + durationMs?: number +} diff --git a/packages/terminal-protocol/tsconfig.json b/packages/terminal-protocol/tsconfig.json new file mode 100644 index 0000000000..1ffa3d2e84 --- /dev/null +++ b/packages/terminal-protocol/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 5d81d5a65f..d8e3ecd699 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -13,6 +13,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean isAuthDisabled: boolean @@ -56,6 +57,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, diff --git a/packages/testing/src/mocks/input-validation.mock.ts b/packages/testing/src/mocks/input-validation.mock.ts index bf8c38ac94..06e8ca88e7 100644 --- a/packages/testing/src/mocks/input-validation.mock.ts +++ b/packages/testing/src/mocks/input-validation.mock.ts @@ -17,7 +17,6 @@ export const inputValidationMockFns = { mockValidateDatabaseHost: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), mockSecureFetchWithValidation: vi.fn(), - mockIsPrivateOrReservedIP: vi.fn().mockReturnValue(false), mockCreatePinnedLookup: vi.fn(), } @@ -35,7 +34,6 @@ export const inputValidationMock = { validateDatabaseHost: inputValidationMockFns.mockValidateDatabaseHost, secureFetchWithPinnedIP: inputValidationMockFns.mockSecureFetchWithPinnedIP, secureFetchWithValidation: inputValidationMockFns.mockSecureFetchWithValidation, - isPrivateOrReservedIP: inputValidationMockFns.mockIsPrivateOrReservedIP, createPinnedLookup: inputValidationMockFns.mockCreatePinnedLookup, SecureFetchHeaders: class { headers: Record = {} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 49ac0d3c6c..de65ba7bbf 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -752,7 +752,6 @@ export const schemaMock = { model: 'model', conversationId: 'conversationId', previewYaml: 'previewYaml', - planArtifact: 'planArtifact', config: 'config', resources: 'resources', lastSeenAt: 'lastSeenAt', diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 36cc743b14..de3e23b6cd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -33,4 +33,9 @@ export { export type { BackoffOptions } from './retry.js' export { backoffWithJitter, parseRetryAfter } from './retry.js' export { normalizeSSODomain } from './sso-domain.js' -export { normalizeEmail, truncate } from './string.js' +export { + normalizeEmail, + sanitizeForJsonb, + sanitizeValueForJsonb, + truncate, +} from './string.js' diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index aca1811866..af81c22d5a 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -2,7 +2,14 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js' +import { + isVersionedType, + normalizeEmail, + sanitizeForJsonb, + sanitizeValueForJsonb, + stripVersionSuffix, + truncate, +} from './string.js' describe('truncate', () => { it('appends the suffix when the string exceeds the slice length', () => { @@ -56,6 +63,55 @@ describe('isVersionedType', () => { }) }) +describe('sanitizeForJsonb', () => { + it('replaces a lone high surrogate left by mid-character truncation', () => { + // '𝐀'.slice(0, 1) cuts the surrogate pair in half + const cut = '\uD835\uDC00'.slice(0, 1) + expect(sanitizeForJsonb(`FIFA WORLD CU${cut}`)).toBe('FIFA WORLD CU\uFFFD') + }) + + it('replaces a lone low surrogate', () => { + expect(sanitizeForJsonb('x\uDC00y')).toBe('x\uFFFDy') + }) + + it('replaces NUL characters', () => { + expect(sanitizeForJsonb('a\u0000b')).toBe('a\uFFFDb') + }) + + it('preserves well-formed surrogate pairs', () => { + expect(sanitizeForJsonb('𝐅𝐈𝐅𝐀 🏆')).toBe('𝐅𝐈𝐅𝐀 🏆') + }) + + it('handles a lone high surrogate followed by a valid pair', () => { + expect(sanitizeForJsonb('\uD835\uD835\uDC00')).toBe('\uFFFD\uD835\uDC00') + }) +}) + +describe('sanitizeValueForJsonb', () => { + it('sanitizes strings nested in objects and arrays', () => { + const input = { outline: ['ok', 'bad\uD835'], meta: { title: 'x\u0000' } } + expect(sanitizeValueForJsonb(input)).toEqual({ + outline: ['ok', 'bad\uFFFD'], + meta: { title: 'x\uFFFD' }, + }) + }) + + it('sanitizes object keys', () => { + expect(sanitizeValueForJsonb({ 'k\uDC00': 1 })).toEqual({ 'k\uFFFD': 1 }) + }) + + it('returns the same reference when nothing needs rewriting', () => { + const input = { a: ['clean', { b: 'also clean 🏆' }], n: 3 } + expect(sanitizeValueForJsonb(input)).toBe(input) + }) + + it('passes primitives through unchanged', () => { + expect(sanitizeValueForJsonb(42)).toBe(42) + expect(sanitizeValueForJsonb(null)).toBe(null) + expect(sanitizeValueForJsonb(undefined)).toBe(undefined) + }) +}) + describe('normalizeEmail', () => { it('trims surrounding whitespace and lowercases', () => { expect(normalizeEmail(' USER@Example.COM ')).toBe('user@example.com') diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 1cbea71cfb..f087494ff7 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -45,3 +45,55 @@ export function isVersionedType(value: string): boolean { export function normalizeEmail(email: string): string { return email.trim().toLowerCase() } + +/** + * Matches UTF-16 code units that Postgres JSONB rejects: unpaired surrogate + * halves (e.g. produced by `slice()` cutting an astral character like 𝐀 in + * half) and the NUL character, which jsonb cannot store at all. + */ +const JSONB_UNSAFE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(value: T): T { + if (typeof value === 'string') { + const clean = sanitizeForJsonb(value) + return (clean === value ? value : clean) as T + } + if (Array.isArray(value)) { + let changed = false + const result = value.map((item) => { + const clean = sanitizeValueForJsonb(item) + if (clean !== item) changed = true + return clean + }) + return (changed ? result : value) as T + } + if (typeof value === 'object' && value !== null) { + let changed = false + const result: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + const cleanKey = sanitizeForJsonb(key) + const cleanItem = sanitizeValueForJsonb(item) + if (cleanKey !== key || cleanItem !== item) changed = true + result[cleanKey] = cleanItem + } + return (changed ? result : value) as T + } + return value +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7d364ffe8d..db4fde9499 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 984, - zodRoutes: 984, + totalRoutes: 990, + zodRoutes: 990, nonZodRoutes: 0, } as const @@ -32,6 +32,12 @@ const BOUNDARY_POLICY_BASELINE = { const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/demo-requests/route.ts', + // Input-less session-bound GET: nothing to validate; response is + // contract-typed via `satisfies InvitationDetails` in the route. + // Public updater feed: input-less GET, session-less, returns YAML (not JSON), + // so it can't be JSON-contract-bound. Wrapped in withRouteHandler. + 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', + 'apps/sim/app/api/invitations/route.ts', 'apps/sim/app/api/logs/export/route.ts', 'apps/sim/app/api/tools/docusign/route.ts', // Better Auth handles its own validation for the catch-all route below. @@ -45,6 +51,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/auth/oauth/connections/route.ts', 'apps/sim/app/api/auth/providers/route.ts', 'apps/sim/app/api/auth/socket-token/route.ts', + 'apps/sim/app/api/desktop/auth/handoff/route.ts', 'apps/sim/app/api/workspaces/invitations/route.ts', // Internal cron entry point that authenticates via `Authorization: Bearer // CRON_SECRET` and ignores query/body. The boundary contract is "no diff --git a/scripts/check-desktop-bridge-contract.ts b/scripts/check-desktop-bridge-contract.ts new file mode 100644 index 0000000000..aa28cb658a --- /dev/null +++ b/scripts/check-desktop-bridge-contract.ts @@ -0,0 +1,258 @@ +/** + * Desktop bridge contract audit. + * + * The desktop shell is an installed binary users update on their own + * schedule, while the web app it loads is deployed continuously. The preload + * bridge contract (`@sim/desktop-bridge`, which embeds `@sim/browser-protocol` + * types) must therefore stay backward compatible: an already-installed shell + * has to satisfy whatever the newest web deployment expects. + * + * This script keeps a frozen snapshot of the full bridge type surface + * (`packages/desktop-bridge/contract-snapshot.ts`) and type-checks that a + * shell built from the snapshot is still assignable to the current + * `SimDesktopApi` — additive/optional changes pass, removals, renames, and + * new required members fail. + * + * Modes: + * - `--check` (default, CI): fails when the current types break + * compatibility with the snapshot, or when the snapshot's recorded floor + * drifts from `MIN_DESKTOP_VERSION`. + * - `--update`: regenerates the snapshot from the current sources. A + * breaking regeneration is refused unless `MIN_DESKTOP_VERSION` + * (`apps/sim/lib/desktop/min-version.ts`) was raised above the previous + * snapshot's floor — bumping the floor is the deliberate escape hatch that + * makes outdated shells show the "update to continue" takeover. + * + * Known limitation: TypeScript checks method parameters bivariantly, so + * widening a request union or callback payload is not flagged. Those changes + * are additive for the shell (unknown requests fail soft), but semantic + * changes to callback payloads still need review. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const BRIDGE_SOURCE_PATH = resolve(ROOT, 'packages/desktop-bridge/src/index.ts') +const PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/browser-protocol/src/index.ts') +const TERMINAL_PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/terminal-protocol/src/index.ts') +/** + * Protocol modules folded into the snapshot verbatim. Anything the bridge + * imports that carries wire shape belongs here — a package left out stays + * outside the freeze, and changes to it pass the audit unnoticed. + */ +const INLINED_PROTOCOL_PACKAGES = ['@sim/browser-protocol', '@sim/terminal-protocol'] as const +const SNAPSHOT_PATH = resolve(ROOT, 'packages/desktop-bridge/contract-snapshot.ts') +const MIN_VERSION_PATH = resolve(ROOT, 'apps/sim/lib/desktop/min-version.ts') + +const FLOOR_PATTERN = /^ \* min-desktop-version: (\S+)$/m + +async function readMinDesktopVersion(): Promise { + const source = await readFile(MIN_VERSION_PATH, 'utf8') + const match = /export const MIN_DESKTOP_VERSION = '([^']+)'/.exec(source) + if (!match) { + throw new Error(`Could not find MIN_DESKTOP_VERSION in ${MIN_VERSION_PATH}`) + } + return match[1] +} + +async function readSnapshot(): Promise<{ source: string; floor: string } | null> { + let source: string + try { + source = await readFile(SNAPSHOT_PATH, 'utf8') + } catch { + return null + } + const floor = FLOOR_PATTERN.exec(source)?.[1] + if (!floor) { + throw new Error(`${SNAPSHOT_PATH} is missing its min-desktop-version header`) + } + return { source, floor } +} + +/** Plain x.y.z ordering for floor versions; throws on anything else. */ +function isFloorRaised(next: string, previous: string): boolean { + const parse = (version: string): number[] => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) + if (!match) { + throw new Error(`MIN_DESKTOP_VERSION must be a plain x.y.z version, got '${version}'`) + } + return [Number(match[1]), Number(match[2]), Number(match[3])] + } + const [nextParts, previousParts] = [parse(next), parse(previous)] + for (let i = 0; i < 3; i++) { + if (nextParts[i] !== previousParts[i]) { + return nextParts[i] > previousParts[i] + } + } + return false +} + +async function buildSnapshot(floor: string): Promise { + const protocol = await readFile(PROTOCOL_SOURCE_PATH, 'utf8') + const terminalProtocol = await readFile(TERMINAL_PROTOCOL_SOURCE_PATH, 'utf8') + const bridgeRaw = await readFile(BRIDGE_SOURCE_PATH, 'utf8') + // The snapshot must be self-contained. A surviving import resolves to the + // CURRENT module on BOTH sides of the comparison, so every change to it is + // invisible to this audit — which is exactly what happened to + // @sim/terminal-protocol, leaving the whole terminal wire surface unfrozen. + const bridge = INLINED_PROTOCOL_PACKAGES.reduce( + (source, pkg) => source.replace(new RegExp(`import type \\{[^}]*\\} from '${pkg}'\\n`), ''), + bridgeRaw + ) + for (const pkg of INLINED_PROTOCOL_PACKAGES) { + if (bridge.includes(pkg)) { + throw new Error( + `packages/desktop-bridge/src/index.ts references ${pkg} in an unexpected shape — ` + + 'update scripts/check-desktop-bridge-contract.ts to inline it.' + ) + } + } + const header = [ + '/**', + ' * GENERATED FILE — DO NOT EDIT.', + ' *', + ' * Frozen snapshot of the desktop preload bridge type surface', + ' * (@sim/browser-protocol + @sim/terminal-protocol inlined into', + ' * @sim/desktop-bridge) as of the last accepted contract change.', + ' * CI type-checks that a shell built from this', + ' * snapshot still satisfies the current SimDesktopApi, so bridge changes', + ' * stay backward compatible with already-installed shells.', + ' *', + ' * Regenerate with: bun run desktop-bridge-contract:update', + ' * Full rules: scripts/check-desktop-bridge-contract.ts', + ' *', + ` * min-desktop-version: ${floor}`, + ' */', + '', + ].join('\n') + const source = `${header}${protocol}\n${terminalProtocol}\n${bridge}` + return formatGeneratedSource(source, SNAPSHOT_PATH, ROOT) +} + +/** + * Type-checks that an old shell (the committed snapshot) is assignable to + * the current SimDesktopApi — i.e. new web code can run against it. + */ +function checkCompatibility(): { compatible: boolean; output: string } { + const compatSource = [ + `import type { SimDesktopApi as CurrentApi } from '${BRIDGE_SOURCE_PATH}'`, + `import type { SimDesktopApi as OldShellApi } from '${SNAPSHOT_PATH}'`, + '', + 'declare const oldInstalledShell: OldShellApi', + '// If this assignment fails, the current web app expects something an', + '// already-installed shell cannot provide — a breaking bridge change.', + 'const currentWebAppExpectation: CurrentApi = oldInstalledShell', + 'void currentWebAppExpectation', + '', + ].join('\n') + const tsconfig = { + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + skipLibCheck: true, + types: [], + paths: { + '@sim/browser-protocol': [PROTOCOL_SOURCE_PATH], + '@sim/terminal-protocol': [TERMINAL_PROTOCOL_SOURCE_PATH], + }, + }, + files: ['./compat.ts'], + } + + const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-bridge-contract-')) + try { + writeFileSync(join(dir, 'compat.ts'), compatSource) + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)) + const result = spawnSync('bunx', ['tsc', '-p', dir, '--pretty', 'false'], { + cwd: ROOT, + encoding: 'utf8', + }) + return { + compatible: result.status === 0, + output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(), + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const BREAKING_GUIDANCE = ` +A shell built from the committed contract snapshot no longer satisfies the +current SimDesktopApi — installed desktop apps would break against this web +deployment. Either: + + 1. Make the change backward compatible: new fields, methods, and surfaces + on the bridge must be optional so older shells (which lack them) still + type-check. This is the default — prefer it. + + 2. If the change is genuinely breaking: bump MIN_DESKTOP_VERSION in + apps/sim/lib/desktop/min-version.ts to the desktop release your shell + change ships in, then run: + + bun run desktop-bridge-contract:update + + Shells older than that floor will show a blocking "Update Sim to + continue" screen until they update. +` + +async function runCheck(): Promise { + const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()]) + if (!snapshot) { + console.error( + `Missing ${SNAPSHOT_PATH}.\nRun: bun run desktop-bridge-contract:update and commit the result.` + ) + process.exit(1) + } + if (snapshot.floor !== minVersion) { + console.error( + `Contract snapshot floor (${snapshot.floor}) does not match MIN_DESKTOP_VERSION ` + + `(${minVersion}).\nRun: bun run desktop-bridge-contract:update and commit the result.` + ) + process.exit(1) + } + const { compatible, output } = checkCompatibility() + if (!compatible) { + console.error('Breaking desktop bridge change detected.\n') + console.error(output) + console.error(BREAKING_GUIDANCE) + process.exit(1) + } + console.log('Desktop bridge contract audit passed: bridge types are backward compatible.') +} + +async function runUpdate(): Promise { + const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()]) + if (snapshot) { + const { compatible, output } = checkCompatibility() + if (!compatible && !isFloorRaised(minVersion, snapshot.floor)) { + console.error('Refusing to accept a breaking bridge change without a floor bump.\n') + console.error(output) + console.error(BREAKING_GUIDANCE) + process.exit(1) + } + } + await writeFile(SNAPSHOT_PATH, await buildSnapshot(minVersion)) + console.log(`Regenerated ${SNAPSHOT_PATH} (min-desktop-version: ${minVersion}).`) +} + +const mode = process.argv.includes('--update') ? 'update' : 'check' +try { + if (mode === 'update') { + await runUpdate() + } else { + await runCheck() + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/scripts/check-desktop-ipc-contract.ts b/scripts/check-desktop-ipc-contract.ts new file mode 100644 index 0000000000..79ec7fda64 --- /dev/null +++ b/scripts/check-desktop-ipc-contract.ts @@ -0,0 +1,293 @@ +/** + * Audits the desktop IPC channel table against the preload bridge. + * + * This is the companion to `check-desktop-bridge-contract.ts` and exists + * because that one has a structural blind spot: it compares the bridge types + * against a snapshot the same PR is allowed to regenerate, so a change is only + * caught if the author forgets to run the updater. Nothing there derives truth + * from the running wire surface. + * + * This script has no snapshot. Every fact it checks is read from the source + * both sides actually execute: + * + * 1. Every channel the preload calls is declared in the main-process table, + * and every declared channel is reachable from the preload. A channel on + * one side only is either dead code or a call that silently no-ops. + * 2. Within a channel-name family (`terminal:`, `browser-agent:`, …) the + * `gate` and `requires` values agree, unless the outlier carries an + * explicit acknowledgment. A surface toggle that a new channel forgets is + * invisible in review — the channel simply works when it should not. + * + * Deviations are legitimate and common: a channel that READS or RESETS a + * surface's settings must keep working while the surface is off, or the user + * could never turn it back on. Each one says so in its own spec: + * + * 'browser-import:sites': { + * gate: 'app-origin', + * deviationReason: 'a read of already-imported data; settings lists these + * hosts to show what an import brought over', + * ... + * } + * + * A typed field rather than a `-- migration-safe:`-style comment because here + * the thing being annotated IS typed data. A comment is bound by position, so + * reordering the table would silently transfer an acknowledgment to whichever + * channel moved underneath it; a field moves with its channel. + * + * Run: `bun run check:desktop-ipc` + */ + +import { readdir, readFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const IPC_SOURCE_PATH = resolve(ROOT, 'apps/desktop/src/main/ipc.ts') +/** + * Every preload that can reach the handler table. The browser-page preload is + * a separate bundle with its own channels — omitting it made this audit report + * `browser-credentials:form-state` as dead when it is the one channel a real + * page depends on. + */ +const PRELOAD_SOURCE_PATHS = [ + resolve(ROOT, 'apps/desktop/src/preload/index.ts'), + resolve(ROOT, 'apps/desktop/src/preload/browser/index.ts'), +] + +const MAIN_SOURCE_DIR = resolve(ROOT, 'apps/desktop/src/main') + +/** + * Every channel declares a gate, so an empty parse is a parser failure rather + * than a real value — and a family that all parsed empty would agree with + * itself and assert nothing. Asserting per channel turns silent vacuity into + * a loud error, which is the failure mode this whole script exists to prevent + * in its sibling audit. + */ +const REQUIRED_FIELD = 'gate' + +/** + * How the main process pushes to the renderer. These channels never appear in + * the handler table — nothing is registered for them — so the reverse + * direction has to be verified against the senders themselves rather than + * skipped by prefix, which would have made the check vacuous for four of the + * six families. + */ +const PUSH_CALL_PATTERN = /(?:\.send|broadcast)\(\s*'([^']+)'/g + +interface ChannelDecl { + name: string + gate: string + requires: string + line: number + /** The channel's own `deviationReason`, or null when it declares none. */ + deviationReason: string | null +} + +/** The handler table is one `'channel': {` entry per line at a fixed depth. */ +function parseChannelTable(source: string): ChannelDecl[] { + const lines = source.split('\n') + const decls: ChannelDecl[] = [] + for (let i = 0; i < lines.length; i++) { + const match = /^ {4}'([^']+)':\s*\{$/.exec(lines[i]) + if (!match) continue + const closing = lines.indexOf(' },', i) + if (closing < 0) continue + const body = lines.slice(i, closing).join('\n') + // Read from the channel's OWN body, so the acknowledgment travels with it + // if the table is ever reordered. + const reason = /deviationReason:\s*\n?\s*(?:'([^']*)'|"([^"]*)")/.exec(body) + decls.push({ + name: match[1], + gate: /gate:\s*'([^']+)'/.exec(body)?.[1] ?? '', + requires: /requires:\s*'([^']+)'/.exec(body)?.[1] ?? '', + line: i + 1, + deviationReason: reason ? (reason[1] ?? reason[2] ?? '').trim() : null, + }) + } + return decls +} + +function familyOf(channel: string): string { + return channel.slice(0, channel.indexOf(':')) +} + +/** The value most channels in a family use; ties resolve to the first seen. */ +function dominant(values: string[]): string { + const counts = new Map() + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1) + let best = values[0] + let bestCount = 0 + for (const [value, count] of counts) { + if (count > bestCount) { + best = value + bestCount = count + } + } + return best +} + +/** + * Channel names bound to a module constant, so a call written as + * `ipcRenderer.send(FORM_STATE_CHANNEL, …)` resolves like an inline literal. + * Only channel-shaped values (`family:name`) are collected, which keeps every + * other string constant in the file out of the map. + */ +function channelConstants(source: string): Map { + const constants = new Map() + for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*'([^']*:[^']*)'/g)) { + constants.set(match[1], match[2]) + } + return constants +} + +/** Every channel any main-process module pushes to a renderer. */ +async function channelsPushedFromMain(): Promise> { + const pushed = new Set() + const entries = await readdir(MAIN_SOURCE_DIR, { recursive: true, withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) continue + const source = await readFile(resolve(entry.parentPath, entry.name), 'utf8') + for (const match of source.matchAll(PUSH_CALL_PATTERN)) pushed.add(match[1]) + } + return pushed +} + +function channelsFromPreload(source: string, methods: string): Set { + const constants = channelConstants(source) + const found = new Set() + const pattern = new RegExp( + `ipcRenderer\\.(?:${methods})\\(\\s*(?:'([^']+)'|([A-Za-z_$][\\w$]*))`, + 'g' + ) + for (const match of source.matchAll(pattern)) { + const name = match[1] ?? constants.get(match[2] ?? '') + if (name) found.add(name) + } + return found +} + +async function main(): Promise { + const [ipcSource, ...preloadSources] = await Promise.all([ + readFile(IPC_SOURCE_PATH, 'utf8'), + ...PRELOAD_SOURCE_PATHS.map((path) => readFile(path, 'utf8')), + ]) + + const declared = parseChannelTable(ipcSource) + // Completeness, not just non-emptiness. A total parse failure is obvious; the + // dangerous case is PARTIAL — 48 of 49 channels parsed, the audit prints + // "passed", and the one it skipped is the one the PR added. Counting the + // channel-shaped keys in the file independently of how the bodies parse is + // what makes a skipped entry loud. + // Deliberately looser than the parser's own pattern: it counts the KEY only, + // with no constraint on what follows. Deriving both counts from the same + // shape would make them drop together and agree, which is exactly the + // vacuous pass this guard exists to prevent. + const expectedChannels = (ipcSource.match(/^ {4}'[a-z-]+:[^']*':/gm) ?? []).length + if (declared.length !== expectedChannels) { + throw new Error( + `Parsed ${declared.length} channels from ${relative(ROOT, IPC_SOURCE_PATH)} but the file ` + + `declares ${expectedChannels} — the table's shape changed and this script can no longer ` + + 'read all of it. Update parseChannelTable rather than letting the audit pass vacuously.' + ) + } + + const unparsed = declared.filter((channel) => channel[REQUIRED_FIELD] === '') + if (unparsed.length > 0) { + throw new Error( + `Could not read \`${REQUIRED_FIELD}\` for ${unparsed.length} channel(s) ` + + `(${unparsed.map((channel) => channel.name).join(', ')}). Every channel declares one, so ` + + 'this is a parser failure — a family that all parsed empty would agree with itself and ' + + 'assert nothing. Update parseChannelTable rather than letting the audit pass vacuously.' + ) + } + + const called = new Set() + const subscribed = new Set() + for (const source of preloadSources) { + for (const name of channelsFromPreload(source, 'invoke|send')) called.add(name) + for (const name of channelsFromPreload(source, 'on|once')) subscribed.add(name) + } + const declaredNames = new Set(declared.map((channel) => channel.name)) + const preloadLabel = 'apps/desktop/src/preload' + const failures: string[] = [] + + for (const name of called) { + if (!declaredNames.has(name)) { + failures.push( + `${preloadLabel}: calls '${name}', which no main-process handler declares. The call ` + + 'resolves to nothing at runtime.' + ) + } + } + + for (const channel of declared) { + if (called.has(channel.name)) continue + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' is handled but never ` + + 'called from the preload — dead channel, or a bridge method that was dropped.' + ) + } + + const pushed = await channelsPushedFromMain() + for (const name of subscribed) { + if (declaredNames.has(name) || pushed.has(name)) continue + failures.push( + `${preloadLabel}: subscribes to '${name}', but no main-process module ever sends it — the ` + + 'listener can never fire.' + ) + } + + const families = new Map() + for (const channel of declared) { + const family = familyOf(channel.name) + const list = families.get(family) + if (list) list.push(channel) + else families.set(family, [channel]) + } + + for (const [family, channels] of families) { + if (channels.length < 2) continue + const expectedGate = dominant(channels.map((channel) => channel.gate)) + const expectedRequires = dominant(channels.map((channel) => channel.requires)) + for (const channel of channels) { + const deviations: string[] = [] + if (channel.gate !== expectedGate) { + deviations.push(`gate '${channel.gate}' (family uses '${expectedGate}')`) + } + if (channel.requires !== expectedRequires) { + const shown = channel.requires === '' ? 'no requires' : `requires '${channel.requires}'` + const expected = expectedRequires === '' ? 'no requires' : `'${expectedRequires}'` + deviations.push(`${shown} (family uses ${expected})`) + } + if (deviations.length === 0) continue + if (channel.deviationReason === null) { + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' departs from the ` + + `${family}: family — ${deviations.join(', ')}. If deliberate, give it a ` + + '`deviationReason` stating why.' + ) + } else if (channel.deviationReason === '') { + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' has an empty ` + + '`deviationReason` — state why the deviation is correct.' + ) + } + } + } + + if (failures.length > 0) { + console.error('Desktop IPC contract audit failed:\n') + for (const failure of failures) console.error(` ✗ ${failure}`) + console.error(`\n${failures.length} problem(s).`) + process.exit(1) + } + + const exempt = declared.filter((channel) => channel.deviationReason !== null).length + console.log( + `Desktop IPC contract audit passed: ${declared.length} channels across ${families.size} ` + + `families, ${exempt} acknowledged deviation(s).` + ) +} + +await main()
+
S
+

Can’t connect to Sim

+

+ Sim couldn’t reach the server. Check your internet connection, then try again. +

+
+ +
+
+
+