From 1671834d9bb739a9a000bb76f35008589827b3d0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Jul 2026 11:33:05 -0700 Subject: [PATCH 001/109] top on a desk --- .github/workflows/ci.yml | 13 + .github/workflows/desktop-e2e.yml | 86 +++ .github/workflows/desktop-release.yml | 140 +++++ apps/desktop/.gitignore | 4 + apps/desktop/README.md | 161 +++++ .../docs/electron-upgrade-checklist.md | 18 + apps/desktop/e2e/smoke.spec.ts | 111 ++++ apps/desktop/electron-builder.yml | 49 ++ apps/desktop/package.json | 44 ++ apps/desktop/playwright.config.ts | 9 + apps/desktop/scripts/build.ts | 41 ++ apps/desktop/src/main/config.test.ts | 129 ++++ apps/desktop/src/main/config.ts | 164 +++++ apps/desktop/src/main/context-menu.test.ts | 93 +++ apps/desktop/src/main/context-menu.ts | 112 ++++ 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 | 115 ++++ apps/desktop/src/main/handoff.ts | 266 ++++++++ apps/desktop/src/main/index.ts | 243 +++++++ apps/desktop/src/main/ipc.test.ts | 97 +++ apps/desktop/src/main/ipc.ts | 81 +++ apps/desktop/src/main/load-health.test.ts | 34 + apps/desktop/src/main/load-health.ts | 151 +++++ apps/desktop/src/main/menu.ts | 122 ++++ apps/desktop/src/main/navigation.test.ts | 245 ++++++++ apps/desktop/src/main/navigation.ts | 227 +++++++ apps/desktop/src/main/observability.test.ts | 42 ++ apps/desktop/src/main/observability.ts | 76 +++ apps/desktop/src/main/security-guards.test.ts | 148 +++++ apps/desktop/src/main/security-guards.ts | 83 +++ .../src/main/session-lifecycle.test.ts | 93 +++ apps/desktop/src/main/session-lifecycle.ts | 242 +++++++ apps/desktop/src/main/settings-window.ts | 50 ++ .../desktop/src/main/telemetry-policy.test.ts | 23 + apps/desktop/src/main/telemetry-policy.ts | 50 ++ apps/desktop/src/main/updater.test.ts | 71 +++ apps/desktop/src/main/updater.ts | 231 +++++++ apps/desktop/src/main/window.test.ts | 102 +++ apps/desktop/src/main/window.ts | 360 +++++++++++ apps/desktop/src/main/windows.test.ts | 102 +++ apps/desktop/src/main/windows.ts | 97 +++ apps/desktop/src/preload/index.ts | 43 ++ apps/desktop/src/test/electron-mock.ts | 99 +++ apps/desktop/static/offline.html | 177 ++++++ apps/desktop/static/settings.html | 151 +++++ apps/desktop/tsconfig.json | 11 + apps/desktop/vitest.config.ts | 21 + apps/sim/app/desktop/auth/page.test.tsx | 80 +++ apps/sim/app/desktop/auth/page.tsx | 71 +++ apps/sim/app/desktop/auth/validation.test.ts | 57 ++ apps/sim/app/desktop/auth/validation.ts | 55 ++ biome.json | 4 +- bun.lock | 594 +++++++++++++++--- 54 files changed, 5866 insertions(+), 100 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/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/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/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/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/settings-window.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/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/index.ts create mode 100644 apps/desktop/src/test/electron-mock.ts create mode 100644 apps/desktop/static/offline.html create mode 100644 apps/desktop/static/settings.html create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/vitest.config.ts 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d8348d8c4..eadda49a0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,3 +446,16 @@ jobs: env: GH_PAT: ${{ secrets.GITHUB_TOKEN }} run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }} + + # Desktop macOS artifacts attach to the release created above. Ordering is + # load-bearing: create-single-release.ts skips creation when the tag already + # exists, so the desktop job must never run before create-release. + desktop-release: + name: Desktop Release + needs: [detect-version, create-release] + if: needs.detect-version.outputs.is_release == 'true' + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.detect-version.outputs.version }} + publish: true + secrets: inherit diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml new file mode 100644 index 00000000000..8e75e2c4a04 --- /dev/null +++ b/.github/workflows/desktop-e2e.yml @@ -0,0 +1,86 @@ +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). + +on: + pull_request: + paths: + - 'apps/desktop/**' + - '.github/workflows/desktop-e2e.yml' + 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 00000000000..571c38d5b14 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,140 @@ +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 + 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 + +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 + + - name: Bundle main and preload + working-directory: apps/desktop + run: bun run build + + - name: Write App Store Connect API key + 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 + 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 }} + run: bunx electron-builder --mac --publish never + + - name: Validate signature and notarization + 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: | + # *-mac.yml, not latest-mac.yml: a prerelease build emits + # beta-mac.yml / alpha-mac.yml, so the hardcoded name would miss it. + gh release upload "$VERSION" \ + apps/desktop/release/*.dmg \ + apps/desktop/release/*.zip \ + apps/desktop/release/*.blockmap \ + apps/desktop/release/*-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/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 00000000000..f0e09cd6d38 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,4 @@ +dist/ +release/ +playwright-report/ +test-results/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000000..f7114009e91 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,161 @@ +# 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 + config.ts # origin + settings store (userData/settings.json) + navigation.ts # navigation classifier + openExternalSafe + windows.ts # window.open policy (single-window, MCP popup, blank children) + window.ts # secure BrowserWindow, permissions, crash/hang recovery + security-guards.ts# global web-contents guards, TLS policy + 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 + 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 +src/preload/ # contextBridge IPC bridge (bundled to dist/preload.cjs) +static/ # bundled local pages (offline.html, settings.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=…`), mints a better-auth one-time token, and **redirects straight to the loopback** (`http://127.0.0.1:/auth/callback?token=…&state=…`, RFC 8252 §7.3). This is the single hand-back channel — a deterministic server redirect, no OS scheme registration, no client-side step, 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`. +- 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 clears cookies/localStorage/IndexedDB/cache/service workers plus any pending handoff state. Two signals trigger it: 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 | + +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. + +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). +- 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). To give the web app types without duplication, put the bridge interface in a shared **types-only package** (e.g. `packages/desktop-bridge`) that both the preload (implements) and the web app (consumes, via a `useDesktop()` hook returning the typed bridge or `null`) import. Until such a feature exists, the bridge stays desktop-local — there are zero desktop-only features in the web app today. + +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. + +## 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. `isVersionBlocked` in `updater.ts` is the kill-switch hook (wire `blockedVersions` when a remote config source exists). +- 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 + +- Voice STT requires the microphone TCC prompt (wired; `NSMicrophoneUsageDescription` + `device.audio-input` entitlement). Camera stays denied by design. +- Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build. +- Web Speech **recognition** (`SpeechRecognition`) does not exist in Electron; Sim does not use it (voice goes through `getUserMedia` + server STT). +- 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/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md new file mode 100644 index 00000000000..8b6a92ccf3c --- /dev/null +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -0,0 +1,18 @@ +# Electron upgrade checklist + +The rendering-parity guarantee (identical to Chrome of the pinned version) is only durable if upgrades are routine. Run this list for every Electron major bump; the abridged list (steps 1, 2, 6, 8) for patch/security releases. + +1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses. +2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`. +3. **Fuses:** the build sets `strictlyRequireAllFuses` — if `electron-builder` fails on a new fuse, decide its state explicitly in `electron-builder.yml` rather than loosening the strict flag. +4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest. +5. **Manual spot-checks (packaged build):** + - Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem). + - GitHub sign-in in-window; one integration connect (e.g. Notion) in-window; one Google-family connect via the browser dialog. + - MCP OAuth popup completes and posts back to the opener. + - Voice input records (mic TCC prompt on a clean profile). + - Workflow canvas (WebGL/ReactFlow), Monaco editing, a table export download. + - Offline page appears with networking off; Retry recovers. +6. **E2E:** `bun run test:e2e` green locally on the new pin; `desktop-e2e.yml` green in CI (the `latest` canary leg should already have hinted at surprises). +7. **Signing/notarization smoke:** run `desktop-release.yml` via `workflow_dispatch` with `publish: false` against a test tag; `spctl`/`stapler` steps must pass. +8. **Ship** behind a staged rollout (10% `stagingPercentage`) and watch `update_error` / `renderer_gone` rates in the event logs before raising. diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts new file mode 100644 index 00000000000..a580535f44f --- /dev/null +++ b/apps/desktop/e2e/smoke.spec.ts @@ -0,0 +1,111 @@ +import { mkdtempSync } from 'node:fs' +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { ElectronApplication } from '@playwright/test' +import { _electron as electron, expect, test } from '@playwright/test' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) + +const PAGES: Record = { + '/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 collapses into the main window (single-window policy)', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await window.locator('#internal-blank').click() + await expect(window.locator('#two')).toHaveText('second-route') + expect(app.windows()).toHaveLength(1) + }) + + 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 00000000000..61d89460192 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,49 @@ +appId: ai.sim.desktop +productName: Sim +copyright: Copyright © 2026 Sim + +directories: + output: release + buildResources: build + +files: + - dist/** + - static/** + - package.json + +asar: true + +electronFuses: + runAsNode: false + enableCookieEncryption: true + enableNodeOptionsEnvironmentVariable: false + enableNodeCliInspectArguments: false + enableEmbeddedAsarIntegrityValidation: true + onlyLoadAppFromAsar: true + strictlyRequireAllFuses: true + +mac: + category: public.app-category.developer-tools + target: + - target: dmg + arch: [universal] + - target: zip + arch: [universal] + icon: build/icon.icns + hardenedRuntime: true + gatekeeperAssess: false + entitlements: build/entitlements.mac.plist + entitlementsInherit: build/entitlements.mac.plist + extendInfo: + NSMicrophoneUsageDescription: Sim uses your microphone for voice input to agents. + notarize: true + +dmg: + sign: false + +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 00000000000..8ef688944b0 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,44 @@ +{ + "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", + "type": "module", + "main": "dist/main.cjs", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "scripts": { + "build": "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", + "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": { + "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", + "@sim/utils": "workspace:*", + "electron-updater": "6.8.9" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@sim/tsconfig": "workspace:*", + "@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 00000000000..142b184e05d --- /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 00000000000..6115ebe2e4c --- /dev/null +++ b/apps/desktop/scripts/build.ts @@ -0,0 +1,41 @@ +import { build } from 'esbuild' + +const watch = process.argv.includes('--watch') + +const common = { + bundle: true, + platform: 'node' as const, + format: 'cjs' as const, + target: 'node22', + sourcemap: true, + external: ['electron'], + tsconfig: 'tsconfig.json', + logLevel: 'info' as const, +} + +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', + }) + await Promise.all([mainCtx.watch(), preloadCtx.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' }), + ]) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts new file mode 100644 index 00000000000..19469b1819d --- /dev/null +++ b/apps/desktop/src/main/config.test.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + 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') + + 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) + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe(DEFAULT_ORIGIN) + }) + + it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => { + const store = createConfigStore(tempSettingsPath(), { + SIM_DESKTOP_ORIGIN: 'http://evil.example', + }) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) +}) diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 00000000000..9a1000329e3 --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -0,0 +1,164 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { createLogger } from '@sim/logger' + +const logger = createLogger('DesktopConfig') + +export const DEFAULT_ORIGIN = 'https://sim.ai' + +/** Loopback hostnames that may use plain HTTP (dev + self-host testing). */ +export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +export interface WindowBounds { + x?: number + y?: number + width: number + height: number +} + +export interface DesktopSettings { + origin: string + windowBounds?: WindowBounds + zoomLevel?: number + lastRoute?: string + themeBackground?: 'dark' | 'light' + blockThirdPartyAnalytics?: boolean +} + +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:' && LOCAL_HOSTNAMES.has(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, +} + +export interface ConfigStore { + readonly filePath: string + getOrigin(): string + setOrigin(origin: string): OriginValidation + get(key: K): DesktopSettings[K] + set(key: K, value: DesktopSettings[K]): void +} + +/** + * 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 }) + } + + const save = () => { + try { + mkdirSync(dirname(filePath), { recursive: true }) + const tempPath = `${filePath}.tmp` + writeFileSync(tempPath, JSON.stringify(settings, null, 2), { mode: 0o600 }) + renameSync(tempPath, filePath) + } catch (error) { + logger.error('Failed to persist desktop settings', { error }) + } + } + + 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 + save() + } + return validated + }, + get(key) { + return settings[key] + }, + set(key, value) { + if (settings[key] === value) { + return + } + settings[key] = value + save() + }, + } +} 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 00000000000..9931eddd907 --- /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 00000000000..689da904371 --- /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/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 00000000000..c9cbad9ea0f --- /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 00000000000..863dc5e269c --- /dev/null +++ b/apps/desktop/src/main/handoff.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + buildRedeemScript, + createHandoffManager, + type HandoffCallback, + 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(), + ...overrides, + } +} + +describe('buildRedeemScript', () => { + it('embeds the token JSON-escaped and targets the verify endpoint', () => { + 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' }))) + }) +}) + +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, () => {}) + 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, () => {}) + 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))).toBe(false) + expect(manager.consume(state)).toBe(true) + expect(manager.consume(state)).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)).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, (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}`) + expect(ok.status).toBe(200) + expect(received).toEqual([{ token: VALID_TOKEN, state }]) + + await expect( + fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`) + ).rejects.toThrow() + }) + + 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, () => {}) + await manager.begin() + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + expect(manager.consume(state)).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts new file mode 100644 index 00000000000..f1b7791bce2 --- /dev/null +++ b/apps/desktop/src/main/handoff.ts @@ -0,0 +1,266 @@ +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' +// 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 + +const CALLBACK_RESPONSE_HTML = ` +Sim + +

You’re signed in — return to the Sim app. You can close this tab.

+` + +export interface HandoffCallback { + token: string + state: string +} + +export interface HandoffManagerDeps { + origin: () => string + openExternal: (url: string) => Promise + events: EventRecorder + now?: () => number +} + +export interface HandoffManager { + begin(): Promise + consume(state: string): boolean + clear(): void +} + +/** + * Owns the system-browser login handoff. 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-use, constant-time compared, + * TTL-bounded. There is no second delivery mechanism and nothing to persist. + */ +export function createHandoffManager( + deps: HandoffManagerDeps, + onCallback: (callback: HandoffCallback) => void +): HandoffManager { + const now = deps.now ?? Date.now + let loopbackServer: Server | null = null + let loopbackTimer: NodeJS.Timeout | undefined + let pending: { state: string; createdAt: number } | null = null + + const stopLoopback = () => { + clearTimeout(loopbackTimer) + loopbackTimer = undefined + if (loopbackServer) { + loopbackServer.close() + loopbackServer = null + } + } + + const startLoopback = async (): Promise => { + stopLoopback() + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (request.method !== 'GET' || url.pathname !== CALLBACK_PATH) { + response.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found') + return + } + const token = url.searchParams.get('token') ?? '' + const state = url.searchParams.get('state') ?? '' + if (!TOKEN_PATTERN.test(token) || !STATE_PATTERN.test(state)) { + response.writeHead(400, { 'Content-Type': 'text/plain' }).end('Invalid request') + return + } + response + .writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + .end(CALLBACK_RESPONSE_HTML) + stopLoopback() + onCallback({ token, state }) + }) + 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 + } + + return { + async begin() { + const state = generateShortId(STATE_LENGTH) + const port = await startLoopback() + if (!port) { + return false + } + pending = { state, createdAt: now() } + const landing = new URL('/desktop/auth', deps.origin()) + landing.searchParams.set('state', state) + landing.searchParams.set('port', String(port)) + deps.events.record('handoff_started') + const opened = await deps.openExternal(landing.toString()) + if (!opened) { + clear() + } + return opened + }, + consume(state: string) { + if (!pending) { + return false + } + if (now() - pending.createdAt > HANDOFF_TTL_MS) { + clear() + return false + } + if (!safeCompare(pending.state, state)) { + return false + } + clear() + return true + }, + clear, + } +} + +/** + * 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. + */ +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.ok + } catch { + return false + } +})()` +} + +/** + * Redeems a one-time token from the app-partition renderer. 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 false + } + const contents = win.webContents + if (!contents.getURL().startsWith(`${origin}/`)) { + try { + await win.loadURL(`${origin}/login`) + } catch { + return false + } + } + try { + const result = await contents.executeJavaScript(buildRedeemScript(token), true) + return result === true + } catch (error) { + logger.error('Token redeem failed', { error }) + return false + } +} + +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) => { + deps.events.record('handoff_redeem_fail', { reason }) + 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)) { + await failInWindow(win, 'state') + return + } + const origin = deps.origin() + if (!(await redeemToken(win, origin, callback.token))) { + await failInWindow(win, 'redeem') + return + } + deps.events.record('handoff_redeem_ok') + try { + await win.loadURL(`${origin}/workspace`) + } catch {} + win.show() + win.focus() + app.focus({ steal: true }) + }, + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 00000000000..3c98f1bd3e1 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,243 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { BrowserWindow } from 'electron' +import { app, net, session } from 'electron' +import { createConfigStore, partitionForOrigin } from '@/main/config' +import { attachContextMenu } from '@/main/context-menu' +import { attachDownloadHandling } from '@/main/downloads' +import { createAuthFlow, createHandoffManager } from '@/main/handoff' +import { registerIpcHandlers } from '@/main/ipc' +import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' +import { installApplicationMenu } from '@/main/menu' +import { openExternalSafe } from '@/main/navigation' +import { createEventLog } from '@/main/observability' +import { installGlobalGuards } from '@/main/security-guards' +import { + attachSessionLifecycle, + decideStartRoute, + handleConnectIntercept, + tearDownSession, +} from '@/main/session-lifecycle' +import { closeSettingsWindow, openSettingsWindow } from '@/main/settings-window' +import { attachTelemetryPolicy } from '@/main/telemetry-policy' +import { checkForUpdatesInteractive, initUpdater } 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' + +function main(): void { + app.enableSandbox() + + const config = createConfigStore(join(app.getPath('userData'), 'settings.json')) + const events = createEventLog(join(app.getPath('userData'), 'logs')) + const preloadPath = join(__dirname, 'preload.cjs') + + let mainWindow: BrowserWindow | null = null + let loadHealth: LoadHealthHandle | null = null + const configuredPartitions = new Set() + + const appOrigin = () => config.getOrigin() + const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://') + const getMainWindow = () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow : null) + + const handoff = createHandoffManager( + { + origin: appOrigin, + openExternal: (url) => openExternalSafe(url, allowHttpLocalhost()), + events, + }, + (callback) => void authFlow.handleCallback(callback) + ) + + const authFlow = createAuthFlow({ + handoff, + origin: appOrigin, + events, + ensureMainWindow: async () => { + let win = getMainWindow() + if (!win) { + await createAndLoadMainWindow() + win = getMainWindow() + } + if (!win) { + throw new Error('Main window unavailable') + } + return win + }, + }) + + 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) + attachDownloadHandling(ses, events) + attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) + ses.setSpellCheckerLanguages(['en-US']) + return ses + } + + async function createAndLoadMainWindow(): Promise { + const origin = appOrigin() + const ses = configureSessionForOrigin(origin) + const win = createMainWindow({ + config, + events, + appOrigin, + partition: partitionForOrigin(origin), + preloadPath, + isPackaged: app.isPackaged, + onClosed: () => { + mainWindow = null + }, + }) + mainWindow = win + attachWindowOpenPolicy(win.webContents, { + appOrigin, + getMainWindow, + allowHttpLocalhost: allowHttpLocalhost(), + }) + attachContextMenu(win.webContents, { + isDev: !app.isPackaged, + allowHttpLocalhost: allowHttpLocalhost(), + }) + loadHealth = attachLoadHealth(win, { + offlinePagePath: OFFLINE_PAGE, + getStartUrl: () => `${appOrigin()}${decideStartRoute('unknown', config.get('lastRoute'))}`, + isOnline: () => net.isOnline(), + events, + }) + attachSessionLifecycle(win, { + appSession: ses, + origin: appOrigin, + events, + clearHandoffState: () => handoff.clear(), + onReauthRequested: () => void authFlow.beginLoginHandoff(), + }) + loadHealth.startWatchdog() + const route = decideStartRoute('unknown', config.get('lastRoute')) + // 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(() => {}) + } + + function openSettings(): void { + openSettingsWindow({ preloadPath, isPackaged: app.isPackaged, getMainWindow }) + } + + async function applyOrigin(raw: string) { + const previousOrigin = appOrigin() + const result = config.setOrigin(raw) + if (!result.ok) { + return result + } + closeSettingsWindow() + if (result.origin === previousOrigin) { + return result + } + logger.info('Server origin changed; recreating window') + events.record('origin_changed') + handoff.clear() + const win = getMainWindow() + if (win) { + mainWindow = null + win.destroy() + } + await createAndLoadMainWindow() + return result + } + + async function signOutFromMenu(): Promise { + const ses = session.fromPartition(partitionForOrigin(appOrigin())) + await tearDownSession(ses, () => handoff.clear(), events) + const win = getMainWindow() + if (win) { + try { + await win.loadURL(`${appOrigin()}/login`) + } catch {} + } + } + + app.on('second-instance', () => { + const win = getMainWindow() + if (win) { + if (win.isMinimized()) { + win.restore() + } + win.focus() + } + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) + + app.on('activate', () => { + if (app.isReady() && !getMainWindow()) { + void createAndLoadMainWindow() + } + }) + + void app.whenReady().then(async () => { + events.record('app_launch', { + version: app.getVersion(), + electron: process.versions.electron ?? '', + }) + registerIpcHandlers({ + config, + appOrigin, + allowHttpLocalhost, + retryLoad: () => loadHealth?.retry(), + openSettings, + closeSettings: closeSettingsWindow, + applyOrigin, + }) + await createAndLoadMainWindow() + installApplicationMenu({ + isPackaged: app.isPackaged, + config, + getMainWindow, + allowHttpLocalhost, + openSettings, + signIn: () => void authFlow.beginLoginHandoff(), + signOut: () => void signOutFromMenu(), + checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events }), + eventLogPath: events.filePath, + }) + initUpdater({ getWindow: getMainWindow, events }) + }) +} + +// Identity and userData must be set before the single-instance lock, which +// writes its lock file into userData. Setting them here (not inside main) +// keeps the SIM_DESKTOP_ORIGIN/USER_DATA test overrides isolated per instance. +app.setName('Sim') +if (process.env.SIM_DESKTOP_USER_DATA) { + app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) +} + +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 00000000000..294b0c982e5 --- /dev/null +++ b/apps/desktop/src/main/ipc.test.ts @@ -0,0 +1,97 @@ +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 { ipcMain, shell } from 'electron' +import { createConfigStore } from '@/main/config' +import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' + +const APP = 'https://sim.ai' + +type Handler = (event: { senderFrame: { url: string } | null }, ...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 fileEvent = { senderFrame: { url: 'file:///app/static/settings.html' } } +const appEvent = { senderFrame: { url: `${APP}/workspace/ws1` } } +const evilEvent = { senderFrame: { url: 'https://evil.example/page' } } + +describe('registerIpcHandlers', () => { + let deps: IpcDeps + + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + vi.mocked(shell.openExternal).mockClear() + deps = { + config: createConfigStore(join(mkdtempSync(join(tmpdir(), 'sim-ipc-')), 's.json'), {}), + appOrigin: () => APP, + allowHttpLocalhost: () => false, + retryLoad: vi.fn(), + openSettings: vi.fn(), + closeSettings: vi.fn(), + applyOrigin: vi.fn(async () => ({ ok: true as const, origin: 'https://sim.ai' })), + } + 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 microphone consent to the app origin', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('desktop:request-mic-permission')?.(evilEvent)).toBe(false) + expect(await invoke.get('desktop:request-mic-permission')?.(fileEvent)).toBe(false) + expect(await invoke.get('desktop:request-mic-permission')?.(appEvent)).toBe(true) + }) + + it('restricts shell-control channels to bundled local pages', async () => { + const { invoke, on } = collectHandlers() + + on.get('offline:retry')?.(appEvent) + expect(deps.retryLoad).not.toHaveBeenCalled() + on.get('offline:retry')?.(fileEvent) + expect(deps.retryLoad).toHaveBeenCalledTimes(1) + + on.get('settings:open')?.(evilEvent) + expect(deps.openSettings).not.toHaveBeenCalled() + on.get('settings:open')?.(fileEvent) + expect(deps.openSettings).toHaveBeenCalledTimes(1) + + expect(await invoke.get('settings:get')?.(appEvent)).toBeNull() + expect(await invoke.get('settings:get')?.(fileEvent)).toEqual({ + origin: 'https://sim.ai', + isDefault: true, + }) + + expect(await invoke.get('settings:save')?.(appEvent, 'https://other.example')).toEqual({ + ok: false, + error: 'Not allowed', + }) + await invoke.get('settings:save')?.(fileEvent, 'https://other.example') + expect(deps.applyOrigin).toHaveBeenCalledWith('https://other.example') + }) + + it('handles a missing senderFrame safely', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('settings:get')?.({ senderFrame: null })).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 00000000000..be8ddce9b1d --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -0,0 +1,81 @@ +import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' +import { app, ipcMain } from 'electron' +import type { ConfigStore, OriginValidation } from '@/main/config' +import { DEFAULT_ORIGIN } from '@/main/config' +import { openExternalSafe } from '@/main/navigation' +import { ensureMicrophoneAccess } from '@/main/window' + +export interface IpcDeps { + config: ConfigStore + appOrigin: () => string + allowHttpLocalhost: () => boolean + retryLoad: () => void + openSettings: () => void + closeSettings: () => void + applyOrigin: (raw: string) => Promise +} + +function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + return (event.senderFrame?.url ?? '').startsWith('file:') +} + +function isAppOriginSender(event: IpcMainEvent | IpcMainInvokeEvent, appOrigin: string): boolean { + return (event.senderFrame?.url ?? '').startsWith(`${appOrigin}/`) +} + +/** + * Registers the whitelisted IPC surface. Shell-control channels (settings, + * offline retry) are restricted to bundled file: pages; microphone consent is + * restricted to the app origin. The remote origin can reach only harmless, + * validated channels. + */ +export function registerIpcHandlers(deps: IpcDeps): void { + ipcMain.handle('desktop:get-app-version', () => app.getVersion()) + + ipcMain.handle('desktop:open-external', (_event, url: unknown) => { + if (typeof url !== 'string') { + return false + } + return openExternalSafe(url, deps.allowHttpLocalhost()) + }) + + ipcMain.handle('desktop:request-mic-permission', (event) => { + if (!isAppOriginSender(event, deps.appOrigin())) { + return false + } + return ensureMicrophoneAccess() + }) + + ipcMain.on('offline:retry', (event) => { + if (isLocalPageSender(event)) { + deps.retryLoad() + } + }) + + ipcMain.on('settings:open', (event) => { + if (isLocalPageSender(event)) { + deps.openSettings() + } + }) + + ipcMain.on('settings:close', (event) => { + if (isLocalPageSender(event)) { + deps.closeSettings() + } + }) + + ipcMain.handle('settings:get', (event) => { + if (!isLocalPageSender(event)) { + return null + } + const origin = deps.config.getOrigin() + return { origin, isDefault: origin === DEFAULT_ORIGIN } + }) + + ipcMain.handle('settings:save', async (event, raw: unknown) => { + if (!isLocalPageSender(event) || typeof raw !== 'string') { + return { ok: false, error: 'Not allowed' } + } + return deps.applyOrigin(raw) + }) +} 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 00000000000..e313d0a6533 --- /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 00000000000..7b54635e000 --- /dev/null +++ b/apps/desktop/src/main/load-health.ts @@ -0,0 +1,151 @@ +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 retry = () => { + if (win.isDestroyed()) { + return + } + const target = intendedUrl ?? deps.getStartUrl() + logger.info('Retrying load', { url: scrubUrl(target) }) + 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() { + clearTimeout(watchdogTimer) + watchdogTimer = setTimeout(() => { + if (!win.isDestroyed() && win.webContents.isLoading()) { + showOffline('timeout', 'The app took too long to load') + } + }, LOAD_WATCHDOG_MS) + }, + } +} diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts new file mode 100644 index 00000000000..49a826d5f04 --- /dev/null +++ b/apps/desktop/src/main/menu.ts @@ -0,0 +1,122 @@ +import type { BrowserWindow, MenuItemConstructorOptions } from 'electron' +import { app, Menu, shell } 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 { + isPackaged: boolean + config: ConfigStore + getMainWindow: () => BrowserWindow | null + allowHttpLocalhost: () => boolean + openSettings: () => void + signIn: () => void + signOut: () => void + checkForUpdates: () => void + eventLogPath: string +} + +/** + * 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: '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' }, + ] + if (!deps.isPackaged) { + viewSubmenu.push({ role: 'toggleDevTools' }, { type: 'separator' }) + } + viewSubmenu.push({ role: 'togglefullscreen' }) + + return [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { label: 'Check for Updates…', click: deps.checkForUpdates }, + { type: 'separator' }, + { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { label: 'File', submenu: [{ role: 'close' }] }, + { role: 'editMenu' }, + { + label: 'Account', + submenu: [ + { label: 'Sign In with Browser…', click: deps.signIn }, + { label: 'Sign Out', click: deps.signOut }, + ], + }, + { label: 'View', submenu: viewSubmenu }, + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Sim Documentation', + click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), + }, + { + label: 'Service Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + { type: 'separator' }, + { + label: 'Show Logs in Finder', + click: () => shell.showItemInFolder(deps.eventLogPath), + }, + ], + }, + ] +} + +/** + * 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 00000000000..7a843589fbf --- /dev/null +++ b/apps/desktop/src/main/navigation.test.ts @@ -0,0 +1,245 @@ +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('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 00000000000..d24f6a260f4 --- /dev/null +++ b/apps/desktop/src/main/navigation.ts @@ -0,0 +1,227 @@ +import { createLogger } from '@sim/logger' +import { shell } from 'electron' +import { LOCAL_HOSTNAMES } from '@/main/config' + +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-' + +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 popups + * collapse into the single main window; 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' + } + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX)) { + 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 && LOCAL_HOSTNAMES.has(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: raw.slice(0, 200) }) + 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 00000000000..cd3e3cf2147 --- /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 00000000000..bbca0dc4509 --- /dev/null +++ b/apps/desktop/src/main/observability.ts @@ -0,0 +1,76 @@ +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_downloaded' + | 'update_error' + | 'update_blocked_version' + | 'handoff_redeem_ok' + | 'handoff_redeem_fail' + | 'load_failure' + | 'renderer_gone' + | 'renderer_unresponsive' + | 'session_expired' + | 'sign_out' + | 'origin_changed' + | 'handoff_started' + +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 00000000000..6e0b3830ef6 --- /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 00000000000..6a3133544a6 --- /dev/null +++ b/apps/desktop/src/main/security-guards.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import type { WebContents } from 'electron' +import { app } from 'electron' +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) => { + 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 — gets webview blocking, packaged DevTools + * lockdown, a default-deny window.open handler (specific policies overwrite + * it), and the navigation classifier. 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 00000000000..17b480e6dbd --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { Session } from 'electron' +import { + decideStartRoute, + isLogoutNavigation, + isSessionCookieName, + probeSession, +} 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('routes a signed-out launch to the login surface', () => { + expect(decideStartRoute('invalid', '/workspace/ws1')).toBe('/login') + }) + + it('restores the last route when plausible', () => { + expect(decideStartRoute('valid', '/workspace/ws1?tab=logs')).toBe('/workspace/ws1?tab=logs') + expect(decideStartRoute('unknown', '/workspace/ws1')).toBe('/workspace/ws1') + }) + + it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => { + expect(decideStartRoute('valid', undefined)).toBe('/workspace') + expect(decideStartRoute('valid', '//evil.example')).toBe('/workspace') + expect(decideStartRoute('valid', '/login')).toBe('/workspace') + }) +}) + +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`) + }) +}) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts new file mode 100644 index 00000000000..b3122e394dc --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -0,0 +1,242 @@ +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 EXPIRY_PROMPT_COOLDOWN_MS = 30_000 +const SESSION_PROBE_TIMEOUT_MS = 5000 +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: a known-signed-out session goes straight + * to the login surface, otherwise the last visited route (when safe and not + * itself an auth surface), falling back to /workspace. + */ +export function decideStartRoute( + sessionState: SessionProbeResult, + lastRoute: string | undefined +): string { + if (sessionState === 'invalid') { + return '/login' + } + if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) { + return lastRoute + } + return '/workspace' +} + +/** + * 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 { + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const response = await session.fetch(`${origin}/api/auth/get-session`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + clearTimeout(timer) + 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' + } +} + +/** + * Clears every session-bearing storage in the app partition plus any pending + * handoff secrets — the desktop analogue of a browser profile sign-out. + */ +export async function tearDownSession( + session: Session, + clearHandoffState: () => void, + events: EventRecorder +): Promise { + events.record('sign_out') + clearHandoffState() + await session.clearStorageData({ storages: [...CLEARED_STORAGES] }) +} + +export interface SessionLifecycleDeps { + appSession: Session + origin: () => string + events: EventRecorder + clearHandoffState: () => void + onReauthRequested: () => void +} + +/** + * Watches the session after login: web sign-out triggers a full partition + * teardown, and 401s from the app API (confirmed by a session probe) surface + * a native "session expired" prompt that reruns the browser handoff. + */ +export function attachSessionLifecycle(win: BrowserWindow, deps: SessionLifecycleDeps): void { + let tearingDown = false + const runTeardown = () => { + if (tearingDown || win.isDestroyed()) { + return + } + tearingDown = true + logger.info('Sign-out detected; clearing partition') + void tearDownSession(deps.appSession, deps.clearHandoffState, deps.events) + .catch((error) => logger.error('Session teardown failed', { error })) + .finally(() => { + 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) + }) + } + + 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)) + + // 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() + } + }) + }) + + let lastExpiryPromptAt = 0 + deps.appSession.webRequest.onCompleted({ urls: [`${deps.origin()}/api/*`] }, (details) => { + if (details.statusCode !== 401 || details.url.includes('/api/auth/')) { + return + } + const nowTs = Date.now() + if (nowTs - lastExpiryPromptAt < EXPIRY_PROMPT_COOLDOWN_MS) { + return + } + lastExpiryPromptAt = nowTs + void probeSession(deps.appSession, deps.origin()).then((state) => { + if (state !== 'invalid' || win.isDestroyed()) { + return + } + deps.events.record('session_expired') + void dialog + .showMessageBox(win, { + type: 'info', + buttons: ['Sign In', 'Not Now'], + defaultId: 0, + cancelId: 1, + message: 'Your session has expired', + detail: 'Sign in again to keep working.', + }) + .then(({ response }) => { + if (response === 0) { + deps.onReauthRequested() + } + }) + }) + }) +} + +/** + * 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/settings-window.ts b/apps/desktop/src/main/settings-window.ts new file mode 100644 index 00000000000..5337bd466e5 --- /dev/null +++ b/apps/desktop/src/main/settings-window.ts @@ -0,0 +1,50 @@ +import { BrowserWindow } from 'electron' +import { createSecureWebPreferences } from '@/main/window' + +const SETTINGS_PAGE = 'static/settings.html' + +let settingsWindow: BrowserWindow | null = null + +export interface SettingsWindowDeps { + preloadPath: string + isPackaged: boolean + getMainWindow: () => BrowserWindow | null +} + +/** + * Opens (or focuses) the small local settings window for the server origin. + * It loads a bundled file, never remote content, and shares no partition with + * the app session. + */ +export function openSettingsWindow(deps: SettingsWindowDeps): void { + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.focus() + return + } + const parent = deps.getMainWindow() ?? undefined + settingsWindow = new BrowserWindow({ + title: 'Sim Settings', + width: 460, + height: 290, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + show: false, + parent, + webPreferences: createSecureWebPreferences('settings', deps.preloadPath, deps.isPackaged), + }) + settingsWindow.once('ready-to-show', () => { + settingsWindow?.show() + }) + settingsWindow.on('closed', () => { + settingsWindow = null + }) + void settingsWindow.loadFile(SETTINGS_PAGE) +} + +export function closeSettingsWindow(): void { + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.close() + } +} 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 00000000000..29b0cd2f6a1 --- /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 00000000000..b79411f61fd --- /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/updater.test.ts b/apps/desktop/src/main/updater.test.ts new file mode 100644 index 00000000000..a8c83013356 --- /dev/null +++ b/apps/desktop/src/main/updater.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { isDowngrade, isVersionBlocked, 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('isVersionBlocked', () => { + it('matches with and without the v prefix', () => { + expect(isVersionBlocked('0.5.24', ['v0.5.24'])).toBe(true) + expect(isVersionBlocked('v0.5.24', ['0.5.24'])).toBe(true) + expect(isVersionBlocked('0.5.25', ['0.5.24'])).toBe(false) + expect(isVersionBlocked('0.5.25', [])).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 00000000000..b3efd941775 --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,231 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { BrowserWindow } from 'electron' +import { app, dialog } 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' + +/** + * Maps the running version to its update channel: prerelease builds follow + * their prerelease channel, stable builds only ever see stable releases. + */ +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 +} + +/** + * The blockedVersions kill-switch: a known-bad release is never offered for + * install even if the feed still serves it. + */ +export function isVersionBlocked(version: string, blockedVersions: readonly string[]): boolean { + const normalized = version.replace(/^v/, '') + return blockedVersions.some((blocked) => blocked.replace(/^v/, '') === normalized) +} + +export interface UpdaterDeps { + getWindow: () => BrowserWindow | null + events: EventRecorder + blockedVersions?: readonly string[] +} + +/** + * Wires electron-updater against the GitHub Releases feed: channel-scoped + * checks on launch and every four hours, delta downloads in the background, + * and install only on user confirmation — never mid-session without consent. + */ +export function initUpdater(deps: UpdaterDeps): void { + if (!app.isPackaged) { + return + } + let autoUpdater: typeof import('electron-updater')['autoUpdater'] + try { + ;({ autoUpdater } = require('electron-updater') as typeof import('electron-updater')) + } catch (error) { + logger.error('electron-updater unavailable', { error }) + return + } + + const currentVersion = app.getVersion() + autoUpdater.channel = resolveUpdateChannel(currentVersion) + autoUpdater.allowDowngrade = false + autoUpdater.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('update-available', (info) => { + deps.events.record('update_check', { available: info.version }) + }) + + autoUpdater.on('update-downloaded', (info) => { + if ( + isDowngrade(currentVersion, info.version) || + isVersionBlocked(info.version, deps.blockedVersions ?? []) + ) { + autoUpdater.autoInstallOnAppQuit = false + deps.events.record('update_blocked_version', { version: info.version }) + return + } + autoUpdater.autoInstallOnAppQuit = true + deps.events.record('update_downloaded', { version: info.version }) + const win = deps.getWindow() + win?.webContents.send('desktop:update-status', { state: 'ready', version: info.version }) + 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') }) + }) + + const check = () => { + autoUpdater.checkForUpdates().catch((error) => { + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + }) + } + setTimeout(check, INITIAL_CHECK_DELAY_MS) + setInterval(check, CHECK_INTERVAL_MS) +} + +/** + * Menu-triggered manual check with user-visible feedback. + */ +export function checkForUpdatesInteractive(deps: UpdaterDeps): void { + if (!app.isPackaged) { + void dialog.showMessageBox({ + type: 'info', + message: 'Updates are only available in packaged builds', + }) + return + } + deps.events.record('update_check', { manual: true }) + try { + const { autoUpdater } = require('electron-updater') as typeof import('electron-updater') + void autoUpdater.checkForUpdates().then((result) => { + if (!result || result.updateInfo.version === app.getVersion()) { + void dialog.showMessageBox({ type: 'info', message: 'Sim is up to date' }) + } + }) + } catch (error) { + logger.error('Manual update check failed', { error }) + } +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts new file mode 100644 index 00000000000..41ecc3dc265 --- /dev/null +++ b/apps/desktop/src/main/window.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + backgroundColorFor, + createSecureWebPreferences, + resolvePermission, + sanitizeBounds, +} from '@/main/window' + +const APP = 'https://sim.ai' + +describe('resolvePermission', () => { + it('allows audio capture from the trusted origin only', () => { + expect(resolvePermission('media', APP, APP, ['audio'])).toBe(true) + expect(resolvePermission('media', 'https://evil.example', APP, ['audio'])).toBe(false) + expect(resolvePermission('media', '', APP, ['audio'])).toBe(false) + }) + + it('denies any video capture', () => { + expect(resolvePermission('media', APP, APP, ['video'])).toBe(false) + expect(resolvePermission('media', APP, APP, ['audio', 'video'])).toBe(false) + }) + + it('default-denies media when the request carries no explicit audio type', () => { + expect(resolvePermission('media', APP, APP)).toBe(false) + expect(resolvePermission('media', APP, APP, [])).toBe(false) + expect(resolvePermission('media', APP, APP, ['unknown'])).toBe(false) + }) + + 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) + }) + + it('default-denies everything else, including unknown future permissions', () => { + for (const permission of [ + '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) + }) +}) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts new file mode 100644 index 00000000000..9abeddcbe7d --- /dev/null +++ b/apps/desktop/src/main/window.ts @@ -0,0 +1,360 @@ +import { createLogger } from '@sim/logger' +import type { Session, WebPreferences } from 'electron' +import { app, BrowserWindow, dialog, nativeTheme, systemPreferences } 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 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. + */ +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, + } +} + +/** + * The permission matrix: microphone capture and sanitized clipboard writes for + * the trusted app origin, default-deny for everything else including unknown + * future permissions. Voice STT uses getUserMedia (audio) — camera stays + * denied. + */ +export function resolvePermission( + permission: string, + requestingOrigin: string, + appOrigin: string, + mediaTypes?: readonly string[] +): boolean { + if (!requestingOrigin || requestingOrigin !== appOrigin) { + return false + } + if (permission === 'media') { + // Default-deny: grant only when the request explicitly asks for audio + // (and nothing else). An absent/empty mediaTypes must not fall through to + // a grant — that would allow camera capture, which is denied by policy. + return ( + mediaTypes !== undefined && + mediaTypes.length > 0 && + mediaTypes.every((type) => type === 'audio') + ) + } + return permission === 'clipboard-sanitized-write' +} + +function originOf(raw: string): string { + try { + return new URL(raw).origin + } catch { + return '' + } +} + +/** + * Resolves macOS TCC microphone consent, prompting on first use. Returns the + * final grant state; on non-darwin platforms capture needs no OS consent. + */ +export async function ensureMicrophoneAccess( + platform: NodeJS.Platform = process.platform +): Promise { + if (platform !== 'darwin') { + return true + } + const status = systemPreferences.getMediaAccessStatus('microphone') + if (status === 'granted') { + return true + } + if (status === 'denied' || status === 'restricted') { + return false + } + try { + return await systemPreferences.askForMediaAccess('microphone') + } catch (error) { + logger.error('Microphone access prompt failed', { error }) + return false + } +} + +/** + * Installs both permission handlers (request + check) on a session from the + * shared permission matrix, wiring macOS microphone TCC consent into media + * grants. + */ +export function setupPermissionHandlers( + session: Session, + getAppOrigin: () => string, + platform: NodeJS.Platform = process.platform +): void { + session.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = details.requestingUrl || webContents?.getURL() || '' + const mediaTypes = 'mediaTypes' in details ? details.mediaTypes : undefined + const allowed = resolvePermission( + permission, + originOf(requestingUrl), + getAppOrigin(), + mediaTypes + ) + if (!allowed) { + callback(false) + return + } + if (permission === 'media') { + void ensureMicrophoneAccess(platform).then(callback) + return + } + callback(true) + }) + + session.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + const mediaType = 'mediaType' in details ? details.mediaType : undefined + return resolvePermission( + permission, + originOf(requestingOrigin), + getAppOrigin(), + mediaType ? [mediaType] : undefined + ) + }) +} + +/** + * 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 +} + +/** + * 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 win = new BrowserWindow({ + title: 'Sim', + width: bounds?.width ?? DEFAULT_WIDTH, + height: bounds?.height ?? DEFAULT_HEIGHT, + x: bounds?.x, + y: bounds?.y, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + 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.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 }) + 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 00000000000..8d84321e0e3 --- /dev/null +++ b/apps/desktop/src/main/windows.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { BrowserWindow, 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 +} + +function makeMainWindow() { + return { + isDestroyed: () => false, + loadURL: vi.fn(() => Promise.resolve()), + focus: vi.fn(), + } as unknown as BrowserWindow +} + +describe('attachWindowOpenPolicy', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + function setup() { + const contents = makeContents() + const main = makeMainWindow() + attachWindowOpenPolicy(contents as unknown as WebContents, { + appOrigin: () => APP, + getMainWindow: () => main, + allowHttpLocalhost: false, + }) + return { contents, main } + } + + 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('collapses internal new-tab opens into the main window', () => { + const { contents, main } = setup() + const result = contents.handler?.({ url: `${APP}/workspace/ws1/w/wf1`, frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(main.loadURL).toHaveBeenCalledWith(`${APP}/workspace/ws1/w/wf1`) + expect(main.focus).toHaveBeenCalled() + }) + + 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, main } = setup() + const result = contents.handler?.({ url: 'javascript:alert(1)', frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(shell.openExternal).not.toHaveBeenCalled() + expect(main.loadURL).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 00000000000..37b9b29e0b9 --- /dev/null +++ b/apps/desktop/src/main/windows.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow, WebContents } from 'electron' +import { + classifyBlankChildNavigation, + classifyWindowOpen, + openExternalSafe, +} from '@/main/navigation' + +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 + getMainWindow: () => BrowserWindow | null + allowHttpLocalhost: boolean +} + +/** + * Applies the window.open routing policy to a WebContents. Internal "new tab" + * opens collapse into the main window (single-window policy); 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': { + const main = deps.getMainWindow() + if (main && !main.isDestroyed()) { + void main.loadURL(details.url) + main.focus() + } + return { action: 'deny' } + } + case 'external': + void openExternalSafe(details.url, deps.allowHttpLocalhost) + return { action: 'deny' } + default: + logger.warn('Denied window.open', { url: details.url.slice(0, 200) }) + 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 + * collapse into the main 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') { + const main = deps.getMainWindow() + if (main && !main.isDestroyed()) { + void main.loadURL(url) + main.focus() + } + } else if (action === 'external') { + void openExternalSafe(url, deps.allowHttpLocalhost) + } + if (!child.isDestroyed()) { + child.close() + } + }) +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 00000000000..5b54439efe4 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,43 @@ +import { contextBridge, ipcRenderer } from 'electron' + +export interface UpdateStatus { + state: string + version?: string +} + +/** + * The minimal bridge exposed to pages. The remote web app reads no Electron + * global today; this surface exists for the bundled offline/settings pages + * and future opt-in web integration. Every channel is validated and gated in + * the main process — nothing here grants page code any privilege by itself. + */ +const api = { + getAppVersion: (): Promise => ipcRenderer.invoke('desktop:get-app-version'), + openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url), + requestMicrophonePermission: (): Promise => + ipcRenderer.invoke('desktop:request-mic-permission'), + onUpdateStatus: (callback: (status: UpdateStatus) => void): (() => void) => { + const listener = (_event: unknown, status: UpdateStatus) => callback(status) + ipcRenderer.on('desktop:update-status', listener) + return () => { + ipcRenderer.removeListener('desktop:update-status', listener) + } + }, + offlineRetry: (): void => { + ipcRenderer.send('offline:retry') + }, + openSettings: (): void => { + ipcRenderer.send('settings:open') + }, + settingsClose: (): void => { + ipcRenderer.send('settings:close') + }, + settingsGet: (): Promise<{ origin: string; isDefault: boolean } | null> => + ipcRenderer.invoke('settings:get'), + settingsSave: (origin: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('settings:save', origin), +} + +export type SimDesktopApi = typeof api + +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 00000000000..9be3a36ed02 --- /dev/null +++ b/apps/desktop/src/test/electron-mock.ts @@ -0,0 +1,99 @@ +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 = { + isPackaged: false, + getVersion: vi.fn(() => '1.0.0'), + getName: vi.fn(() => 'Sim'), + setName: vi.fn(), + getPath: vi.fn(() => '/tmp/sim-desktop-test'), + 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()), + dock: { downloadFinished: 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), +} + +export const clipboard = { + writeText: vi.fn(), +} + +export const systemPreferences = { + getMediaAccessStatus: vi.fn(() => 'granted'), + askForMediaAccess: vi.fn(() => Promise.resolve(true)), +} + +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 class BrowserWindow { + static fromWebContents = vi.fn(() => null) + webContents = { + on: vi.fn(), + getURL: vi.fn(() => ''), + loadURL: vi.fn(() => Promise.resolve()), + reload: vi.fn(), + setZoomLevel: vi.fn(), + getZoomLevel: vi.fn(() => 0), + executeJavaScript: vi.fn(() => Promise.resolve(true)), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() }, + } + on = vi.fn() + once = vi.fn() + isDestroyed = vi.fn(() => false) + isMinimized = vi.fn(() => false) + isFullScreen = vi.fn(() => false) + isMaximized = vi.fn(() => false) + getNormalBounds = vi.fn(() => ({ x: 0, y: 0, width: 1360, height: 860 })) + loadURL = vi.fn(() => Promise.resolve()) + loadFile = vi.fn(() => Promise.resolve()) + focus = vi.fn() + show = vi.fn() + close = vi.fn() + destroy = vi.fn() + restore = vi.fn() +} diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html new file mode 100644 index 00000000000..a87ff6671da --- /dev/null +++ b/apps/desktop/static/offline.html @@ -0,0 +1,177 @@ + + + + + + Sim — Can’t connect + + + +
+
S
+

Can’t connect to Sim

+

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

+
+ + +
+ +
+
+ + + diff --git a/apps/desktop/static/settings.html b/apps/desktop/static/settings.html new file mode 100644 index 00000000000..ed845da3f48 --- /dev/null +++ b/apps/desktop/static/settings.html @@ -0,0 +1,151 @@ + + + + + + Sim — Settings + + + +

Server

+ + +
+ Point Sim at a self-hosted instance. Must be HTTPS (HTTP is allowed for localhost only). + Changing the server keeps a separate sign-in per server. +
+
+
+ + +
+ + + diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000000..77d5b0963ea --- /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 00000000000..9718634dfc7 --- /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/sim/app/desktop/auth/page.test.tsx b/apps/sim/app/desktop/auth/page.test.tsx new file mode 100644 index 00000000000..691f75cd976 --- /dev/null +++ b/apps/sim/app/desktop/auth/page.test.tsx @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGenerateOneTimeToken, mockRedirect } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGenerateOneTimeToken: vi.fn(), + mockRedirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { generateOneTimeToken: mockGenerateOneTimeToken, getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})) + +import DesktopAuthPage from '@/app/desktop/auth/page' + +const VALID_STATE = 'a'.repeat(32) + +function pageProps(params: Record) { + return { searchParams: Promise.resolve(params) } +} + +describe('DesktopAuthPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGenerateOneTimeToken.mockResolvedValue({ token: 'tok123456' }) + }) + + it('rejects a missing/malformed state or missing port without minting a token', async () => { + const invalid = [ + {}, + { state: 'short', port: '54321' }, + { state: 'bad state!bad state!', port: '54321' }, + { state: VALID_STATE }, + { state: VALID_STATE, port: '80' }, + ] + for (const params of invalid) { + const result = await DesktopAuthPage(pageProps(params)) + expect((result as { type: { name: string } }).type.name).toBe('InvalidRequest') + } + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockGenerateOneTimeToken).not.toHaveBeenCalled() + }) + + it('redirects a signed-out browser to login with itself as callbackUrl', async () => { + mockGetSession.mockResolvedValue(null) + await expect(DesktopAuthPage(pageProps({ state: VALID_STATE, port: '54321' }))).rejects.toThrow( + `NEXT_REDIRECT:/login?callbackUrl=${encodeURIComponent( + `/desktop/auth?state=${VALID_STATE}&port=54321` + )}` + ) + expect(mockGenerateOneTimeToken).not.toHaveBeenCalled() + }) + + it('mints a token and redirects straight to the 127.0.0.1 loopback callback', async () => { + await expect(DesktopAuthPage(pageProps({ state: VALID_STATE, port: '54321' }))).rejects.toThrow( + `NEXT_REDIRECT:http://127.0.0.1:54321/auth/callback?token=tok123456&state=${VALID_STATE}` + ) + }) + + it('redirects to login when minting fails', async () => { + mockGenerateOneTimeToken.mockRejectedValue(new Error('UNAUTHORIZED')) + await expect(DesktopAuthPage(pageProps({ state: VALID_STATE, port: '54321' }))).rejects.toThrow( + 'NEXT_REDIRECT:/login' + ) + }) +}) diff --git a/apps/sim/app/desktop/auth/page.tsx b/apps/sim/app/desktop/auth/page.tsx new file mode 100644 index 00000000000..cb765e87d2c --- /dev/null +++ b/apps/sim/app/desktop/auth/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { auth, getSession } from '@/lib/auth' +import { + buildDesktopAuthPath, + buildLoopbackUrl, + isValidHandoffState, + parseLoopbackPort, +} from '@/app/desktop/auth/validation' + +export const metadata: Metadata = { + title: 'Sign in to Sim Desktop', + robots: { index: false }, +} + +export const dynamic = 'force-dynamic' + +interface DesktopAuthPageProps { + searchParams: Promise> +} + +function InvalidRequest() { + return ( +
+
+

Sign-in link incomplete

+

+ Open the Sim desktop app and choose Sign In to start again. +

+
+
+ ) +} + +/** + * Desktop login landing. The desktop app opens this page in the system browser + * with a one-time state and the port of a local 127.0.0.1 loopback listener. + * Once the browser session is authenticated it mints a better-auth one-time + * token and redirects straight to the loopback (RFC 8252 §7.3) — a single, + * deterministic hand-back with no OS scheme registration and no client-side + * step. The app redeems the token same-origin against + * /api/auth/one-time-token/verify, which sets the session cookie in its + * partition. + */ +export default async function DesktopAuthPage({ searchParams }: DesktopAuthPageProps) { + const params = await searchParams + const state = typeof params.state === 'string' ? params.state : '' + const port = parseLoopbackPort(typeof params.port === 'string' ? params.port : '') + if (!isValidHandoffState(state) || port === null) { + return + } + + const session = await getSession() + if (!session?.user) { + redirect(`/login?callbackUrl=${encodeURIComponent(buildDesktopAuthPath(state, port))}`) + } + + let token: string | null = null + try { + const response = await auth.api.generateOneTimeToken({ headers: await headers() }) + token = response?.token ?? null + } catch { + token = null + } + if (!token) { + redirect(`/login?callbackUrl=${encodeURIComponent(buildDesktopAuthPath(state, port))}`) + } + + redirect(buildLoopbackUrl(token, state, port)) +} diff --git a/apps/sim/app/desktop/auth/validation.test.ts b/apps/sim/app/desktop/auth/validation.test.ts new file mode 100644 index 00000000000..23899f74475 --- /dev/null +++ b/apps/sim/app/desktop/auth/validation.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildDesktopAuthPath, + buildLoopbackUrl, + isValidHandoffState, + parseLoopbackPort, +} from '@/app/desktop/auth/validation' + +describe('isValidHandoffState', () => { + it('accepts URL-safe high-entropy states', () => { + expect(isValidHandoffState('a'.repeat(32))).toBe(true) + expect(isValidHandoffState('Ab0_-'.repeat(4))).toBe(true) + }) + + it('rejects short, oversized, malformed, and non-string values', () => { + expect(isValidHandoffState('a'.repeat(15))).toBe(false) + expect(isValidHandoffState('a'.repeat(257))).toBe(false) + expect(isValidHandoffState('bad state!'.repeat(3))).toBe(false) + expect(isValidHandoffState(undefined)).toBe(false) + expect(isValidHandoffState(42)).toBe(false) + }) +}) + +describe('parseLoopbackPort', () => { + it('accepts non-privileged ports', () => { + expect(parseLoopbackPort('1024')).toBe(1024) + expect(parseLoopbackPort('54321')).toBe(54321) + expect(parseLoopbackPort('65535')).toBe(65535) + }) + + it('rejects privileged, out-of-range, and malformed ports', () => { + expect(parseLoopbackPort('80')).toBeNull() + expect(parseLoopbackPort('65536')).toBeNull() + expect(parseLoopbackPort('-1')).toBeNull() + expect(parseLoopbackPort('12a4')).toBeNull() + expect(parseLoopbackPort('')).toBeNull() + expect(parseLoopbackPort(undefined)).toBeNull() + }) +}) + +describe('URL builders', () => { + const state = 's'.repeat(32) + + it('rebuilds the landing path from validated params only', () => { + expect(buildDesktopAuthPath(state, 54321)).toBe(`/desktop/auth?state=${state}&port=54321`) + expect(buildDesktopAuthPath(state, null)).toBe(`/desktop/auth?state=${state}`) + }) + + it('builds the loopback callback URL on the 127.0.0.1 IP literal', () => { + expect(buildLoopbackUrl('tok123456', state, 54321)).toBe( + `http://127.0.0.1:54321/auth/callback?token=tok123456&state=${state}` + ) + }) +}) diff --git a/apps/sim/app/desktop/auth/validation.ts b/apps/sim/app/desktop/auth/validation.ts new file mode 100644 index 00000000000..7559946615e --- /dev/null +++ b/apps/sim/app/desktop/auth/validation.ts @@ -0,0 +1,55 @@ +const STATE_PATTERN = /^[A-Za-z0-9_-]{16,256}$/ +const PORT_PATTERN = /^\d{1,5}$/ +const MIN_EPHEMERAL_PORT = 1024 +const MAX_PORT = 65535 + +/** + * Validates the desktop handoff state parameter: a high-entropy URL-safe + * value generated by the desktop app. Anything else is rejected before a + * token is minted. + */ +export function isValidHandoffState(value: unknown): value is string { + return typeof value === 'string' && STATE_PATTERN.test(value) +} + +/** + * Parses the loopback fallback port the desktop app is listening on. + * Only non-privileged ports are accepted. + */ +export function parseLoopbackPort(value: unknown): number | null { + if (typeof value !== 'string' || !PORT_PATTERN.test(value)) { + return null + } + const port = Number(value) + if (port < MIN_EPHEMERAL_PORT || port > MAX_PORT) { + return null + } + return port +} + +/** + * Rebuilds this page's own path from validated parameters, for use as the + * post-login callbackUrl. + */ +export function buildDesktopAuthPath(state: string, port: number | null): string { + const params = new URLSearchParams({ state }) + if (port !== null) { + params.set('port', String(port)) + } + return `/desktop/auth?${params.toString()}` +} + +/** + * Builds the loopback callback URL on the `127.0.0.1` IP literal (RFC 8252 + * §7.3 — not `localhost`, which could bind other interfaces). Loopback is the + * single hand-back channel: it needs no OS scheme registration, so it works + * identically in dev and packaged builds. It is itself interceptable per RFC + * 8252 §8.10; the mitigation is the equivalent of PKCE — a single-use, + * short-TTL token bound to a 128-bit state the app compares in constant time. + * (RFC's top choice, claimed-`https` universal links, is a future enhancement — + * it needs an associated-domains entitlement + apple-app-site-association.) + */ +export function buildLoopbackUrl(token: string, state: string, port: number): string { + const params = new URLSearchParams({ token, state }) + return `http://127.0.0.1:${port}/auth/callback?${params.toString()}` +} diff --git a/biome.json b/biome.json index 271e701c8b4..77f2e832bc1 100644 --- a/biome.json +++ b/biome.json @@ -30,7 +30,9 @@ "!**/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 eaee1d6f50f..e6e8204c66b 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,26 @@ "yaml": "^2.8.1", }, }, + "apps/desktop": { + "name": "@sim/desktop", + "version": "0.0.0", + "dependencies": { + "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", + "@sim/utils": "workspace:*", + "electron-updater": "6.8.9", + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@sim/tsconfig": "workspace:*", + "@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", @@ -958,6 +978,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.0.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-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], + + "@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=="], @@ -968,57 +1006,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=="], @@ -1128,6 +1166,10 @@ "@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=="], + "@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=="], @@ -1318,10 +1360,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=="], @@ -1568,6 +1620,8 @@ "@sim/db": ["@sim/db@workspace:packages/db"], + "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], + "@sim/emcn": ["@sim/emcn@workspace:packages/emcn"], "@sim/logger": ["@sim/logger@workspace:packages/logger"], @@ -1596,6 +1650,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=="], @@ -1690,6 +1746,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=="], @@ -1838,6 +1896,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/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cookie": ["@types/cookie@0.4.1", "", {}, "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="], @@ -1916,12 +1976,16 @@ "@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/inquirer": ["@types/inquirer@8.2.13", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-shSvl3mn4Z8AK627kA1vx8PYkyH6CdIjV5NYYj7a0xIxzmG3ZgzEpzCi3CWfktjAlq+0Z0wHJGtWNiACaYpeOg=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -1930,6 +1994,8 @@ "@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=="], @@ -1952,6 +2018,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@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=="], @@ -2074,6 +2142,8 @@ "@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=="], @@ -2106,6 +2176,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=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -2120,6 +2192,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=="], @@ -2128,14 +2202,20 @@ "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "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=="], "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], "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=="], @@ -2172,6 +2252,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=="], @@ -2194,12 +2276,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=="], @@ -2238,6 +2330,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.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -2260,6 +2356,8 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "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=="], @@ -2284,6 +2382,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=="], "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], @@ -2320,6 +2420,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=="], @@ -2446,6 +2548,12 @@ "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "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=="], @@ -2462,6 +2570,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=="], @@ -2474,8 +2584,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"], @@ -2498,6 +2612,8 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "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=="], @@ -2506,6 +2622,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=="], + "e2b": ["e2b@2.30.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-4kvfwh3QfPukrYmWEhrVLxL3WnQabzHabvhIRmvk6oU/YTWQtCrlZX+jaA9XBtVI/vQUbu5E5a6GlOhDXmcKzg=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -2518,8 +2636,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=="], @@ -2546,6 +2678,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=="], @@ -2558,11 +2692,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=="], @@ -2610,6 +2746,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=="], @@ -2658,6 +2796,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=="], @@ -2688,6 +2828,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=="], @@ -2732,6 +2876,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=="], @@ -2740,6 +2888,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=="], @@ -2754,6 +2904,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=="], @@ -2810,12 +2962,16 @@ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "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=="], "http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="], + "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=="], @@ -2848,6 +3004,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=="], @@ -2912,6 +3070,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=="], @@ -2926,6 +3086,8 @@ "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "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.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -2944,6 +3106,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=="], @@ -2954,8 +3118,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=="], @@ -2968,6 +3136,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=="], @@ -2978,6 +3148,8 @@ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], "libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="], @@ -3032,10 +3204,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=="], @@ -3058,6 +3234,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=="], @@ -3084,6 +3262,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=="], @@ -3208,6 +3388,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -3232,6 +3414,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=="], @@ -3290,6 +3474,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=="], @@ -3298,18 +3484,26 @@ "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-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + "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=="], @@ -3328,6 +3522,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=="], @@ -3362,6 +3558,10 @@ "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=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -3408,6 +3608,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=="], @@ -3438,8 +3640,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=="], @@ -3466,6 +3676,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=="], @@ -3476,6 +3688,8 @@ "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=="], @@ -3486,6 +3700,8 @@ "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=="], @@ -3530,6 +3746,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=="], @@ -3540,6 +3760,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=="], @@ -3572,6 +3794,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@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=="], @@ -3638,12 +3862,18 @@ "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@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -3656,6 +3886,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=="], @@ -3692,6 +3924,8 @@ "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=="], @@ -3710,12 +3944,16 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "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=="], @@ -3752,6 +3990,8 @@ "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "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"], @@ -3800,6 +4040,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=="], @@ -3854,6 +4096,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=="], @@ -3882,6 +4126,10 @@ "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], + "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=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -3894,10 +4142,14 @@ "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "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=="], @@ -3910,6 +4162,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=="], @@ -3924,6 +4180,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=="], @@ -3996,10 +4254,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=="], @@ -4008,6 +4270,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=="], @@ -4048,6 +4312,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=="], @@ -4104,6 +4370,8 @@ "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="], @@ -4182,14 +4450,40 @@ "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "@earendil-works/pi-coding-agent/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], "@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/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "@electron/get/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "@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.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "@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=="], "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + "@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=="], @@ -4490,12 +4784,20 @@ "@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@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=="], + "@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/through/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4510,6 +4812,18 @@ "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/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/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4524,12 +4838,16 @@ "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=="], "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=="], + "cheerio/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4538,6 +4856,8 @@ "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=="], @@ -4572,10 +4892,20 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "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=="], + "e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "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-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=="], @@ -4604,8 +4934,6 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "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/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "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=="], @@ -4622,6 +4950,8 @@ "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "global-agent/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], @@ -4644,6 +4974,8 @@ "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "jake/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "jsonwebtoken/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -4692,6 +5024,14 @@ "node-abi/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "node-api-version/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "node-gyp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], + + "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=="], @@ -4722,6 +5062,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=="], @@ -4732,6 +5078,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=="], "protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4744,6 +5092,8 @@ "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "react-email/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=="], + "react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -4758,14 +5108,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=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "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=="], + "simple-update-notifier/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "simstudio/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4798,12 +5154,14 @@ "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=="], + "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=="], @@ -4812,6 +5170,10 @@ "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=="], "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], @@ -4846,6 +5208,8 @@ "@earendil-works/pi-ai/@google/genai/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + "@electron/rebuild/node-abi/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -4970,12 +5334,20 @@ "@trigger.dev/core/socket.io-client/engine.io-client": ["engine.io-client@6.5.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ=="], + "@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=="], + "@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/through/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -4984,6 +5356,14 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "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=="], "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -5012,65 +5392,71 @@ "docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "engine.io/@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=="], - "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "ffmpeg-static/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "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=="], - "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "fumadocs-mdx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "fumadocs-mdx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "fumadocs-mdx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "fumadocs-mdx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "fumadocs-mdx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "fumadocs-mdx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "fumadocs-mdx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "fumadocs-mdx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "fumadocs-mdx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "fumadocs-mdx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "fumadocs-mdx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "fumadocs-mdx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "fumadocs-mdx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "fumadocs-mdx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "fumadocs-mdx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "fumadocs-mdx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "fumadocs-mdx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "fumadocs-mdx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - "fumadocs-mdx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "fumadocs-mdx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "fumadocs-mdx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "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=="], - "fumadocs-mdx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "ffmpeg-static/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -5154,6 +5540,8 @@ "next/sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -5182,79 +5570,81 @@ "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "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=="], + "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "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=="], + "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "sim/tailwindcss/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=="], + "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "simstudio-ts-sdk/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "simstudio/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "tsx/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/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "tsx/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/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "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=="], - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "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=="], - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "sim/tailwindcss/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=="], - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "simstudio-ts-sdk/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "simstudio/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "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=="], - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "twilio/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -5292,6 +5682,12 @@ "@trigger.dev/core/socket.io/engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "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=="], + "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "groq-sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], From 36ddb562403229490e5e321f60b8c27c7a2bf95e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Jul 2026 12:11:53 -0700 Subject: [PATCH 002/109] fix auth stuff --- apps/desktop/src/main/handoff.test.ts | 3 +- apps/desktop/src/main/handoff.ts | 45 ++++++++++++++++--------- apps/sim/app/desktop/auth/page.test.tsx | 13 +++++-- apps/sim/app/desktop/auth/page.tsx | 12 +++++-- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index 863dc5e269c..86b492ddcbe 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -27,11 +27,12 @@ function makeDeps(overrides: Partial = {}): HandoffManagerDe } describe('buildRedeemScript', () => { - it('embeds the token JSON-escaped and targets the verify endpoint', () => { + 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') }) }) diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index f1b7791bce2..118082333e9 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -147,11 +147,19 @@ export function createHandoffManager( } } +/** 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. + * 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 })) @@ -163,40 +171,41 @@ export function buildRedeemScript(token: string): string { credentials: 'include', body: ${body}, }) - return response.ok + return response.status } catch { - return false + return ${REDEEM_NETWORK_ERROR} } })()` } /** - * Redeems a one-time token from the app-partition renderer. 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. + * 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 { +): Promise { if (win.isDestroyed()) { - return false + return REDEEM_WINDOW_UNAVAILABLE } const contents = win.webContents if (!contents.getURL().startsWith(`${origin}/`)) { try { await win.loadURL(`${origin}/login`) } catch { - return false + return REDEEM_WINDOW_UNAVAILABLE } } try { - const result = await contents.executeJavaScript(buildRedeemScript(token), true) - return result === true + 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 false + return REDEEM_NETWORK_ERROR } } @@ -219,8 +228,11 @@ export interface AuthFlow { * back on /login. */ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { - const failInWindow = async (win: BrowserWindow, reason: string) => { - deps.events.record('handoff_redeem_fail', { reason }) + 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', @@ -250,8 +262,9 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { return } const origin = deps.origin() - if (!(await redeemToken(win, origin, callback.token))) { - await failInWindow(win, 'redeem') + 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') diff --git a/apps/sim/app/desktop/auth/page.test.tsx b/apps/sim/app/desktop/auth/page.test.tsx index 691f75cd976..cb6af567c13 100644 --- a/apps/sim/app/desktop/auth/page.test.tsx +++ b/apps/sim/app/desktop/auth/page.test.tsx @@ -12,8 +12,8 @@ const { mockGetSession, mockGenerateOneTimeToken, mockRedirect } = vi.hoisted(() })) vi.mock('@/lib/auth', () => ({ - auth: { api: { generateOneTimeToken: mockGenerateOneTimeToken, getSession: vi.fn() } }, - getSession: mockGetSession, + auth: { api: { generateOneTimeToken: mockGenerateOneTimeToken, getSession: mockGetSession } }, + getSession: vi.fn(), })) vi.mock('next/navigation', () => ({ @@ -71,6 +71,15 @@ describe('DesktopAuthPage', () => { ) }) + it('reads the session fresh (bypassing the cookie cache) so it never mints against a dead session', async () => { + await expect(DesktopAuthPage(pageProps({ state: VALID_STATE, port: '54321' }))).rejects.toThrow( + 'NEXT_REDIRECT:' + ) + expect(mockGetSession).toHaveBeenCalledWith( + expect.objectContaining({ query: { disableCookieCache: true } }) + ) + }) + it('redirects to login when minting fails', async () => { mockGenerateOneTimeToken.mockRejectedValue(new Error('UNAUTHORIZED')) await expect(DesktopAuthPage(pageProps({ state: VALID_STATE, port: '54321' }))).rejects.toThrow( diff --git a/apps/sim/app/desktop/auth/page.tsx b/apps/sim/app/desktop/auth/page.tsx index cb765e87d2c..0af95d2a7f1 100644 --- a/apps/sim/app/desktop/auth/page.tsx +++ b/apps/sim/app/desktop/auth/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import { headers } from 'next/headers' import { redirect } from 'next/navigation' -import { auth, getSession } from '@/lib/auth' +import { auth } from '@/lib/auth' import { buildDesktopAuthPath, buildLoopbackUrl, @@ -51,14 +51,20 @@ export default async function DesktopAuthPage({ searchParams }: DesktopAuthPageP return } - const session = await getSession() + // Force a DB-backed session read (bypass the cookie cache). A cache-only + // session can outlive its DB row after a sign-out/revoke, and minting a + // one-time token against a dead session makes /one-time-token/verify fail + // with "Session not found" (400) on redeem. A fresh read sends the user to + // re-login instead of minting a doomed token. + const hdrs = await headers() + const session = await auth.api.getSession({ headers: hdrs, query: { disableCookieCache: true } }) if (!session?.user) { redirect(`/login?callbackUrl=${encodeURIComponent(buildDesktopAuthPath(state, port))}`) } let token: string | null = null try { - const response = await auth.api.generateOneTimeToken({ headers: await headers() }) + const response = await auth.api.generateOneTimeToken({ headers: hdrs }) token = response?.token ?? null } catch { token = null From 37df74bcd3b4d293a2f52fafc412e14e826f916d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 18 Jul 2026 14:42:41 -0700 Subject: [PATCH 003/109] intermediate state --- apps/desktop/README.md | 19 +- apps/desktop/package.json | 2 + apps/desktop/src/main/browser-agent/cdp.ts | 91 ++ .../src/main/browser-agent/driver.test.ts | 75 ++ apps/desktop/src/main/browser-agent/driver.ts | 649 ++++++++++ .../src/main/browser-agent/page-functions.ts | 482 +++++++ .../src/main/browser-agent/registry.ts | 20 + .../src/main/browser-agent/session.test.ts | 159 +++ .../desktop/src/main/browser-agent/session.ts | 248 ++++ apps/desktop/src/main/index.ts | 41 +- apps/desktop/src/main/ipc.test.ts | 44 + apps/desktop/src/main/ipc.ts | 64 + .../main/local-filesystem-grant-store.test.ts | 55 + .../src/main/local-filesystem-grant-store.ts | 99 ++ .../desktop/src/main/local-filesystem.test.ts | 209 +++ apps/desktop/src/main/local-filesystem.ts | 771 +++++++++++ apps/desktop/src/main/security-guards.ts | 23 +- .../src/main/session-lifecycle.test.ts | 23 + apps/desktop/src/main/session-lifecycle.ts | 6 +- apps/desktop/src/preload/index.ts | 59 +- apps/desktop/src/test/electron-mock.ts | 71 + .../local-files/stage/route.test.ts | 154 +++ .../api/mothership/local-files/stage/route.ts | 101 ++ .../home/components/message-content/utils.ts | 25 +- .../add-resource-dropdown.tsx | 30 + .../browser-session/browser-session.test.ts | 32 + .../browser-session/browser-session.tsx | 172 +++ .../resource-content/components/index.ts | 1 + .../resource-content/resource-content.tsx | 5 + .../resource-registry/resource-registry.tsx | 16 + .../resource-tabs/resource-tabs.tsx | 6 +- .../user-input/components/constants.ts | 5 +- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 16 +- .../hooks/stream/handle-tool-event.test.ts | 8 + .../home/hooks/stream/handle-tool-event.ts | 25 + .../home/hooks/stream/stream-context.ts | 7 + .../home/hooks/stream/stream-helpers.ts | 42 +- .../home/hooks/stream/stream-test-helpers.ts | 2 + .../[workspaceId]/home/hooks/use-chat.ts | 111 +- .../app/workspace/[workspaceId]/home/types.ts | 1 + .../sim/lib/api/contracts/mothership-chats.ts | 19 + apps/sim/lib/browser-agent/transport.ts | 100 ++ apps/sim/lib/copilot/chat/payload.test.ts | 32 + apps/sim/lib/copilot/chat/payload.ts | 10 + apps/sim/lib/copilot/chat/post.test.ts | 20 + apps/sim/lib/copilot/chat/post.ts | 18 + .../lib/copilot/generated/tool-catalog-v1.ts | 1144 ++++++++++++----- .../lib/copilot/generated/tool-schemas-v1.ts | 1025 ++++++++++----- .../lib/copilot/generated/vfs-snapshot-v1.ts | 1 - .../sim/lib/copilot/request/tools/executor.ts | 4 +- apps/sim/lib/copilot/resources/types.ts | 12 +- .../tool-executor/register-handlers.ts | 14 +- .../tools/client/browser-tool-execution.ts | 192 +++ .../lib/copilot/tools/client/completion.ts | 88 ++ .../tools/client/local-filesystem.test.ts | 179 +++ .../copilot/tools/client/local-filesystem.ts | 195 +++ .../tools/client/run-tool-execution.ts | 84 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 470 +++++++ .../lib/copilot/tools/handlers/vfs-mutate.ts | 734 +++++++++++ .../sim/lib/copilot/tools/local-filesystem.ts | 146 +++ .../tools/server/files/file-folders.ts | 19 +- .../copilot/tools/server/files/rename-file.ts | 4 +- apps/sim/lib/copilot/tools/server/router.ts | 15 +- apps/sim/lib/copilot/tools/tool-display.ts | 122 ++ apps/sim/lib/copilot/vfs/resource-writer.ts | 41 +- .../workspace-file-folder-manager.ts | 5 + .../workspace/workspace-file-manager.ts | 72 +- .../orchestration/file-folder-lifecycle.ts | 67 + .../workspace-files/orchestration/index.ts | 3 + apps/sim/package.json | 2 + apps/sim/stores/browser-session/store.ts | 27 + apps/sim/types/sim-desktop.d.ts | 7 + bun.lock | 42 +- packages/browser-protocol/package.json | 31 + packages/browser-protocol/src/index.ts | 124 ++ packages/browser-protocol/tsconfig.json | 5 + packages/desktop-bridge/package.json | 32 + packages/desktop-bridge/src/index.ts | 146 +++ packages/desktop-bridge/tsconfig.json | 8 + 79 files changed, 8390 insertions(+), 838 deletions(-) create mode 100644 apps/desktop/src/main/browser-agent/cdp.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/page-functions.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/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/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/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/lib/browser-agent/transport.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/handlers/vfs-mutate.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts create mode 100644 apps/sim/lib/copilot/tools/local-filesystem.ts create mode 100644 apps/sim/stores/browser-session/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/desktop-bridge/package.json create mode 100644 packages/desktop-bridge/src/index.ts create mode 100644 packages/desktop-bridge/tsconfig.json diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f7114009e91..d00c1c1d96b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,6 +15,7 @@ src/main/ # main process (bundled to dist/main.cjs) 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 downloads.ts # will-download handling context-menu.ts # native right-click + spellcheck telemetry-policy.ts # third-party analytics blocking @@ -107,7 +108,7 @@ Yes — the architecture has a single, clean seam for native features, and nothi 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). To give the web app types without duplication, put the bridge interface in a shared **types-only package** (e.g. `packages/desktop-bridge`) that both the preload (implements) and the web app (consumes, via a `useDesktop()` hook returning the typed bridge or `null`) import. Until such a feature exists, the bridge stays desktop-local — there are zero desktop-only features in the web app today. +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: @@ -131,6 +132,22 @@ const desktop = useDesktop() 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 + +The main Copilot agent can inspect a user-selected local directory in the desktop app through request-local client tools (`local_mount_directory`, `local_list_mounts`, `local_list`, `local_glob`, `local_read`, `local_grep`, `local_stat`, and `local_forget_mount`). This capability is: + +- **Explicit and read-only:** the native folder picker creates the grant; there are no write/delete/execute 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. Sandboxed macOS builds also retain the security-scoped bookmark. There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session. +- **Revocable:** `local_forget_mount` 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:** renderer and model see only `localfs:///...` URIs. Electron resolves every URI, checks lexical and realpath containment, and refuses symlink escapes. +- **Desktop-only:** the web app advertises these request-local tools only when `window.simDesktop.localFilesystem` is present. They are available directly to the main agent and are not added to subagent allowlists. + +Server-side tools cannot consume a `localfs://` URI. `local_stage_file` reads the granted file in Electron and uploads its bytes as a normal chat upload, returning `uploads/...` plus the collision-safe `fileName`. The agent then calls `materialize_file(fileName)` to promote it to durable `files/...` before passing that path to tools such as `function_execute` or `generate_image`: + +```text +localfs://... → local_stage_file → uploads/... → materialize_file → files/... → server tool +``` + ## 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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8ef688944b0..9b6f5aa86b9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -26,6 +26,8 @@ "test:e2e": "playwright test" }, "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/logger": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", 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 00000000000..30be14598d8 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -0,0 +1,91 @@ +/** + * CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles + * the page states that would otherwise wedge automation (JS dialogs, file + * choosers) and captures screenshots that work even while the view is hidden. + * The user sees and drives the real embedded page, so there is no screencast + * and no synthetic input here. + */ +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 +} + +let callbacks: CdpCallbacks | null = null +/** 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 { + callbacks = 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(() => {}) +} + +function handleDebuggerEvent( + contents: WebContents, + method: string, + params: Record +): void { + 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() + } +} + +/** Full-quality screenshot via CDP (works while the view is hidden). */ +export async function captureScreenshot(contents: WebContents): Promise { + const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', { + format: 'jpeg', + quality: 80, + }) + return `data:image/jpeg;base64,${result.data}` +} 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 00000000000..644166534b0 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +type DriverModule = typeof import('@/main/browser-agent/driver') + +async function freshDriver(): Promise { + vi.resetModules() + return await import('@/main/browser-agent/driver') +} + +describe('parseKeyCombo', () => { + let driver: DriverModule + + beforeEach(async () => { + driver = await freshDriver() + }) + + it('parses named keys, letters, and modifier combos', () => { + expect(driver.parseKeyCombo('Enter')).toMatchObject({ key: 'Enter', keyCode: 13 }) + expect(driver.parseKeyCombo('esc')).toMatchObject({ key: 'Escape', keyCode: 27 }) + expect(driver.parseKeyCombo('a')).toMatchObject({ key: 'a', code: 'KeyA' }) + expect(driver.parseKeyCombo('Control+A')).toMatchObject({ key: 'a', ctrl: true }) + expect(driver.parseKeyCombo('Shift+a')).toMatchObject({ key: 'A', shift: true }) + expect(driver.parseKeyCombo('Cmd+Shift+Z')).toMatchObject({ + key: 'Z', + meta: true, + shift: true, + }) + expect(driver.parseKeyCombo('5')).toMatchObject({ key: '5', code: 'Digit5' }) + }) + + it('rejects unknown keys and modifiers', () => { + expect(() => driver.parseKeyCombo('Hyper+X')).toThrow(/Unrecognized modifier/) + expect(() => driver.parseKeyCombo('NotAKey')).toThrow(/Unrecognized key/) + }) +}) + +describe('executeTool', () => { + let driver: DriverModule + + beforeEach(async () => { + driver = await 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: [] }) + }) +}) 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 00000000000..c29df6d3c2e --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -0,0 +1,649 @@ +/** + * 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); actuation is synthetic DOM events from those same + * functions. 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 type { + BrowserPageState, + BrowserPanelAction, + BrowserPanelBounds, + BrowserToolName, +} from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import type { BrowserWindow, WebContents } from 'electron' +import * as cdp from '@/main/browser-agent/cdp' +import { + clickElement, + collectSnapshot, + getViewportInfo, + hasTakeoverBanner, + hoverElement, + isTakeoverDone, + pageContainsText, + pressKeyOnPage, + readPageText, + removeTakeoverBanner, + scrollPage, + selectOptionInElement, + showTakeoverBanner, + typeIntoElement, +} from '@/main/browser-agent/page-functions' +import * as session from '@/main/browser-agent/session' + +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 TOOL_WATCHDOG_MS = 150_000 + +export class ToolError extends Error {} + +export interface DriverCallbacks { + onPageState: (state: BrowserPageState) => void + onSessionStatus: (alive: boolean) => void +} + +let driverCallbacks: DriverCallbacks | 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) +} + +function pageStateFor(contents: WebContents): BrowserPageState { + return { + 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 + if (session.activeTab()?.view.webContents !== contents) return + driverCallbacks?.onPageState(pageStateFor(contents)) +} + +/** 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.' + ) + }, + }) + .catch((error) => { + logger.warn('CDP instrumentation failed', { + error: error instanceof Error ? error.message : String(error), + }) + }) + for (const event of [ + 'did-navigate', + 'did-navigate-in-page', + 'page-title-updated', + 'did-start-loading', + 'did-stop-loading', + ] as const) { + contents.on(event as 'did-navigate', () => pushPageState(contents)) + } + driverCallbacks?.onSessionStatus(true) +} + +export function initDriver( + callbacks: DriverCallbacks, + getMainWindow: () => BrowserWindow | null +): void { + driverCallbacks = callbacks + session.initSession( + { + onSessionClosed: () => { + driverCallbacks?.onSessionStatus(false) + }, + onTabCreated: instrumentTab, + onActiveTabChanged: pushPageState, + onDownloadBlocked: (filename) => { + recordNotice( + `The page tried to download "${filename}"; downloads are not supported in the agent browser, so it was blocked.` + ) + }, + }, + getMainWindow + ) +} + +/** Renderer-reported panel rect (null = panel hidden/unmounted). */ +export function setPanelBounds(bounds: BrowserPanelBounds | null): void { + session.setPanelBounds(bounds) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +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 +} + +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 +} + +// --------------------------------------------------------------------------- +// Page-function execution +// --------------------------------------------------------------------------- + +/** + * 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 expression = `(${String(fn)}).apply(null, ${JSON.stringify(args)})` + try { + return (await contents.executeJavaScript(expression, true)) as Result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new ToolError( + `Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` + + 'navigate to a regular website first.' + ) + } +} + +/** Maps sentinel `{ error: ... }` results from injected functions to ToolErrors. */ +function unwrapPageResult(result: unknown): unknown { + if (typeof result === 'object' && result !== null && '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( + 'Refusing to type into a password field. Call browser_request_takeover so the user ' + + 'can enter their credentials themselves.' + ) + } + 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 setUrlDraft(event.target.value)} + onFocus={(event) => { + setUrlDraft(pageState?.url ?? '') + event.target.select() + }} + onBlur={() => setUrlDraft(null)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') submitUrl() + if (event.key === 'Escape') urlInputRef.current?.blur() + }} + /> + + {/* Host area: the real page is overlaid exactly on this rect. */} +
+ {(!pageState || !sessionAlive) && ( +
+ +

+ {sessionAlive + ? 'Waiting for the browser session to start…' + : 'The browser session was closed — ask Sim to navigate again to start a new one.'} +

+
+ )} +
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts index 679bc28cda5..a2b60307d49 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts @@ -1 +1,2 @@ +export { BrowserSession } from './browser-session/browser-session' export { GenericResourceContent } from './generic-resource-content' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 95a79eee750..32c2f0d770c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -34,6 +34,7 @@ import { type PreviewMode, resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' import { RESOURCE_TAB_ICON_BUTTON_CLASS, @@ -275,6 +276,9 @@ export const ResourceContent = memo(function ResourceContent({ ) + case 'browser': + return + default: return null } @@ -315,6 +319,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) return case 'folder': case 'generic': + case 'browser': return null default: return null diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 5fbf767a4c0..436c53f045d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -5,6 +5,7 @@ import { cn } from '@sim/emcn' import { Calendar, Connections, + Cursor, Database, File as FileIcon, Folder as FolderIcon, @@ -212,6 +213,15 @@ export const RESOURCE_REGISTRY: Record , }, + browser: { + type: 'browser', + label: 'Browser', + icon: Cursor, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, } as const export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY) @@ -265,6 +275,12 @@ const RESOURCE_INVALIDATORS: Record< * invalidate when one is added. */ integration: () => {}, + /** + * The browser panel renders live frames pushed by the desktop app + * (in-memory store, no server-backed query), so there is nothing to + * invalidate. + */ + browser: () => {}, } /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index a99416db8de..d38e6030e67 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -328,7 +328,11 @@ export function ResourceTabs({ const handleAdd = useCallback( (resource: MothershipResource) => { if (!chatId) return - addResource.mutate({ chatId, resource }) + // Ephemeral panels (live browser session) are in-memory only — never + // persisted to the chat's resource list. + if (!isEphemeralResource(resource)) { + addResource.mutate({ chatId, resource }) + } onAddResource(resource) }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 1e7ee30ce22..b0319286ec7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -100,7 +100,7 @@ export const SPEECH_RECOGNITION_LANG = 'en-US' * supplied — preventing silent drift between the two taxonomies. */ const RESOURCE_TO_CONTEXT: Record< - MothershipResourceType, + Exclude, (resource: MothershipResource) => ChatContext > = { workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }), @@ -117,5 +117,8 @@ const RESOURCE_TO_CONTEXT: Record< } export function mapResourceToContext(resource: MothershipResource): ChatContext { + if (resource.type === 'browser') { + throw new Error('Live browser sessions cannot be attached as chat context') + } return RESOURCE_TO_CONTEXT[resource.type](resource) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index a29c59b28b3..08898a14a2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -35,6 +35,7 @@ export type AvailableResourceGroup = ReturnType[nu * `@sla` surfaces Slack) but should not clutter the explicit attach menu. */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) +const NON_ATTACHABLE_RESOURCE_TYPES = new Set(['browser']) interface PlusMenuDropdownProps { availableResources: AvailableResourceGroup[] @@ -76,13 +77,14 @@ export const PlusMenuDropdown = React.memo( // The `+` browse menu hides mention-only resource types; `@`-mention mode // exposes the full catalog so integrations remain searchable inline. - const visibleResources = useMemo( - () => - isMention - ? availableResources - : availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)), - [isMention, availableResources] - ) + const visibleResources = useMemo(() => { + const attachable = availableResources.filter( + ({ type }) => !NON_ATTACHABLE_RESOURCE_TYPES.has(type) + ) + return isMention + ? attachable + : attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) + }, [isMention, availableResources]) const workflowTree = useMemo(() => { const workflowGroup = visibleResources.find((g) => g.type === 'workflow') diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index 1a760f149f1..a82629b0363 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -109,6 +109,14 @@ describe('tool events (dispatch → model + side effects)', () => { expect(toolNode(ctx, 'tc-3').status).toBe('error') }) + it('starts a desktop local filesystem tool once its complete call arrives', () => { + const startClientLocalFilesystemTool = vi.fn() + const ctx = createStreamLoopContext(makeStreamLoopDeps({ startClientLocalFilesystemTool })) + dispatchStreamEvent(ctx, toolCall('local-1', 'local_list_mounts')) + + expect(startClientLocalFilesystemTool).toHaveBeenCalledWith('local-1', 'local_list_mounts', {}) + }) + it('settles a file-write row on its own result, independent of a streaming preview session', () => { const previewSessionsRef = ref>({}) const ctx = createStreamLoopContext(makeStreamLoopDeps({ previewSessionsRef })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index 1b2004a9a3f..e679858cdca 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -1,3 +1,4 @@ +import { isBrowserToolName } from '@sim/browser-protocol' import { MothershipStreamV1ToolPhase, MothershipStreamV1ToolStatus, @@ -8,6 +9,7 @@ import { extractResourcesFromToolResult, isResourceToolName, } from '@/lib/copilot/resources/extraction' +import { isLocalFilesystemToolName } from '@/lib/copilot/tools/local-filesystem' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' @@ -187,5 +189,28 @@ export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void deps.startClientWorkflowTool(rawId, name, args ?? {}) } } + if (isLocalFilesystemToolName(name) && !isPartial) { + const shouldStartLocalFilesystemTool = + node?.kind === 'tool' && node.status === 'running' && !node.result + if (shouldStartLocalFilesystemTool) { + const args = payload.arguments as Record | undefined + deps.startClientLocalFilesystemTool(rawId, name, args ?? {}) + } + } + if (isBrowserToolName(name) && !isPartial) { + const shouldStartBrowserTool = + !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && + node?.kind === 'tool' && + node.status === 'running' && + !node.result + if (shouldStartBrowserTool) { + deps.startClientBrowserTool( + rawId, + name, + (payload.arguments as Record | undefined) ?? {}, + parsed.ts + ) + } + } ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index b49871bfbdf..c1dd5141450 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -71,6 +71,13 @@ export interface StreamLoopDeps { addResource: (resource: MothershipResource) => boolean removeResource: (resourceType: MothershipResourceType, resourceId: string) => void startClientWorkflowTool: (id: string, name: string, args: Record) => void + startClientLocalFilesystemTool: (id: string, name: string, args: Record) => void + startClientBrowserTool: ( + id: string, + name: string, + args: Record, + ts?: string + ) => void upsertMothershipChatHistory: ( chatId: string, updater: (current: MothershipChatHistory) => MothershipChatHistory diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 72e1bd2dabc..208929f1590 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -21,10 +21,8 @@ import { ManageScheduledTaskOperation, ManageSkill, ManageSkillOperation, - MoveWorkflow, QueryLogs, Redeploy, - RenameWorkflow, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -34,7 +32,7 @@ import { WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' @@ -52,12 +50,15 @@ export const DEPLOY_TOOL_NAMES: Set = new Set([ Redeploy.id, ]) -export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id]) +export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id, 'mkdir', 'mv']) export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ - MoveWorkflow.id, - RenameWorkflow.id, + 'mv', + 'cp', DeleteWorkflow.id, + // Grace-period transcript/checkpoint compatibility. + 'move_workflow', + 'rename_workflow', ]) export type StreamPayload = Record @@ -289,6 +290,28 @@ export function resolveStreamingToolDisplayTitle( return toolTitle ? `Finding ${toolTitle}` : undefined } + if (name === 'mv') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + if (!toolTitle) return undefined + const multiSource = /"sources"\s*:\s*\[\s*"[^"]*"\s*,/.test(streamingArgs) + const firstSource = streamingArgs.match(/"sources"\s*:\s*\[\s*"([^"]*)"/m)?.[1] + const destination = matchStreamingStringArg(streamingArgs, 'destination') + const verb = multiSource + ? 'Moving' + : mvDisplayVerb(firstSource ? decodeStreamingString(firstSource) : undefined, destination) + return `${verb} ${toolTitle}` + } + + if (name === 'cp') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Duplicating ${toolTitle}` : undefined + } + + if (name === 'mkdir') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Creating ${toolTitle}` : undefined + } + if (name === ScrapePage.id) { const url = matchStreamingStringArg(streamingArgs, 'url') return url ? `Scraping ${url}` : undefined @@ -364,12 +387,13 @@ export function resolveStreamingToolDisplayTitle( } if (name === ManageFolder.id) { + // create/rename/move remain here for checkpoint and transcript compatibility. return resolveOperationDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), { - [ManageFolderOperation.create]: 'Creating folder', - [ManageFolderOperation.rename]: 'Renaming folder', - [ManageFolderOperation.move]: 'Moving folder', + create: 'Creating folder', + rename: 'Renaming folder', + move: 'Moving folder', [ManageFolderOperation.delete]: 'Deleting folder', }, 'Folder action' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts index c7f5b4a50eb..040229a82a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts @@ -39,6 +39,8 @@ export function makeStreamLoopDeps(overrides: Partial = {}): Str addResource: vi.fn(() => true), removeResource: vi.fn(), startClientWorkflowTool: vi.fn(), + startClientLocalFilesystemTool: vi.fn(), + startClientBrowserTool: vi.fn(), upsertMothershipChatHistory: vi.fn(), ensureWorkflowInRegistry: vi.fn(() => false), onPreviewPhase: vi.fn(), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index e81ec0603f9..a46ce3a38af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -7,6 +7,7 @@ import { useRef, useState, } from 'react' +import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' @@ -21,6 +22,7 @@ import { reorderMothershipChatResourcesContract, } from '@/lib/api/contracts/mothership-chats' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' +import { initBrowserAgentTransport, isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' @@ -54,6 +56,9 @@ import { isFilePreviewSession, } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +import { BROWSER_SESSION_RESOURCE_ID, isEphemeralResource } from '@/lib/copilot/resources/types' +import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' +import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' import { bindRunToolToExecution, cancelRunToolExecution, @@ -62,6 +67,7 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' +import { isLocalFilesystemToolName } from '@/lib/copilot/tools/local-filesystem' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { readSSELines } from '@/lib/core/utils/sse' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -929,7 +935,12 @@ export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[] const payload = event.payload if (!('phase' in payload)) continue if (payload.phase !== MothershipStreamV1ToolPhase.result) continue - if (typeof payload.toolCallId === 'string' && isWorkflowToolName(payload.toolName)) { + // Client-executed tools (workflow runs, browser actions) must never + // re-fire when their completed call replays after reconnect/reload. + if ( + typeof payload.toolCallId === 'string' && + (isWorkflowToolName(payload.toolName) || isBrowserToolName(payload.toolName)) + ) { completedToolCallIds.add(payload.toolCallId) } } @@ -1260,6 +1271,7 @@ export function useChat( const streamingContentRef = useRef('') const streamingBlocksRef = useRef([]) const handledClientWorkflowToolIdsRef = useRef>(new Set()) + const handledClientLocalFilesystemToolIdsRef = useRef>(new Set()) const recoveringClientWorkflowToolIdsRef = useRef>(new Set()) const executionStream = useExecutionStream() const isHomePage = pathname.endsWith('/home') @@ -1480,7 +1492,9 @@ export function useChat( }) setActiveResourceId(resource.id) - if (resource.id === 'streaming-file') { + // Ephemeral panels (streaming file preview, live browser session) are + // in-memory only — never persisted to the chat's resource list. + if (resource.id === 'streaming-file' || resource.type === 'browser') { return true } @@ -1508,6 +1522,9 @@ export function useChat( setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId))) setActiveResourceId((prev) => (prev === resourceId ? null : prev)) + // Ephemeral panels were never persisted; nothing to delete server-side. + if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return + const key = `${resourceType}:${resourceId}` const wasPending = pendingPersistResourceKeysRef.current.delete(key) const inFlightAdd = inFlightResourceAddsRef.current.get(key) @@ -1549,7 +1566,7 @@ export function useChat( if (!chatId) return const order = resourcesRef.current.filter( (r) => - r.id !== 'streaming-file' && + !isEphemeralResource(r) && !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) ) if (order.length === 0) return @@ -1562,7 +1579,7 @@ export function useChat( } return } - const persistableResources = newOrder.filter((r) => r.id !== 'streaming-file') + const persistableResources = newOrder.filter((r) => !isEphemeralResource(r)) if (persistableResources.length === 0) return requestJson(reorderMothershipChatResourcesContract, { body: { chatId: persistChatId, resources: persistableResources }, @@ -1617,6 +1634,40 @@ export function useChat( [ensureWorkflowToolResource] ) + const startClientLocalFilesystemTool = useCallback( + (toolCallId: string, toolName: string, toolArgs: Record) => { + if (!isLocalFilesystemToolName(toolName)) { + return + } + if (handledClientLocalFilesystemToolIdsRef.current.has(toolCallId)) { + return + } + handledClientLocalFilesystemToolIdsRef.current.add(toolCallId) + executeLocalFilesystemTool(toolCallId, toolName, toolArgs, { + workspaceId, + chatId: chatIdRef.current ?? selectedChatIdRef.current, + }) + }, + [workspaceId] + ) + + const startClientBrowserTool = useCallback( + (toolCallId: string, toolName: string, toolArgs: Record, eventTs?: string) => { + if (!isBrowserToolName(toolName)) { + return + } + // Surface the live browser panel the first time the agent touches the + // browser; addResource activates it and dedupes on subsequent calls. + if (addResource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' })) { + onResourceEventRef.current?.() + } + // Replay/exactly-once guarding lives in executeBrowserToolOnClient + // (sessionStorage-backed, so reloads cannot re-run an action). + executeBrowserToolOnClient(toolCallId, toolName, toolArgs, eventTs) + }, + [addResource] + ) + const recoverPendingClientWorkflowTools = useCallback( async (nextMessages: ChatMessage[]) => { const pending: ToolCallInfo[] = [] @@ -1729,6 +1780,10 @@ export function useChat( cancelActiveStreamReader, ]) + useEffect(() => { + initBrowserAgentTransport() + }, []) + useEffect(() => { if (workflowIdRef.current) return if (!isHomePage || !chatIdRef.current) return @@ -1781,7 +1836,28 @@ export function useChat( const localOnly = resourcesRef.current.filter( (r) => r.id !== 'streaming-file' && !serverKeys.has(`${r.type}:${r.id}`) ) - const mergedResources = [...persistedResources, ...localOnly] + // Server order is authoritative for persisted resources, but local-only + // items (pending-persist adds, ephemeral panels like the live browser) + // keep their current on-screen position — hydration reruns on every send + // and stream completion, and appending them at the end made those tabs + // visibly jump/flash each time. + const mergedResources = [...persistedResources] + for (const resource of localOnly) { + const currentIndex = resourcesRef.current.findIndex( + (r) => r.type === resource.type && r.id === resource.id + ) + const insertAt = + currentIndex < 0 ? mergedResources.length : Math.min(currentIndex, mergedResources.length) + mergedResources.splice(insertAt, 0, resource) + } + const resourcesUnchanged = + mergedResources.length === resourcesRef.current.length && + mergedResources.every( + (resource, index) => + resourcesRef.current[index].type === resource.type && + resourcesRef.current[index].id === resource.id && + resourcesRef.current[index].title === resource.title + ) if (mergedResources.length > 0) { const hydratedActiveResourceId = @@ -1789,9 +1865,13 @@ export function useChat( mergedResources.some((resource) => resource.id === activeResourceIdRef.current) ? activeResourceIdRef.current : mergedResources[mergedResources.length - 1].id - activeResourceIdRef.current = hydratedActiveResourceId - setResources(mergedResources) - setActiveResourceId(hydratedActiveResourceId) + // Replacing the array with an identical one still re-renders the tab + // strip and panel — skip the no-op so open panels don't flash. + if (!resourcesUnchanged) { + activeResourceIdRef.current = hydratedActiveResourceId + setResources(mergedResources) + setActiveResourceId(hydratedActiveResourceId) + } for (const resource of persistedResources) { if (resource.type !== 'workflow') continue @@ -1949,6 +2029,8 @@ export function useChat( addResource, removeResource, startClientWorkflowTool, + startClientLocalFilesystemTool, + startClientBrowserTool, upsertMothershipChatHistory: upsertChatHistory, ensureWorkflowInRegistry, onPreviewPhase, @@ -2050,6 +2132,8 @@ export function useChat( addResource, removeResource, startClientWorkflowTool, + startClientLocalFilesystemTool, + startClientBrowserTool, upsertChatHistory, onPreviewPhase, applyPreviewSessionUpdate, @@ -3204,7 +3288,10 @@ export function useChat( abortControllerRef.current = abortController const currentActiveId = activeResourceIdRef.current - const currentResources = resourcesRef.current + // The live browser panel is a client-side surface, not a + // server-resolvable resource — it must never ride resourceAttachments + // (the request schema rejects it, failing the whole send). + const currentResources = resourcesRef.current.filter((r) => r.type !== 'browser') const resourceAttachments = currentResources.length > 0 ? currentResources.map((r) => ({ @@ -3228,6 +3315,12 @@ export function useChat( ...(resourceAttachments ? { resourceAttachments } : {}), ...(contexts && contexts.length > 0 ? { contexts } : {}), ...(workflowIdRef.current ? { workflowId: workflowIdRef.current } : {}), + ...(typeof window !== 'undefined' && window.simDesktop?.localFilesystem + ? { desktopCapabilities: { localFilesystem: true } } + : {}), + // Advertised only when the desktop app's browser-agent bridge is + // present — gates the browser subagent server-side. + ...(isBrowserAgentAvailable() ? { browserCapable: true } : {}), userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }), signal: abortController.signal, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 20bc2513dab..e2b3aeb3ddc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -165,4 +165,5 @@ export const SUBAGENT_LABELS: Record = { job: 'Job Agent', file: 'File Agent', media: 'Media Agent', + browser: 'Browser Agent', } as const diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 0165c512bd3..2eafefc19b6 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -214,6 +214,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/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts new file mode 100644 index 00000000000..bf90c3b1124 --- /dev/null +++ b/apps/sim/lib/browser-agent/transport.ts @@ -0,0 +1,100 @@ +/** + * 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 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 { + BrowserPageState, + BrowserPanelAction, + BrowserPanelBounds, + BrowserToolName, +} from '@sim/browser-protocol' +import type { SimDesktopBrowserAgentApi } from '@sim/desktop-bridge' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +let initialized = false + +function bridge(): SimDesktopBrowserAgentApi | null { + if (typeof window === 'undefined') return null + return window.simDesktop?.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.onSessionStatus((alive) => { + useBrowserSessionStore.getState().setSessionAlive(alive) + }) +} + +/** True when browser tools can run (gates the copilot's browserCapable flag). */ +export function isBrowserAgentAvailable(): boolean { + return bridge() !== null +} + +/** + * 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( + 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(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 }) +} + +/** + * 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): void { + bridge()?.setPanelBounds(bounds) +} diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 06e3a288e0a..dd83af79ca0 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -209,6 +209,38 @@ describe('buildCopilotRequestPayload', () => { ) }) + it('advertises desktop local filesystem tools only for capable requests', 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: '' } + ) + const capableTools = capablePayload.mothershipTools as Array<{ name: string }> + expect(capableTools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(['local_mount_directory', 'local_read', 'local_stage_file']) + ) + + 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') + }) + 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 9bea8cddc10..fddae9f582e 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -11,6 +11,7 @@ import { } from '@/lib/copilot/integration-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' +import { buildLocalFilesystemToolSchemas } from '@/lib/copilot/tools/local-filesystem' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' @@ -50,6 +51,8 @@ interface BuildPayloadParams { timezone?: string } includeMothershipTools?: boolean + desktopLocalFilesystem?: boolean + browserCapable?: boolean } export interface ToolSchema { @@ -367,6 +370,10 @@ export async function buildCopilotRequestPayload( }) } } + + if (params.desktopLocalFilesystem) { + mothershipTools.push(...buildLocalFilesystemToolSchemas()) + } } return { @@ -398,6 +405,9 @@ 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). ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), + // Advertised only when the desktop app's browser-agent bridge answered the + // page handshake — gates the browser subagent server-side. + ...(params.browserCapable ? { browserCapable: true } : {}), isHosted, } } diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 465006952d2..51d33b0ebca 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -270,6 +270,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 bd86a60c314..e19cfdc6ca4 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -152,6 +152,12 @@ 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(), + }) + .optional(), + browserCapable: z.boolean().optional(), }) type UnifiedChatRequest = z.infer @@ -190,6 +196,8 @@ type UnifiedChatBranch = implicitFeedback?: string workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean }) => Promise> buildExecutionContext: (params: { userId: string @@ -220,6 +228,8 @@ type UnifiedChatBranch = userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean }) => Promise> buildExecutionContext: (params: { userId: string @@ -637,6 +647,8 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, }, { selectedModel } ), @@ -694,6 +706,8 @@ async function resolveBranch(params: { userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, includeMothershipTools: true, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, }, { selectedModel: '' } ), @@ -1020,6 +1034,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { implicitFeedback: body.implicitFeedback, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: body.browserCapable === true, }) : branch.buildPayload({ message: body.message, @@ -1034,6 +1050,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMetadata, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: body.browserCapable === true, }), activeOtelRoot.context ) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cce8011cd3e..dfcbada05b8 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,11 +9,32 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | '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' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -50,7 +71,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -64,42 +84,40 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_integration_tools' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' - | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -108,11 +126,32 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | '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' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -149,7 +188,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -163,60 +201,58 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_integration_tools' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' - | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' parameters: unknown - requiredPermission?: 'admin' | 'read' | 'write' + requiredPermission?: 'admin' | 'write' resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: | 'agent' | 'auth' + | 'browser' | 'deploy' | 'file' | 'knowledge' | 'media' - | 'research' | 'run' | 'scheduled_task' - | 'superagent' + | 'search' | 'table' | 'workflow' } @@ -254,6 +290,346 @@ 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 to extract, in plain language.' }, + }, + 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 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:// — localhost/local dev URLs are supported).', + }, + }, + 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 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, e.g. 'Enter' or 'Control+A'." }, + }, + 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: { + 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 one viewport).', + }, + 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.' }, + }, + 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).', + }, + }, + }, + clientExecutable: true, +} + +export const CallIntegrationTool: ToolCatalogEntry = { + id: 'call_integration_tool', + name: 'call_integration_tool', + route: 'go', + mode: 'sync', + parameters: { + properties: { + arguments: { + additionalProperties: true, + description: "Inputs matching the selected operation's server-owned inputSchema.", + type: 'object', + }, + credentialId: { + description: + 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', + type: 'string', + }, + description: { + description: + 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', + type: 'string', + }, + toolId: { description: 'Exact toolId returned by search_integration_tools.', type: 'string' }, + }, + required: ['toolId', 'description', 'arguments'], + type: 'object', + }, +} + export const CheckDeploymentStatus: ToolCatalogEntry = { id: 'check_deployment_status', name: 'check_deployment_status', @@ -284,6 +660,36 @@ export const CompleteScheduledTask: ToolCatalogEntry = { }, } +export const Cp: ToolCatalogEntry = { + id: 'cp', + name: 'cp', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const CrawlWebsite: ToolCatalogEntry = { id: 'crawl_website', name: 'crawl_website', @@ -335,7 +741,7 @@ export const CreateFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -377,29 +783,6 @@ export const CreateFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const CreateFileFolder: ToolCatalogEntry = { - id: 'create_file_folder', - name: 'create_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - requiredPermission: 'write', -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -408,8 +791,11 @@ export const CreateWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { - description: { type: 'string', description: 'Optional workflow description.' }, - folderId: { type: 'string', description: 'Optional folder ID.' }, + folderPath: { + type: 'string', + description: + 'Optional canonical workflow-folder VFS path copied from glob("workflows/**"), for example "workflows/Dream" or "workflows/Client%20Work/Intake". Omit for the workspace root.', + }, name: { type: 'string', description: 'Workflow name.' }, workspaceId: { type: 'string', description: 'Optional workspace ID.' }, }, @@ -813,7 +1199,7 @@ export const DeployCustomBlock: ToolCatalogEntry = { iconUrl: { type: 'string', description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', }, inputs: { type: 'array', @@ -1026,7 +1412,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1301,7 +1687,8 @@ export const Ffmpeg: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1465,7 +1852,8 @@ export const FunctionExecute: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1498,6 +1886,12 @@ 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.', + default: 10, + }, title: { type: 'string', description: @@ -1630,7 +2024,8 @@ export const GenerateAudio: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1764,7 +2159,8 @@ export const GenerateImage: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1922,7 +2318,8 @@ export const GenerateVideo: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -2411,23 +2808,6 @@ export const KnowledgeBase: ToolCatalogEntry = { }, } -export const ListFileFolders: ToolCatalogEntry = { - id: 'list_file_folders', - name: 'list_file_folders', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - requiredPermission: 'read', -} - export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', @@ -2616,35 +2996,16 @@ export const ManageFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, 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".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', - }, + 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. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', }, }, required: ['operation'], @@ -2861,72 +3222,57 @@ export const Media: ToolCatalogEntry = { internal: true, } -export const MoveFile: ToolCatalogEntry = { - id: 'move_file', - name: 'move_file', +export const Mkdir: ToolCatalogEntry = { + id: 'mkdir', + name: 'mkdir', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string' }, }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const MoveFileFolder: ToolCatalogEntry = { - id: 'move_file_folder', - name: 'move_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, requiredPermission: 'write', } -export const MoveWorkflow: ToolCatalogEntry = { - id: 'move_workflow', - name: 'move_workflow', +export const Mv: ToolCatalogEntry = { + id: 'mv', + name: 'mv', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string' }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, requiredPermission: 'write', } @@ -2939,6 +3285,11 @@ export const OauthGetAuthLink: ToolCatalogEntry = { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -3130,6 +3481,55 @@ export const QueryLogs: ToolCatalogEntry = { }, } +export const QueryUserTable: ToolCatalogEntry = { + id: 'query_user_table', + name: 'query_user_table', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3208,107 +3608,26 @@ export const Redeploy: ToolCatalogEntry = { description: 'Invocation examples keyed by surface name. For API deploys this includes curl examples for sync, stream, async, and polling.', }, - isDeployed: { - type: 'boolean', - description: 'Whether the workflow API is currently deployed after this tool call.', - }, - version: { - type: 'number', - description: 'Deployment version for the current API deployment.', - }, - workflowId: { type: 'string', description: 'Workflow ID that was deployed or undeployed.' }, - }, - required: [ - 'workflowId', - 'isDeployed', - 'deploymentType', - 'deploymentStatus', - 'deploymentConfig', - 'examples', - ], - }, - requiredPermission: 'admin', -} - -export const RenameFile: ToolCatalogEntry = { - id: 'rename_file', - name: 'rename_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { type: 'object', description: 'Contains id and the new name.' }, - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the rename succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const RenameFileFolder: ToolCatalogEntry = { - id: 'rename_file_folder', - name: 'rename_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'New folder name.' }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - requiredPermission: 'write', -} - -export const RenameWorkflow: ToolCatalogEntry = { - id: 'rename_workflow', - name: 'rename_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'The new name for the workflow.' }, - workflowId: { type: 'string', description: 'The workflow ID to rename.' }, + isDeployed: { + type: 'boolean', + description: 'Whether the workflow API is currently deployed after this tool call.', + }, + version: { + type: 'number', + description: 'Deployment version for the current API deployment.', + }, + workflowId: { type: 'string', description: 'Workflow ID that was deployed or undeployed.' }, }, - required: ['workflowId', 'name'], - }, - requiredPermission: 'write', -} - -export const Research: ToolCatalogEntry = { - id: 'research', - name: 'research', - route: 'subagent', - mode: 'async', - parameters: { - properties: { topic: { description: 'The topic to research.', type: 'string' } }, - required: ['topic'], - type: 'object', + required: [ + 'workflowId', + 'isDeployed', + 'deploymentType', + 'deploymentStatus', + 'deploymentConfig', + 'examples', + ], }, - subagentId: 'research', - internal: true, + requiredPermission: 'admin', } export const Respond: ToolCatalogEntry = { @@ -3324,6 +3643,12 @@ export const Respond: ToolCatalogEntry = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { type: 'string' }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean' }, type: { description: 'Optional logical result type override', type: 'string' }, }, @@ -3408,6 +3733,99 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } +export const RunCode: ToolCatalogEntry = { + id: 'run_code', + name: 'run_code', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'table_input'], +} + export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -3571,6 +3989,26 @@ export const ScrapePage: ToolCatalogEntry = { }, } +export const Search: ToolCatalogEntry = { + id: 'search', + name: 'search', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'search', + internal: true, +} + export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3586,6 +4024,77 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } +export const SearchIntegrationTools: ToolCatalogEntry = { + id: 'search_integration_tools', + name: 'search_integration_tools', + route: 'go', + mode: 'sync', + parameters: { + properties: { + limit: { + description: 'Maximum matches to return. Defaults to 5.', + maximum: 10, + minimum: 1, + type: 'integer', + }, + query: { + description: 'What the service operation must do, in plain language.', + type: 'string', + }, + service: { + description: + 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', + type: 'string', + }, + }, + required: ['query'], + type: 'object', + }, +} + +export const SearchKnowledgeBase: ToolCatalogEntry = { + id: 'search_knowledge_base', + name: 'search_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -3768,26 +4277,6 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { requiredPermission: 'write', } -export const Superagent: ToolCatalogEntry = { - id: 'superagent', - name: 'superagent', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - task: { - description: - "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - subagentId: 'superagent', - internal: true, -} - export const Table: ToolCatalogEntry = { id: 'table', name: 'table', @@ -3870,50 +4359,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const UserMemory: ToolCatalogEntry = { - id: 'user_memory', - name: 'user_memory', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { type: 'number', description: 'Number of results for search (default 10)' }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { type: 'string', description: 'Search query to find relevant memories' }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { type: 'string', description: 'Value to remember' }, - }, - required: ['operation'], - }, -} - export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4256,10 +4701,23 @@ export const Workflow: ToolCatalogEntry = { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, subagentId: 'workflow', @@ -4495,21 +4953,13 @@ export const ManageCustomToolOperationValues = [ ] as const export const ManageFolderOperation = { - create: 'create', - rename: 'rename', - move: 'move', delete: 'delete', } as const export type ManageFolderOperation = (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] -export const ManageFolderOperationValues = [ - ManageFolderOperation.create, - ManageFolderOperation.rename, - ManageFolderOperation.move, - ManageFolderOperation.delete, -] as const +export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const export const ManageMcpToolOperation = { add: 'add', @@ -4576,22 +5026,36 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const -export const UserMemoryOperation = { - add: 'add', - search: 'search', - delete: 'delete', - correct: 'correct', - list: 'list', +export const QueryUserTableOperation = { + get: 'get', + getSchema: 'get_schema', + getRow: 'get_row', + queryRows: 'query_rows', +} as const + +export type QueryUserTableOperation = + (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] + +export const QueryUserTableOperationValues = [ + QueryUserTableOperation.get, + QueryUserTableOperation.getSchema, + QueryUserTableOperation.getRow, + QueryUserTableOperation.queryRows, +] as const + +export const SearchKnowledgeBaseOperation = { + get: 'get', + query: 'query', + listTags: 'list_tags', } as const -export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] +export type SearchKnowledgeBaseOperation = + (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] -export const UserMemoryOperationValues = [ - UserMemoryOperation.add, - UserMemoryOperation.search, - UserMemoryOperation.delete, - UserMemoryOperation.correct, - UserMemoryOperation.list, +export const SearchKnowledgeBaseOperationValues = [ + SearchKnowledgeBaseOperation.get, + SearchKnowledgeBaseOperation.query, + SearchKnowledgeBaseOperation.listTags, ] as const export const UserTableOperation = { @@ -4682,11 +5146,32 @@ 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, + [BrowserListTabs.id]: BrowserListTabs, + [BrowserNavigate.id]: BrowserNavigate, + [BrowserOpenTab.id]: BrowserOpenTab, + [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, + [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, - [CreateFileFolder.id]: CreateFileFolder, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteFile.id]: DeleteFile, @@ -4723,7 +5208,6 @@ export const TOOL_CATALOG: Record = { [Grep.id]: Grep, [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, - [ListFileFolders.id]: ListFileFolders, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -4737,42 +5221,40 @@ export const TOOL_CATALOG: Record = { [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, - [MoveFile.id]: MoveFile, - [MoveFileFolder.id]: MoveFileFolder, - [MoveWorkflow.id]: MoveWorkflow, + [Mkdir.id]: Mkdir, + [Mv.id]: Mv, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, + [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, - [RenameFile.id]: RenameFile, - [RenameFileFolder.id]: RenameFileFolder, - [RenameWorkflow.id]: RenameWorkflow, - [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, + [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, + [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, + [SearchIntegrationTools.id]: SearchIntegrationTools, + [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, - [Superagent.id]: Superagent, [Table.id]: Table, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, - [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [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 2d9678086ff..23589069f46 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,287 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['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 to extract, in plain language.', + }, + }, + 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_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:// — localhost/local dev URLs are supported).', + }, + }, + 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_press_key']: { + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: "Key or combination, e.g. 'Enter' or 'Control+A'.", + }, + }, + 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: { + 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 one viewport).', + }, + 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.', + }, + }, + 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).', + }, + }, + }, + resultSchema: undefined, + }, + ['call_integration_tool']: { + parameters: { + properties: { + arguments: { + additionalProperties: true, + description: "Inputs matching the selected operation's server-owned inputSchema.", + type: 'object', + }, + credentialId: { + description: + 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', + type: 'string', + }, + description: { + description: + 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', + type: 'string', + }, + toolId: { + description: 'Exact toolId returned by search_integration_tools.', + type: 'string', + }, + }, + required: ['toolId', 'description', 'arguments'], + type: 'object', + }, + resultSchema: undefined, + }, + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +328,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +341,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['cp']: { + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + resultSchema: undefined, + }, + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +403,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -117,7 +424,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -162,35 +469,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_workflow']: { parameters: { type: 'object', properties: { - path: { + folderPath: { type: 'string', description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - resultSchema: undefined, - }, - create_workflow: { - parameters: { - type: 'object', - properties: { - description: { - type: 'string', - description: 'Optional workflow description.', - }, - folderId: { - type: 'string', - description: 'Optional folder ID.', + 'Optional canonical workflow-folder VFS path copied from glob("workflows/**"), for example "workflows/Dream" or "workflows/Client%20Work/Intake". Omit for the workspace root.', }, name: { type: 'string', @@ -205,7 +491,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +524,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +554,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +586,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,7 +599,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -327,7 +613,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +697,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +856,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_custom_block: { + ['deploy_custom_block']: { parameters: { type: 'object', properties: { @@ -611,7 +897,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { iconUrl: { type: 'string', description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', }, inputs: { type: 'array', @@ -698,7 +984,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -814,7 +1100,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -838,7 +1124,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -854,7 +1140,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -887,7 +1173,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -919,7 +1205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -958,7 +1244,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -1002,7 +1288,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1124,7 +1410,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1183,7 +1470,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1196,7 +1483,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1291,7 +1578,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1324,6 +1612,12 @@ 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.', + default: 10, + }, title: { type: 'string', description: @@ -1334,7 +1628,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1352,7 +1646,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1452,7 +1746,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1504,7 +1799,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1589,7 +1884,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1632,7 +1928,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1747,7 +2043,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1799,7 +2096,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1820,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1842,7 +2139,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1855,7 +2152,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1868,7 +2165,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1896,14 +2193,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1928,7 +2225,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1947,7 +2244,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1960,7 +2257,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1979,7 +2276,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -2027,7 +2324,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -2040,7 +2337,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2233,19 +2530,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - resultSchema: undefined, - }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2259,14 +2544,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2279,7 +2564,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2298,7 +2583,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2315,7 +2600,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2344,7 +2629,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2424,46 +2709,31 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, 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".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, operation: { type: 'string', description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + 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. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + '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: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2515,7 +2785,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2590,7 +2860,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2623,7 +2893,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2647,7 +2917,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2660,69 +2930,64 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['mkdir']: { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', }, }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - move_file_folder: { - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, resultSchema: undefined, }, - move_workflow: { + ['mv']: { parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -2733,7 +2998,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2747,7 +3012,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2781,7 +3046,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2800,7 +3065,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2911,7 +3176,69 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['query_user_table']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: 'MongoDB-style filter for query_rows', + }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { + type: 'string', + description: 'Row ID (required for get_row)', + }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { + type: 'string', + description: 'Table ID (required for all operations)', + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + ['read']: { parameters: { type: 'object', properties: { @@ -2938,7 +3265,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -3017,90 +3344,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: - 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: 'Contains id and the new name.', - }, - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the rename succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - rename_file_folder: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'New folder name.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - resultSchema: undefined, - }, - rename_workflow: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The new name for the workflow.', - }, - workflowId: { - type: 'string', - description: 'The workflow ID to rename.', - }, - }, - required: ['workflowId', 'name'], - }, - resultSchema: undefined, - }, - research: { - parameters: { - properties: { - topic: { - description: 'The topic to research.', - type: 'string', - }, - }, - required: ['topic'], - type: 'object', - }, - resultSchema: undefined, - }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -3109,6 +3353,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { + type: 'string', + }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean', @@ -3123,7 +3375,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3141,7 +3393,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3158,7 +3410,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3190,7 +3442,100 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_code']: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3222,7 +3567,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3260,7 +3605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3303,7 +3648,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3316,7 +3661,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3337,7 +3682,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search']: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3354,7 +3713,81 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_integration_tools']: { + parameters: { + properties: { + limit: { + description: 'Maximum matches to return. Defaults to 5.', + maximum: 10, + minimum: 1, + type: 'integer', + }, + query: { + description: 'What the service operation must do, in plain language.', + type: 'string', + }, + service: { + description: + 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', + type: 'string', + }, + }, + required: ['query'], + type: 'object', + }, + resultSchema: undefined, + }, + ['search_knowledge_base']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3375,7 +3808,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3415,7 +3848,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3437,7 +3870,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3459,7 +3892,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3493,7 +3926,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3534,21 +3967,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { - parameters: { - properties: { - task: { - description: - "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - resultSchema: undefined, - }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3561,7 +3980,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3590,7 +4009,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3608,7 +4027,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3633,56 +4052,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { - type: 'number', - description: 'Number of results for search (default 10)', - }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { - type: 'string', - description: 'Search query to find relevant memories', - }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { - type: 'string', - description: 'Value to remember', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -4045,20 +4415,33 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index 9a4df7519b1..57cb552726e 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -113,7 +113,6 @@ export interface VfsSnapshotV1Table { * via the `definition` "VfsSnapshotV1Workflow". */ export interface VfsSnapshotV1Workflow { - description?: string folderPath?: string id: string isDeployed?: boolean diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index bb52291515c..8a2fbd6a6a3 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,12 +33,12 @@ import { KnowledgeBase, MaterializeFile, Media, - Research, Run, RunBlock, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -212,7 +212,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ GenerateVideo.id, Ffmpeg.id, Media.id, - Research.id, + Search.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 41e0fee863b..ff38b280cfc 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -10,6 +10,7 @@ export const MothershipResourceType = { log: 'log', integration: 'integration', generic: 'generic', + browser: 'browser', } as const export type MothershipResourceType = (typeof MothershipResourceType)[keyof typeof MothershipResourceType] @@ -22,9 +23,18 @@ export interface MothershipResource { } export function isEphemeralResource(resource: MothershipResource): boolean { - return resource.type === 'generic' || resource.id === 'streaming-file' + return ( + resource.type === 'generic' || resource.type === 'browser' || resource.id === 'streaming-file' + ) } +/** + * Singleton id for the live browser-session panel. The panel's frames live + * only in memory (pushed by the desktop app with each tool result), so the + * resource is ephemeral and never persisted to the chat. + */ +export const BROWSER_SESSION_RESOURCE_ID = 'browser-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/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 999dea91b28..56054c05812 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, CompleteScheduledTask, + Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, DeleteWorkflow, @@ -33,14 +34,14 @@ import { ManageScheduledTask, ManageSkill, MaterializeFile, - MoveWorkflow, + Mkdir as MkdirTool, + Mv as MvTool, OauthGetAuthLink, OauthRequestAccess, OpenResource, PromoteToLive, Read as ReadTool, Redeploy, - RenameWorkflow, RestoreResource, RunBlock, RunFromBlock, @@ -90,6 +91,7 @@ import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, executeDeleteWorkflow, @@ -144,9 +146,10 @@ function buildHandlerMap(): Record { [CreateWorkflow.id]: h(executeCreateWorkflow), [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [RenameWorkflow.id]: h(executeRenameWorkflow), - [MoveWorkflow.id]: h(executeMoveWorkflow), [ManageFolder.id]: h(executeManageFolder), + // Grace-period handlers for checkpoints created before mv replaced these tools. + rename_workflow: h(executeRenameWorkflow), + move_workflow: h(executeMoveWorkflow), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), @@ -178,6 +181,9 @@ function buildHandlerMap(): Record { [GrepTool.id]: h(executeVfsGrep), [GlobTool.id]: h(executeVfsGlob), [ReadTool.id]: h(executeVfsRead), + [MvTool.id]: h(executeVfsMv), + [CpTool.id]: h(executeVfsCp), + [MkdirTool.id]: h(executeVfsMkdir), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), 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 00000000000..9dd162ebcb4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -0,0 +1,192 @@ +/** + * 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' + +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 +/** 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 +} + +/** Screenshots captured this session, keyed by toolCallId, for UI display. */ +export const capturedBrowserScreenshots = new Map() + +function timeoutForTool(toolName: BrowserToolName, params: Record): number | null { + if (toolName === 'browser_request_takeover') return null + if ( + toolName === 'browser_navigate' || + toolName === 'browser_go_back' || + toolName === 'browser_go_forward' || + toolName === 'browser_open_tab' + ) { + return NAVIGATION_TOOL_TIMEOUT_MS + } + if (toolName === 'browser_wait_for') { + const requested = typeof params.timeoutMs === 'number' ? params.timeoutMs : 10_000 + return requested + WAIT_FOR_TIMEOUT_GRACE_MS + } + return DEFAULT_TOOL_TIMEOUT_MS +} + +/** + * Tool results feed the model as text; images are not supported on that path + * yet, so the screenshot's data URL is retained locally for the UI and the + * model gets a short factual note instead of half a megabyte of base64. + */ +function sanitizeResultForModel( + toolCallId: string, + toolName: BrowserToolName, + result: unknown +): Record | undefined { + if (!isRecordLike(result)) { + return result === undefined ? undefined : { value: result } + } + if (toolName === 'browser_screenshot' && typeof result.dataUrl === 'string') { + capturedBrowserScreenshots.set(toolCallId, result.dataUrl) + const { dataUrl: _dataUrl, ...rest } = result + return { + ...rest, + note: 'Screenshot captured and shown to the user. Visual inspection is not available to you in this build — use browser_snapshot or browser_read_text to inspect content.', + } + } + 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, + }) + }) +} + +async function doExecuteBrowserTool( + toolCallId: string, + toolName: BrowserToolName, + params: Record +): Promise { + // 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(toolName, params, timeoutForTool(toolName, params)) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Browser action completed', + sanitizeResultForModel(toolCallId, toolName, result) + ) + } catch (err) { + const message = toError(err).message + logger.warn('Browser tool failed', { toolCallId, toolName, error: message }) + await reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + }).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 00000000000..ef66300b447 --- /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/local-filesystem.test.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts new file mode 100644 index 00000000000..2fc7814d8c3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReportCompletion, mockRequestJson, mockRunUploadStrategy, mockUploadViaApiFallback } = + vi.hoisted(() => ({ + mockReportCompletion: vi.fn(), + mockRequestJson: vi.fn(), + mockRunUploadStrategy: vi.fn(), + mockUploadViaApiFallback: vi.fn(), + })) + +vi.mock('@/lib/copilot/tools/client/completion', () => ({ + reportClientToolCompletion: mockReportCompletion, +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) +vi.mock('@/lib/uploads/client/direct-upload', () => ({ + DirectUploadError: class DirectUploadError extends Error { + constructor( + message: string, + public code: string + ) { + super(message) + } + }, + runUploadStrategy: mockRunUploadStrategy, +})) +vi.mock('@/lib/uploads/client/api-fallback', () => ({ + uploadViaApiFallback: mockUploadViaApiFallback, +})) + +import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' + +describe('executeLocalFilesystemTool', () => { + const localFilesystem = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'simDesktop', { + configurable: true, + value: { localFilesystem }, + }) + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('executes read-only tools through the desktop bridge and reports the result', async () => { + localFilesystem.mockResolvedValue({ + ok: true, + data: { + mounts: [ + { + id: 'mount-1', + name: 'project', + uri: 'localfs://mount-1/', + remembered: true, + }, + ], + }, + }) + + executeLocalFilesystemTool('tool-1', 'local_list_mounts', {}, { workspaceId: 'ws-1' }) + + await vi.waitFor(() => { + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-1', + 'success', + 'Local filesystem tool completed.', + { + mounts: [ + { + id: 'mount-1', + name: 'project', + uri: 'localfs://mount-1/', + remembered: true, + }, + ], + } + ) + }) + }) + + it('forgets a remembered mount through the desktop bridge', async () => { + localFilesystem.mockResolvedValue({ + ok: true, + data: { forgotten: true }, + }) + + executeLocalFilesystemTool( + 'tool-forget', + 'local_forget_mount', + { uri: 'localfs://mount-1/' }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'forget_mount', + uri: 'localfs://mount-1/', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-forget', + 'success', + 'Local filesystem tool completed.', + { forgotten: true } + ) + }) + }) + + it('uploads a local file, links it to the chat, and returns the materialization handoff', async () => { + localFilesystem.mockResolvedValue({ + ok: true, + data: { + uri: 'localfs://mount-1/report.txt', + name: 'report.txt', + size: 5, + bytes: new Uint8Array([104, 101, 108, 108, 111]), + }, + }) + mockRunUploadStrategy.mockResolvedValue({ key: 'storage-key' }) + mockRequestJson.mockResolvedValue({ + success: true, + displayName: 'report.txt', + fileName: 'report.txt', + uploadPath: 'uploads/report.txt', + }) + + executeLocalFilesystemTool( + 'tool-2', + 'local_stage_file', + { uri: 'localfs://mount-1/report.txt' }, + { workspaceId: 'ws-1', chatId: 'chat-1' } + ) + + await vi.waitFor(() => { + expect(mockRequestJson).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: { workspaceId: 'ws-1', chatId: 'chat-1', key: 'storage-key' }, + }) + ) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-2', + 'success', + 'Local filesystem tool completed.', + expect.objectContaining({ + sourceUri: 'localfs://mount-1/report.txt', + uploadPath: 'uploads/report.txt', + fileName: 'report.txt', + nextStep: expect.stringContaining('materialize_file'), + }) + ) + }) + }) + + it('reports bridge errors without exposing a host path', async () => { + localFilesystem.mockResolvedValue({ + ok: false, + code: 'ACCESS_DENIED', + error: 'The requested path is outside the selected folder.', + }) + + executeLocalFilesystemTool( + 'tool-3', + 'local_read', + { uri: 'localfs://mount-1/link' }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-3', + 'error', + 'The requested path is outside the selected folder.', + { error: 'The requested path is outside the selected folder.' } + ) + }) + }) +}) 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 00000000000..4651f2abbe1 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.ts @@ -0,0 +1,195 @@ +import type { + LocalFilesystemData, + LocalFilesystemRequest, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { requestJson } from '@/lib/api/client/request' +import { stageLocalFileUploadContract } from '@/lib/api/contracts/mothership-chats' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { LOCAL_FILESYSTEM_TOOL_NAMES } from '@/lib/copilot/tools/local-filesystem' +import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' +import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' + +const logger = createLogger('CopilotLocalFilesystemTool') + +interface LocalFilesystemExecutionContext { + workspaceId: string + chatId?: string +} + +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 bridge(): NonNullable { + if (typeof window === 'undefined' || !window.simDesktop?.localFilesystem) { + throw new Error('The desktop local filesystem bridge is unavailable.') + } + return window.simDesktop +} + +function requestForTool(toolName: string, args: Record): LocalFilesystemRequest { + switch (toolName) { + case LOCAL_FILESYSTEM_TOOL_NAMES.mountDirectory: + return { operation: 'mount_directory' } + case LOCAL_FILESYSTEM_TOOL_NAMES.listMounts: + return { operation: 'list_mounts' } + case LOCAL_FILESYSTEM_TOOL_NAMES.forgetMount: + return { operation: 'forget_mount', uri: requiredString(args, 'uri') } + case LOCAL_FILESYSTEM_TOOL_NAMES.list: + return { operation: 'list', uri: requiredString(args, 'uri') } + case LOCAL_FILESYSTEM_TOOL_NAMES.glob: + return { + operation: 'glob', + uri: requiredString(args, 'uri'), + pattern: requiredString(args, 'pattern'), + } + case LOCAL_FILESYSTEM_TOOL_NAMES.read: + return { + operation: 'read', + uri: requiredString(args, 'uri'), + ...(typeof args.startLine === 'number' ? { startLine: args.startLine } : {}), + ...(typeof args.lineCount === 'number' ? { lineCount: args.lineCount } : {}), + } + case LOCAL_FILESYSTEM_TOOL_NAMES.grep: + return { + operation: 'grep', + uri: requiredString(args, 'uri'), + query: requiredString(args, 'query'), + ...(typeof args.include === 'string' ? { include: args.include } : {}), + ...(typeof args.caseSensitive === 'boolean' ? { caseSensitive: args.caseSensitive } : {}), + } + case LOCAL_FILESYSTEM_TOOL_NAMES.stat: + return { operation: 'stat', uri: requiredString(args, 'uri') } + default: + throw new Error(`Unsupported local filesystem tool: ${toolName}`) + } +} + +function successfulData(response: LocalFilesystemResponse): LocalFilesystemData { + if (!response.ok) { + throw new Error(response.error) + } + return response.data +} + +async function stageLocalFile( + args: Record, + context: LocalFilesystemExecutionContext +): Promise> { + if (!context.chatId) { + throw new Error('The chat is not ready to receive a local file yet.') + } + const uri = requiredString(args, 'uri') + const data = successfulData(await bridge().localFilesystem({ operation: 'read_file_bytes', uri })) + if (!('bytes' in data)) { + throw new Error('The desktop app returned an invalid local file response.') + } + + const bytes = data.bytes + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer + const file = new File([buffer], data.name) + const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(context.workspaceId)}` + + let uploaded: { key: string } + try { + uploaded = await runUploadStrategy({ + file, + workspaceId: context.workspaceId, + context: 'mothership', + presignedEndpoint, + }) + } catch (error) { + if (!(error instanceof DirectUploadError) || error.code !== 'FALLBACK_REQUIRED') { + throw error + } + const fallback = await uploadViaApiFallback(file, 'mothership', context.workspaceId) + if (!fallback.key) { + throw new Error('The local file upload did not return a storage key.') + } + uploaded = { key: fallback.key } + } + + const staged = await requestJson(stageLocalFileUploadContract, { + body: { + workspaceId: context.workspaceId, + chatId: context.chatId, + key: uploaded.key, + }, + }) + + return { + sourceUri: uri, + uploadPath: staged.uploadPath, + fileName: staged.fileName, + displayName: staged.displayName, + size: data.size, + nextStep: + 'Call materialize_file with fileName before passing this file to server-side tools. Use the files/... path returned by materialize_file.', + } +} + +async function execute( + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): Promise { + if (toolName === LOCAL_FILESYSTEM_TOOL_NAMES.stageFile) { + return stageLocalFile(args, context) + } + return successfulData(await bridge().localFilesystem(requestForTool(toolName, args))) +} + +export function executeLocalFilesystemTool( + toolCallId: string, + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): void { + void execute(toolName, args, context).then( + async (data) => { + 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) => { + 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, + }) + } + } + ) +} 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 44117d1195e..cd66f64031f 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/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts new file mode 100644 index 00000000000..76bfc202aca --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -0,0 +1,470 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkspaceAccess: vi.fn(), + ensureWorkflowAccess: vi.fn(), + getDefaultWorkspaceId: vi.fn(), + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + getWorkspaceFileByName: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + performMoveRenameWorkspaceFile: vi.fn(), + performUpdateWorkspaceFileFolder: vi.fn(), + performCreateFolder: vi.fn(), + performUpdateFolder: vi.fn(), + performUpdateWorkflow: vi.fn(), + duplicateWorkflow: vi.fn(), + listFolders: vi.fn(), + verifyFolderWorkspace: vi.fn(), + listTables: vi.fn(), + renameTable: vi.fn(), + getKnowledgeBases: vi.fn(), + updateKnowledgeBase: vi.fn(), + checkKnowledgeBaseWriteAccess: vi.fn(), + workflowRows: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(mocks.workflowRows()), + }), + }), + }, + workflow: { id: 'id', name: 'name', folderId: 'folderId', workspaceId: 'workspaceId' }, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, + ensureWorkflowAccess: mocks.ensureWorkflowAccess, + getDefaultWorkspaceId: mocks.getDefaultWorkspaceId, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateFolder: mocks.performCreateFolder, + performUpdateFolder: mocks.performUpdateFolder, + performUpdateWorkflow: mocks.performUpdateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/duplicate', () => ({ + duplicateWorkflow: mocks.duplicateWorkflow, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + listFolders: mocks.listFolders, + verifyFolderWorkspace: mocks.verifyFolderWorkspace, +})) + +vi.mock('@/lib/table/service', () => ({ + listTables: mocks.listTables, + renameTable: mocks.renameTable, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mocks.getKnowledgeBases, + updateKnowledgeBase: mocks.updateKnowledgeBase, +})) + +vi.mock('@/app/api/knowledge/utils', () => ({ + checkKnowledgeBaseWriteAccess: mocks.checkKnowledgeBaseWriteAccess, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext + +describe('vfs mv/cp', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) + mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.verifyFolderWorkspace.mockResolvedValue(true) + mocks.listFolders.mockResolvedValue([]) + mocks.workflowRows.mockReturnValue([]) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + }) + + describe('category rules', () => { + it('rejects cross-category moves', async () => { + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'workflows/report' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('across categories') + }) + + it('rejects uploads with a materialize_file pointer', async () => { + const result = await executeVfsMv( + { sources: ['uploads/data.csv'], destination: 'files/data.csv' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('materialize_file') + }) + + it('rejects read-only categories', async () => { + const result = await executeVfsMv( + { sources: ['components/blocks/gmail.json'], destination: 'components/blocks/g.json' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('not a movable resource') + }) + + it('aborts before mutating when the request was cancelled', async () => { + const abortedContext = { + userId: 'user-1', + workspaceId: 'ws-1', + abortSignal: { aborted: true }, + } as unknown as ExecutionContext + const result = await executeVfsMv( + { sources: ['files/a.md'], destination: 'files/b.md' }, + abortedContext + ) + expect(result.success).toBe(false) + expect(result.error).toContain('aborted') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + }) + }) + + describe('files', () => { + it('moves and renames a file in one call, auto-creating destination folders', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/Reports/2026/final.md' }, + context + ) + + expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { + folderId: null, + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + fileId: 'file-1', + targetFolderId: 'ensured-folder', + newName: 'final.md', + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], + }) + }) + + it('moves into an existing folder keeping the name without creating anything', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'a.png' }, + }) + + const result = await executeVfsMv( + { sources: ['files/a.png'], destination: 'files/Images' }, + context + ) + + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + ) + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) + }) + + it('requires a folder destination for multiple sources', async () => { + const result = await executeVfsMv( + { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('must be a folder') + }) + + it('does not create destination folders for unresolved sources', async () => { + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'files/Archive/' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Not found') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('rejects copying workspace files', async () => { + const result = await executeVfsCp( + { sources: ['files/template.md'], destination: 'files/Reports/january.md' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cp only duplicates workflows') + }) + + it('moves and renames a file folder', async () => { + mocks.findWorkspaceFileFolderIdByPath + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('folder-src') + mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ + success: true, + folder: { name: 'Reports 2025' }, + }) + + const result = await executeVfsMv( + { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, + context + ) + + expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + folderId: 'folder-src', + userId: 'user-1', + name: 'Reports 2025', + parentId: 'ensured-folder', + }) + 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', () => { + it('renames a workflow at root', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Old Name', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, + context + ) + + expect(mocks.assertWorkflowMutable).toHaveBeenCalledWith('wf-1') + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) + ) + expect(result.success).toBe(true) + }) + + it('moves a workflow into an existing folder keeping its name', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Archive', parentId: null }, + ]) + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'My Workflow', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, + context + ) + + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) + ) + expect(result.success).toBe(true) + }) + + it('surfaces locked-workflow rejections', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Locked One', folderId: null }]) + mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) + const result = await executeVfsMv( + { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + }) + + it('duplicates a workflow with cp', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Template', folderId: null }]) + mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) + + const result = await executeVfsCp( + { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, + context + ) + + expect(mocks.assertWorkflowMutable).not.toHaveBeenCalled() + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkflowId: 'wf-1', + workspaceId: 'ws-1', + folderId: null, + name: 'My Copy', + }) + ) + expect(result.success).toBe(true) + }) + + it('rejects copying workflow folders', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Projects', parentId: null }, + ]) + const result = await executeVfsCp( + { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('moves and renames a workflow folder', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Q1', parentId: null }, + { folderId: 'fold-2', folderName: 'Archive', parentId: null }, + ]) + mocks.performUpdateFolder.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, + context + ) + expect(mocks.performUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) + ) + expect(result.success).toBe(true) + }) + }) + + describe('mkdir', () => { + it('creates a nested file folder chain', async () => { + const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(result.success).toBe(true) + }) + + it('creates a workflow folder', async () => { + mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) + const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) + expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + name: 'Archive', + parentId: undefined, + }) + expect(result.success).toBe(true) + }) + + it('rejects flat namespaces and reserved paths', async () => { + const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, 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') }, + ], + }) + }) + + it('rejects creation inside a locked workflow folder', async () => { + mocks.assertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + expect(mocks.performCreateFolder).not.toHaveBeenCalled() + }) + }) + + describe('flat namespaces', () => { + it('renames a table', async () => { + mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) + mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/Customers' }, + context + ) + expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) + expect(result.success).toBe(true) + }) + + it('rejects nested table destinations', async () => { + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('flat namespace') + }) + + it('rejects copying tables', async () => { + const result = await executeVfsCp( + { sources: ['tables/Leads'], destination: 'tables/Leads Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('renames a knowledge base after a write-access check', async () => { + mocks.getKnowledgeBases.mockResolvedValue([{ id: 'kb-1', name: 'Docs' }]) + mocks.checkKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true }) + mocks.updateKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Product Docs' }) + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, + context + ) + expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') + expect(result.success).toBe(true) + }) + + it('rejects the reserved knowledgebases/connectors name', async () => { + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('reserved') + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts new file mode 100644 index 00000000000..28f574d0bd3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -0,0 +1,734 @@ +import { db, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' +import { toError } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { + ensureWorkflowAccess, + ensureWorkspaceAccess, + getDefaultWorkspaceId, +} from '@/lib/copilot/tools/handlers/access' +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + buildVfsFolderPathMap, + canonicalWorkflowVfsDir, + 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 { + ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + performCreateFolder, + performUpdateFolder, + performUpdateWorkflow, +} from '@/lib/workflows/orchestration' +import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' +import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' +import { + performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +const logger = createLogger('VfsMutateTools') + +type MutateVerb = 'mv' | 'cp' +type MutateCategory = 'files' | 'workflows' | 'tables' | 'knowledgebases' + +const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'knowledgebases']) + +const CATEGORY_REJECTIONS: Record = { + uploads: + 'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.', + 'recently-deleted': + 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', +} + +interface VfsMutateOutcome { + from: string + to?: string + kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + id?: string + error?: string +} + +function topLevelSegment(path: string): string { + return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' +} + +function classifyCategory(path: string): { category: MutateCategory } | { error: string } { + const top = topLevelSegment(path) + if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } + const rejection = CATEGORY_REJECTIONS[top] + if (rejection) return { error: rejection } + return { + error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, + } +} + +function normalizeSources(raw: unknown): string[] { + if (typeof raw === 'string') return raw.trim() ? [raw.trim()] : [] + if (!Array.isArray(raw)) return [] + return raw.filter( + (source): source is string => typeof source === 'string' && source.trim().length > 0 + ) +} + +function hasTrailingSlash(path: string): boolean { + return /\/\s*$/.test(path) +} + +function assertMutationNotAborted(context: ExecutionContext): void { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before the mutation could be applied.') + } +} + +function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { + const failed = outcomes.filter((outcome) => outcome.error) + if (failed.length === outcomes.length) { + return { + success: false, + error: failed[0]?.error || `${verb} failed`, + output: { results: outcomes }, + } + } + return { success: true, output: { results: outcomes } } +} + +export async function executeVfsMv( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('mv', params, context) +} + +export async function executeVfsCp( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('cp', params, context) +} + +export async function executeVfsMkdir( + 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 folder VFS paths)' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined + const outcomes: VfsMutateOutcome[] = [] + + for (const path of paths) { + const top = topLevelSegment(path) + const segments = decodeVfsPathSegments(path).slice(1) + const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' + + if (top !== 'files' && top !== 'workflows') { + const rejection = + top === 'tables' || top === 'knowledgebases' + ? `${top}/ is a flat namespace with no folders.` + : (CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + outcomes.push({ from: path, kind, error: rejection }) + continue + } + if (segments.length === 0) { + 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 + if (top === 'files') { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }) + } else { + ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( + workspaceId, + context.userId, + await loadWorkflowFolderIndex(workspaceId) + ) + folderId = await ensureWorkflowFolder(segments) + } + outcomes.push({ + from: path, + to: `${top}/${encodeVfsPathSegments(segments)}`, + kind, + id: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ from: path, kind, error: toError(error).message }) + } + } + + return buildResult('mkdir', outcomes) + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +async function executeVfsMutate( + verb: MutateVerb, + params: Record, + context: ExecutionContext +): Promise { + try { + const sources = normalizeSources(params.sources) + const destination = typeof params.destination === 'string' ? params.destination.trim() : '' + if (sources.length === 0) { + return { success: false, error: 'sources is required (an array of canonical VFS paths)' } + } + if (!destination) { + return { success: false, error: 'destination is required' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + const classified = classifyCategory(sources[0]) + if ('error' in classified) return { success: false, error: classified.error } + const { category } = classified + + for (const source of sources.slice(1)) { + const other = classifyCategory(source) + if ('error' in other) return { success: false, error: other.error } + if (other.category !== category) { + return { + success: false, + error: `All sources must share one category; got ${category}/ and ${other.category}/.`, + } + } + } + + if (topLevelSegment(destination) !== category) { + return { + success: false, + error: `Cannot ${verb} across categories: ${category}/ sources cannot target "${destination}". Resources stay within their category.`, + } + } + + switch (category) { + case 'files': + return await mutateWorkspaceFiles(verb, sources, destination, context, workspaceId) + case 'workflows': + return await mutateWorkflows(verb, sources, destination, context, workspaceId) + default: + return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +interface DestinationPlan { + dirMode: boolean + folderSegments: string[] + leafName?: string + ensureFolderId: () => Promise +} + +async function planDestination(args: { + destination: string + sourceCount: number + lookupFolder: (segments: string[]) => Promise + ensureFolderPath: (segments: string[]) => Promise +}): Promise { + const rest = decodeVfsPathSegments(args.destination).slice(1) + const plan = ( + dirMode: boolean, + folderSegments: string[], + leafName?: string, + knownFolderId?: string | null + ): DestinationPlan => { + let memo: Promise | undefined + return { + dirMode, + folderSegments, + leafName, + ensureFolderId: () => + (memo ??= + knownFolderId !== undefined + ? Promise.resolve(knownFolderId) + : folderSegments.length > 0 + ? args.ensureFolderPath(folderSegments) + : Promise.resolve(null)), + } + } + + if (rest.length === 0) return plan(true, [], undefined, null) + if (hasTrailingSlash(args.destination)) return plan(true, rest) + const existing = await args.lookupFolder(rest) + if (existing) return plan(true, rest, undefined, existing) + if (args.sourceCount > 1) { + return { + error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, + } + } + return plan(false, rest.slice(0, -1), rest.at(-1) as string) +} + +async function resolveFileAtExactPath( + workspaceId: string, + segments: string[] +): Promise { + const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') + if (segments.length === 1) { + return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) + if (!folderId) return null + return getWorkspaceFileByName(workspaceId, fileName, { folderId }) +} + +async function mutateWorkspaceFiles( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (verb === 'cp') { + return { + success: false, + 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, + lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), + ensureFolderPath: (segments) => + ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }), + }) + if ('error' in dest) return { success: false, error: dest.error } + + type SourceRef = + | { source: string; file: WorkspaceFileRecord } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a file or folder under files/' }) + continue + } + const file = await resolveFileAtExactPath(workspaceId, segments) + if (file) { + refs.push({ source, file }) + continue + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) + continue + } + + if ('file' in ref) { + assertMutationNotAborted(context) + const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) + const result = await performMoveRenameWorkspaceFile({ + workspaceId, + userId: context.userId, + fileId: ref.file.id, + targetFolderId: await dest.ensureFolderId(), + newName: targetName, + }) + outcomes.push( + result.success && result.file + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + } + : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } + ) + continue + } + + assertMutationNotAborted(context) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId: ref.folderId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + outcomes.push( + result.success && result.folder + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + } + : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } + ) + } + + return buildResult(verb, outcomes) +} + +interface WorkflowFolderIndex { + folderPathById: Map + folderIdByPath: Map +} + +async function loadWorkflowFolderIndex(workspaceId: string): Promise { + const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) + const folderIdByPath = new Map() + for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) + return { folderPathById, folderIdByPath } +} + +function makeWorkflowFolderEnsurer( + workspaceId: string, + userId: string, + index: WorkflowFolderIndex +): (segments: string[]) => Promise { + return async (segments) => { + let parentId: string | null = null + let pathSoFar = '' + for (const segment of segments) { + pathSoFar = pathSoFar + ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` + : encodeVfsPathSegments([segment]) + const existing = index.folderIdByPath.get(pathSoFar) + if (existing) { + parentId = existing + continue + } + await assertFolderMutable(parentId) + const created = await performCreateFolder({ + workspaceId, + userId, + name: segment, + parentId: parentId ?? undefined, + }) + if (!created.success || !created.folder) { + throw new Error(created.error || `Failed to create workflow folder "${segment}"`) + } + index.folderIdByPath.set(pathSoFar, created.folder.id) + index.folderPathById.set(created.folder.id, pathSoFar) + parentId = created.folder.id + } + return parentId + } +} + +async function mutateWorkflows( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + 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 ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) + const dest = await planDestination({ + destination, + sourceCount: sources.length, + lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, + ensureFolderPath: ensureWorkflowFolderPath, + }) + if ('error' in dest) return { success: false, error: dest.error } + if (!dest.dirMode && (dest.leafName as string).length > 200) { + return { success: false, error: 'Workflow name must be 200 characters or less' } + } + + type SourceRef = + | { source: string; workflow: (typeof workflowRows)[number] } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) + continue + } + const encoded = encodeVfsPathSegments(segments) + const workflow = workflowByPath.get(`workflows/${encoded}`) + if (workflow) { + refs.push({ source, workflow }) + continue + } + const folderId = folderIdByPath.get(encoded) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) + continue + } + + if ('workflow' in ref) { + const workflow = ref.workflow + const targetName = dest.dirMode ? workflow.name : (dest.leafName as string) + try { + assertMutationNotAborted(context) + if (verb === 'cp') { + const duplicated = await duplicateWorkflow({ + sourceWorkflowId: workflow.id, + userId: context.userId, + workspaceId, + folderId: await dest.ensureFolderId(), + name: targetName, + requestId: generateRequestId(), + }) + outcomes.push({ + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, + kind: 'workflow', + id: duplicated.id, + }) + } else { + await ensureWorkflowAccess(workflow.id, context.userId, 'write') + await assertWorkflowMutable(workflow.id) + const targetFolderId = await dest.ensureFolderId() + await assertFolderMutable(targetFolderId) + if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { + outcomes.push({ + from: ref.source, + kind: 'workflow', + error: 'Destination folder not found', + }) + continue + } + const result = await performUpdateWorkflow({ + workflowId: workflow.id, + userId: context.userId, + workspaceId, + currentName: workflow.name, + currentFolderId: workflow.folderId, + name: dest.dirMode ? undefined : targetName, + folderId: targetFolderId, + }) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, + kind: 'workflow', + id: workflow.id, + } + : { + from: ref.source, + kind: 'workflow', + error: result.error || 'Failed to move workflow', + } + ) + } + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) + } + continue + } + + if (verb === 'cp') { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Workflow folders cannot be copied.', + }) + continue + } + try { + assertMutationNotAborted(context) + await assertFolderMutable(ref.folderId) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + await assertFolderMutable(targetFolderId) + const result = await performUpdateFolder({ + folderId: ref.folderId, + workspaceId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + const finalLeaf = dest.dirMode + ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') + : (dest.leafName as string) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, + kind: 'workflow_folder', + id: ref.folderId, + } + : { + from: ref.source, + kind: 'workflow_folder', + error: result.error || 'Failed to move folder', + } + ) + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) + } + } + + return buildResult(verb, outcomes) +} + +async function renameFlatResource( + verb: MutateVerb, + category: 'tables' | 'knowledgebases', + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const label = category === 'tables' ? 'Tables' : 'Knowledge bases' + const kind = category === 'tables' ? 'table' : 'knowledge_base' + + if (verb === 'cp') { + return { success: false, error: `${label} cannot be copied — duplication is not supported.` } + } + if (sources.length > 1) { + return { success: false, error: `${label} are renamed one at a time.` } + } + + const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) + const destSegments = decodeVfsPathSegments(destination).slice(1) + if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { + return { + success: false, + error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + } + } + + const sourceName = sourceSegments[0] + const newName = destSegments[0] + const canonicalSource = normalizeVfsSegment(sourceName) + + if (category === 'tables') { + const tables = await listTables(workspaceId) + const match = tables.find((table) => normalizeVfsSegment(table.name) === canonicalSource) + if (!match) { + return { success: false, error: `Table not found at ${sources[0]}` } + } + assertMutationNotAborted(context) + const renamed = await renameTable(match.id, newName, generateRequestId()) + return buildResult(verb, [ + { + from: sources[0], + to: `tables/${normalizeVfsSegment(renamed.name)}`, + kind, + id: match.id, + }, + ]) + } + + if (newName.toLowerCase() === 'connectors') { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } + } + const knowledgeBases = await getKnowledgeBases(context.userId, workspaceId) + const match = knowledgeBases.find( + (knowledgeBase) => normalizeVfsSegment(knowledgeBase.name) === canonicalSource + ) + if (!match) { + return { success: false, error: `Knowledge base not found at ${sources[0]}` } + } + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + if (!access.hasAccess) { + return { + success: false, + error: `Write access required to rename knowledge base "${match.name}"`, + } + } + assertMutationNotAborted(context) + await updateKnowledgeBase(match.id, { name: newName }, generateRequestId()) + logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) + return buildResult(verb, [ + { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, + ]) +} 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 00000000000..c10ee7bd002 --- /dev/null +++ b/apps/sim/lib/copilot/tools/local-filesystem.ts @@ -0,0 +1,146 @@ +export const LOCAL_FILESYSTEM_TOOL_NAMES = { + mountDirectory: 'local_mount_directory', + listMounts: 'local_list_mounts', + forgetMount: 'local_forget_mount', + list: 'local_list', + glob: 'local_glob', + read: 'local_read', + grep: 'local_grep', + stat: 'local_stat', + stageFile: 'local_stage_file', +} as const + +const LOCAL_FILESYSTEM_TOOL_NAME_SET = new Set(Object.values(LOCAL_FILESYSTEM_TOOL_NAMES)) + +export function isLocalFilesystemToolName(name: string): boolean { + return LOCAL_FILESYSTEM_TOOL_NAME_SET.has(name) +} + +const uriProperty = { + type: 'string', + description: + 'An opaque localfs:// URI returned by a local filesystem tool. Never use an absolute host path.', +} + +function objectSchema( + properties: Record, + required: string[] = [] +): Record { + return { + type: 'object', + properties, + required, + additionalProperties: false, + } +} + +/** + * Request-local tools advertised only when the renderer proves that it has the + * Electron desktop bridge. They are client-executed and therefore never enter + * the static mothership catalog or any subagent allowlist. + */ +export function buildLocalFilesystemToolSchemas() { + return [ + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.mountDirectory, + description: + 'Open the native folder picker so the user can grant the desktop app read-only access to one local directory. The grant is encrypted with the operating-system keychain and remembered across app restarts when secure storage is available. Returns an opaque localfs:// root URI; it never returns the absolute host path. Call this only when no suitable remembered mount exists.', + input_schema: objectSchema({}), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.listMounts, + description: + 'List local directories the user has granted to the desktop app, including remembered grants restored after restart. Returns only display names, remembered status, and opaque localfs:// root URIs.', + input_schema: objectSchema({}), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.forgetMount, + description: + 'Forget and revoke one granted local directory. Use only when the user explicitly asks to remove access. The user must select the directory again before it can be read.', + input_schema: objectSchema({ uri: uriProperty }, ['uri']), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.list, + description: + 'List the immediate children of a granted local directory. This is read-only and returns opaque localfs:// URIs for follow-up calls.', + input_schema: objectSchema({ uri: uriProperty }, ['uri']), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.glob, + description: + 'Find files and directories below a granted local directory using a relative glob such as **/*.ts. Results are bounded and symlinked directories are not traversed.', + input_schema: objectSchema( + { + uri: uriProperty, + pattern: { + type: 'string', + description: 'Relative glob pattern within uri. Absolute paths and .. are rejected.', + }, + }, + ['uri', 'pattern'] + ), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.read, + description: + 'Read a bounded line window from a UTF-8 local text file. Binary and large files must be staged with local_stage_file instead.', + input_schema: objectSchema( + { + uri: uriProperty, + startLine: { + type: 'integer', + minimum: 1, + description: 'One-based first line. Defaults to 1.', + }, + lineCount: { + type: 'integer', + minimum: 1, + maximum: 2000, + description: 'Maximum lines to return. Defaults to 500.', + }, + }, + ['uri'] + ), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.grep, + description: + 'Search text files below a granted local directory for a literal string. Results and scanned files are bounded; binary, large, and symlinked files are skipped.', + input_schema: objectSchema( + { + uri: uriProperty, + query: { type: 'string', description: 'Literal text to find.' }, + include: { + type: 'string', + description: 'Optional relative file glob such as **/*.md. Defaults to **/*.', + }, + caseSensitive: { + type: 'boolean', + description: 'Use case-sensitive matching. Defaults to false.', + }, + }, + ['uri', 'query'] + ), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.stat, + description: 'Return read-only metadata for one granted localfs:// file or directory.', + input_schema: objectSchema({ uri: uriProperty }, ['uri']), + executeLocally: true, + }, + { + name: LOCAL_FILESYSTEM_TOOL_NAMES.stageFile, + description: + 'Upload one granted local file into this chat and return its uploads/... VFS path. This does not make the file durable. Before passing it to server-side tools such as function_execute or generate_image, call materialize_file with the returned fileName, then use the returned files/... path.', + input_schema: objectSchema({ uri: uriProperty }, ['uri']), + executeLocally: true, + }, + ] +} 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 109256e23e4..a3845ff04f3 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - CreateFileFolder, - DeleteFileFolder, - ListFileFolders, - MoveFile, - MoveFileFolder, - RenameFileFolder, -} from '@/lib/copilot/generated/tool-catalog-v1' +import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -192,7 +185,7 @@ function folderLabel(folder: WorkspaceFileFolderRecord): string { } export const listFileFoldersServerTool: BaseServerTool = { - name: ListFileFolders.id, + name: 'list_file_folders', async execute( params: ListFileFoldersArgs, context?: ServerToolContext @@ -215,7 +208,7 @@ export const listFileFoldersServerTool: BaseServerTool = { - name: CreateFileFolder.id, + name: 'create_file_folder', async execute( params: CreateFileFolderArgs, context?: ServerToolContext @@ -285,7 +278,7 @@ export const createFileFolderServerTool: BaseServerTool = { - name: RenameFileFolder.id, + name: 'rename_file_folder', async execute( params: RenameFileFolderArgs, context?: ServerToolContext @@ -341,7 +334,7 @@ export const renameFileFolderServerTool: BaseServerTool = { - name: MoveFileFolder.id, + name: 'move_file_folder', async execute( params: MoveFileFolderArgs, context?: ServerToolContext @@ -450,7 +443,7 @@ export const deleteFileFolderServerTool: BaseServerTool = { - name: MoveFile.id, + name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index 10bac242eca..2a2efe0f700 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { RenameFile } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -32,7 +31,8 @@ interface RenameFileResult { } export const renameFileServerTool: BaseServerTool = { - name: RenameFile.id, + // Grace-period executor for checkpoints created before mv replaced rename_file. + name: 'rename_file', async execute(params: RenameFileArgs, context?: ServerToolContext): Promise { if (!context?.userId) { throw new Error('Authentication required') diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 9dd8c737dde..77763a5137b 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -3,7 +3,6 @@ import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateFile, - CreateFileFolder, DeleteFile, DeleteFileFolder, DownloadToWorkspaceFile, @@ -16,10 +15,6 @@ import { ManageCustomTool, ManageMcpTool, ManageSkill, - MoveFile, - MoveFileFolder, - RenameFile, - RenameFileFolder, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -130,12 +125,12 @@ const WRITE_ACTIONS: Record = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], - [RenameFile.id]: ['*'], + rename_file: ['*'], [DeleteFile.id]: ['*'], - [MoveFile.id]: ['*'], - [CreateFileFolder.id]: ['*'], - [RenameFileFolder.id]: ['*'], - [MoveFileFolder.id]: ['*'], + move_file: ['*'], + create_file_folder: ['*'], + rename_file_folder: ['*'], + move_file_folder: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 099503901d1..c355721a6bc 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -50,11 +50,54 @@ function operationTitle( return labels[operation] ?? 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}`) } +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +/** + * Derive whether an mv call is a rename or a move from its canonical VFS paths. + * A single source with an unchanged parent and changed leaf is a rename. + */ +export function mvDisplayVerb( + source: string | undefined, + destination: string | undefined +): 'Renaming' | 'Moving' { + if (!source || !destination || /\/\s*$/.test(destination)) return 'Moving' + const segments = (path: string) => + path + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .map(decodePathSegment) + const src = segments(source) + const dst = segments(destination) + if (src.length < 2 || dst.length < 2) return 'Moving' + const sameParent = src.slice(0, -1).join('/') === dst.slice(0, -1).join('/') + const leafChanged = src.at(-1) !== dst.at(-1) + return sameParent && leafChanged ? 'Renaming' : 'Moving' +} + function workspaceFileTitle(args: ToolArgs): string { const title = stringArg(args, 'title') if (!title) return '' @@ -114,6 +157,15 @@ const TOOL_TITLES: Record = { get_workflow_run_options: 'Getting run options', list_file_folders: 'Listing folders', list_integration_tools: 'Listing integrations', + local_glob: 'Finding local files', + local_grep: 'Searching local files', + local_forget_mount: 'Forgetting local folder', + local_list: 'Listing local files', + local_list_mounts: 'Listing local folders', + local_mount_directory: 'Selecting local folder', + local_read: 'Reading local file', + local_stage_file: 'Preparing local file', + local_stat: 'Inspecting local file', list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', @@ -138,6 +190,19 @@ 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_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', @@ -149,6 +214,7 @@ const TOOL_TITLES: Record = { agent: 'Tools Agent', research: 'Research Agent', media: 'Media Agent', + browser: 'Browser Agent', superagent: 'Executing action', } @@ -193,6 +259,21 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Finding ${target}` : 'Finding files' } + case 'mv': { + const sources = stringArrayArg(args, 'sources') + const verb = + sources.length === 1 ? mvDisplayVerb(sources[0], stringArg(args, 'destination')) : 'Moving' + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `${verb} ${target}` : verb + } + case 'cp': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Duplicating ${target}` : 'Duplicating workflow' + } + case 'mkdir': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Creating ${target}` : 'Creating folder' + } case 'enrichment_run': { const subject = nestedStringArg( args, @@ -209,6 +290,34 @@ 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_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' @@ -295,6 +404,8 @@ const COMPLETED_VERB_REWRITES: Record = { Adding: 'Added', Applying: 'Applied', Checking: 'Checked', + Clicking: 'Clicked', + Closing: 'Closed', Comparing: 'Compared', Completing: 'Completed', Crawling: 'Crawled', @@ -304,16 +415,20 @@ const COMPLETED_VERB_REWRITES: Record = { Downloading: 'Downloaded', Editing: 'Edited', Executing: 'Executed', + Extracting: 'Extracted', Finding: 'Found', Gathering: 'Gathered', Generating: 'Generated', Getting: 'Got', + Going: 'Went', + Hovering: 'Hovered', Listing: 'Listed', Loading: 'Loaded', Managing: 'Managed', Moving: 'Moved', Opening: 'Opened', Preparing: 'Prepared', + Pressing: 'Pressed', Processing: 'Processed', Promoting: 'Promoted', Querying: 'Queried', @@ -323,12 +438,19 @@ const COMPLETED_VERB_REWRITES: Record = { Requesting: 'Requested', Restoring: 'Restored', Running: 'Ran', + Scanning: 'Scanned', Scraping: 'Scraped', + Scrolling: 'Scrolled', Searching: 'Searched', + Selecting: 'Selected', Setting: 'Set', + Switching: 'Switched', + Taking: 'Took', Toggling: 'Toggled', + Typing: 'Typed', Updating: 'Updated', Validating: 'Validated', + Waiting: 'Waited', Writing: 'Wrote', } diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 460f6ba36dd..fc161df57ae 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -99,20 +99,33 @@ export function parseWorkspaceFileCreatePath(path: string): { async function resolveCreateTarget( workspaceId: string, - path: string + path: string, + createFolders?: { userId: string } ): Promise { const parsed = parseWorkspaceFileCreatePath(path) - const folderId = - parsed.folderSegments.length > 0 - ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) - : null - - if (parsed.folderSegments.length > 0 && !folderId) { - throw new Error( - `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` - ) + let folderId: string | null = null + if (parsed.folderSegments.length > 0) { + if (createFolders) { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: createFolders.userId, + pathSegments: parsed.folderSegments, + }) + if (!folderId) { + throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) + } + } else { + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, + } + } + } } const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) @@ -388,7 +401,9 @@ export async function writeWorkspaceFileByPath(args: { } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { + userId: args.userId, + }) const uploaded = await uploadWorkspaceFile( args.workspaceId, args.userId, 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 5a8e47f72b9..d854f0c8f63 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 @@ -452,6 +452,11 @@ export async function ensureWorkspaceFileFolderPath(params: { }): Promise { if (params.pathSegments.length === 0) return null + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { + includeReservedSystemFolders: true, + }) + if (existing) return existing + // Load all active folders once and build a lookup keyed by "name|parentId" // so we can resolve existing segments without a per-segment SELECT. const existingFolders = await db 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 e84d4c4dc72..780dd6bbb21 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,7 +9,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import { decrementStorageUsageForBillingContextInTx, @@ -577,7 +577,8 @@ export const CHAT_DISPLAY_NAME_INDEX = 'workspace_files_chat_display_name_unique /** * Track a file that was already uploaded to workspace S3 as a chat-scoped upload. * Links the existing workspaceFiles metadata record (created by the storage service - * during upload) to the chat by setting chatId and context='mothership'. + * during upload) to the same user's chat by setting chatId and context='mothership'. + * A row already linked to a different chat is never re-parented. * Falls back to inserting a new record if none exists for the key. * * Allocates a collision-free `displayName` (the partial unique index on @@ -603,6 +604,8 @@ export async function trackChatUpload( and( eq(workspaceFiles.key, s3Key), eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.userId, userId), + or(isNull(workspaceFiles.chatId), eq(workspaceFiles.chatId, chatId)), isNull(workspaceFiles.deletedAt) ) ) @@ -1192,6 +1195,71 @@ export async function renameWorkspaceFile( } } +/** + * Move and/or rename a workspace file in one row update. + */ +export async function moveRenameWorkspaceFile(params: { + workspaceId: string + fileId: string + targetFolderId: string | null + newName: string +}): Promise<{ file: WorkspaceFileRecord; renamed: boolean; moved: boolean }> { + const normalizedName = normalizeWorkspaceFileItemName(params.newName.trim(), 'File') + const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) + if (!fileRecord) { + throw new Error('File not found') + } + + const targetFolderId = await assertWorkspaceFileFolderTarget( + params.workspaceId, + params.targetFolderId + ) + const currentFolderId = fileRecord.folderId ?? null + const renamed = fileRecord.name !== normalizedName + const moved = currentFolderId !== targetFolderId + if (!renamed && !moved) { + return { file: fileRecord, renamed, moved } + } + + if (await fileExistsInWorkspace(params.workspaceId, normalizedName, targetFolderId)) { + throw new FileConflictError(normalizedName) + } + + let updated: { id: string }[] + try { + updated = await db + .update(workspaceFiles) + .set({ originalName: normalizedName, folderId: targetFolderId, updatedAt: new Date() }) + .where( + and( + eq(workspaceFiles.id, params.fileId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace') + ) + ) + .returning({ id: workspaceFiles.id }) + } catch (error: unknown) { + if (getPostgresErrorCode(error) === '23505') { + throw new FileConflictError(normalizedName) + } + throw error + } + + if (updated.length === 0) { + throw new Error('File not found or could not be moved') + } + + return { + file: { + ...fileRecord, + name: normalizedName, + folderId: targetFolderId, + }, + renamed, + moved, + } +} + /** * Soft delete a workspace file. */ diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..ce11157ccb7 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -5,6 +5,7 @@ import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, FileConflictError, + moveRenameWorkspaceFile, moveWorkspaceFileItems, renameWorkspaceFile, restoreWorkspaceFile, @@ -307,6 +308,72 @@ export async function performRenameWorkspaceFile( } } +export interface PerformMoveRenameWorkspaceFileParams { + workspaceId: string + userId: string + fileId: string + targetFolderId: string | null + newName: string +} + +export interface PerformMoveRenameWorkspaceFileResult { + success: boolean + error?: string + errorCode?: WorkspaceFilesOrchestrationErrorCode + file?: WorkspaceFileRecord +} + +export async function performMoveRenameWorkspaceFile( + params: PerformMoveRenameWorkspaceFileParams +): Promise { + const { workspaceId, userId, fileId, targetFolderId, newName } = params + + try { + const { file, renamed, moved } = await moveRenameWorkspaceFile({ + workspaceId, + fileId, + targetFolderId, + newName, + }) + logger.info('Moved/renamed workspace file', { workspaceId, fileId, renamed, moved }) + + if (moved) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Moved file "${file.name}"${targetFolderId ? ' to folder' : ' to root'}`, + metadata: { targetFolderId }, + }) + } + if (renamed) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Renamed file to "${file.name}"`, + }) + } + + return { success: true, file } + } catch (error) { + logger.error('Failed to move/rename workspace file', { error }) + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + if (toError(error).message.includes('not found')) { + return { success: false, error: toError(error).message, errorCode: 'not_found' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + export async function performRestoreWorkspaceFile( params: PerformRestoreWorkspaceFileParams ): Promise { diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 5e5268bda8c..81c7af23352 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -3,6 +3,8 @@ export { type PerformCreateWorkspaceFileFolderResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformMoveRenameWorkspaceFileParams, + type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, type PerformMoveWorkspaceFileItemsResult, type PerformRenameWorkspaceFileParams, @@ -15,6 +17,7 @@ export { type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, performDeleteWorkspaceFileItems, + performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, performRenameWorkspaceFile, performRestoreWorkspaceFile, diff --git a/apps/sim/package.json b/apps/sim/package.json index 398d3d03e04..0b4effad33e 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -99,6 +99,8 @@ "@react-email/components": "0.5.7", "@react-email/render": "2.0.8", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts new file mode 100644 index 00000000000..7ef45d3be2d --- /dev/null +++ b/apps/sim/stores/browser-session/store.ts @@ -0,0 +1,27 @@ +import type { BrowserPageState } 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 + /** False after the browser session ends; true again when a new one starts. */ + sessionAlive: boolean + setPageState: (state: BrowserPageState) => void + setSessionAlive: (alive: boolean) => void + reset: () => void +} + +const initialState = { pageState: null as BrowserPageState | null, sessionAlive: true } + +export const useBrowserSessionStore = create()( + devtools( + (set) => ({ + ...initialState, + setPageState: (pageState) => set({ pageState, sessionAlive: true }), + setSessionAlive: (alive) => set({ sessionAlive: alive }), + reset: () => set(initialState), + }), + { name: 'browser-session-store' } + ) +) diff --git a/apps/sim/types/sim-desktop.d.ts b/apps/sim/types/sim-desktop.d.ts new file mode 100644 index 00000000000..902bc65d35e --- /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/bun.lock b/bun.lock index e6e8204c66b..1984386152b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,8 @@ "name": "@sim/desktop", "version": "0.0.0", "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/logger": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", @@ -187,6 +189,8 @@ "@react-email/components": "0.5.7", "@react-email/render": "2.0.8", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", @@ -369,6 +373,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", @@ -406,6 +419,17 @@ "typescript": "^7.0.2", }, }, + "packages/desktop-bridge": { + "name": "@sim/desktop-bridge", + "version": "0.1.0", + "dependencies": { + "@sim/browser-protocol": "workspace:*", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^7.0.2", + }, + }, "packages/emcn": { "name": "@sim/emcn", "version": "0.1.0", @@ -1618,10 +1642,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"], @@ -3240,7 +3268,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4712,11 +4740,9 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "@sim/browser-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - - "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -4940,10 +4966,14 @@ "fumadocs-openapi/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=="], + "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@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=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], @@ -5116,8 +5146,6 @@ "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "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=="], "simple-update-notifier/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], diff --git a/packages/browser-protocol/package.json b/packages/browser-protocol/package.json new file mode 100644 index 00000000000..486787e4170 --- /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 00000000000..31868c27768 --- /dev/null +++ b/packages/browser-protocol/src/index.ts @@ -0,0 +1,124 @@ +/** + * 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_go_back', + 'browser_go_forward', + 'browser_open_tab', + 'browser_switch_tab', + 'browser_close_tab', + 'browser_list_tabs', + '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] + +const BROWSER_TOOL_NAME_SET: ReadonlySet = new Set(BROWSER_TOOL_NAMES) + +export function isBrowserToolName(name: string): name is BrowserToolName { + return BROWSER_TOOL_NAME_SET.has(name) +} + +/** 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 +} + +/** + * Browser-chrome commands from the panel header (URL bar, back/forward, + * reload). Page interactions need no protocol — the user acts on the real + * embedded page directly. + */ +export interface BrowserPanelAction { + action: 'navigate' | 'reload' | 'back' | 'forward' + /** Absolute URL for `navigate` (typed into the panel's URL bar). */ + url?: string +} + +/** Live state of the active page, pushed to the panel header. */ +export interface BrowserPageState { + url: string + title: string + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +/** + * One interactive element from a `browser_snapshot`, identified by the + * numeric id the model passes back to browser_click / browser_type / etc. + */ +export interface SnapshotElement { + id: number + role: string + name: string + value?: string + href?: string + disabled?: boolean + checked?: boolean +} + +export interface SnapshotResult { + url: string + title: string + /** + * Structural outline of the page: headings and landmarks interleaved with + * interactive elements, each interactive line carrying its [ref=N] id. + */ + outline: string + /** True when the element list was cut off at the collection cap. */ + truncated: boolean + scrollY: number + pageHeight: number + viewportHeight: number +} + +export interface BrowserTabInfo { + tabId: string + title: string + url: string + active: boolean +} diff --git a/packages/browser-protocol/tsconfig.json b/packages/browser-protocol/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /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/desktop-bridge/package.json b/packages/desktop-bridge/package.json new file mode 100644 index 00000000000..2956a1c0d1d --- /dev/null +++ b/packages/desktop-bridge/package.json @@ -0,0 +1,32 @@ +{ + "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" + } + }, + "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:*" + }, + "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 00000000000..b54c2fcec00 --- /dev/null +++ b/packages/desktop-bridge/src/index.ts @@ -0,0 +1,146 @@ +import type { + BrowserPageState, + BrowserPanelAction, + BrowserPanelBounds, + BrowserToolName, + BrowserToolResponse, +} from '@sim/browser-protocol' + +export interface DesktopUpdateStatus { + state: string + version?: string +} + +/** + * 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(tool: BrowserToolName, params: Record): Promise + /** Browser-chrome commands from the panel header (URL bar, back, reload). */ + panelAction(action: BrowserPanelAction): 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. + */ + setPanelBounds(bounds: BrowserPanelBounds | null): void + /** Subscribe to live page state for the panel header. Returns an unsubscribe function. */ + onPageState(callback: (state: BrowserPageState) => void): () => void + /** + * Subscribe to session liveness changes (false when the browser session + * ends). Returns an unsubscribe function. + */ + onSessionStatus(callback: (alive: boolean) => 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 + column: number + text: string +} + +export type LocalFilesystemRequest = + | { operation: 'mount_directory' } + | { operation: 'list_mounts' } + | { operation: 'forget_mount'; uri: string } + | { operation: 'list'; uri: string } + | { operation: 'glob'; uri: string; pattern: string } + | { + operation: 'read' + uri: string + startLine?: number + lineCount?: number + } + | { + operation: 'grep' + uri: string + query: string + include?: string + caseSensitive?: boolean + } + | { operation: 'stat'; uri: string } + | { operation: 'read_file_bytes'; uri: string } + +export type LocalFilesystemData = + | { mount: LocalFilesystemMount | null; cancelled: boolean } + | { mounts: LocalFilesystemMount[] } + | { forgotten: boolean } + | { entries: LocalFilesystemEntry[]; truncated: boolean } + | { matches: LocalFilesystemGrepMatch[]; truncated: boolean } + | LocalFilesystemReadResult + | LocalFilesystemStat + | { uri: string; name: string; size: number; bytes: Uint8Array } + +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' + | 'IO_ERROR' + error: string + } + +export interface SimDesktopApi { + getAppVersion(): Promise + openExternal(url: string): Promise + requestMicrophonePermission(): Promise + onUpdateStatus(callback: (status: DesktopUpdateStatus) => void): () => void + offlineRetry(): void + openSettings(): void + settingsClose(): void + settingsGet(): Promise<{ origin: string; isDefault: boolean } | null> + settingsSave(origin: string): Promise<{ ok: boolean; error?: string }> + localFilesystem(request: LocalFilesystemRequest): Promise + browserAgent?: SimDesktopBrowserAgentApi +} diff --git a/packages/desktop-bridge/tsconfig.json b/packages/desktop-bridge/tsconfig.json new file mode 100644 index 00000000000..f24122d580b --- /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"] +} From a8da276e69d61d881eb7f8346a36581951736dcb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 18 Jul 2026 14:58:41 -0700 Subject: [PATCH 004/109] update --- .../desktop/src/main/browser-agent/session.ts | 42 +++++++++++------ .../[workspaceId]/home/hooks/use-chat.ts | 16 +++++-- apps/sim/lib/copilot/chat/post.ts | 46 +++++++++++++++---- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 341f8e95a85..a1a7cc22425 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -120,38 +120,51 @@ export function hasSession(): boolean { return tabs.some((tab) => !tab.view.webContents.isDestroyed()) } +/** The view currently 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 = '' + /** * Repositions the active view over the panel rect inside the main window * (re-parenting if the main window was recreated), and detaches it when the * panel is hidden. CSS pixels scale to DIP by the main page's zoom factor. + * Idempotent: repeated calls with unchanged inputs perform no view mutations. */ function layout(): void { const win = getMainWindow() const active = activeTab() + const showing = active !== null && panelBounds !== null && win !== null - // Detach every view that should not be showing. - for (const tab of tabs) { - if (tab !== active || panelBounds === null || win === null) { - hostedWindow?.contentView.removeChildView(tab.view) - win?.contentView.removeChildView(tab.view) + if (!showing || hostedWindow !== win || attachedView !== active?.view) { + if (attachedView) { + hostedWindow?.contentView.removeChildView(attachedView) + attachedView = null + lastAppliedBounds = '' } } - if (!active || panelBounds === null || win === null) { + if (!showing || !active || !win || panelBounds === null) { return } - if (hostedWindow !== win) { + if (attachedView !== active.view) { + win.contentView.addChildView(active.view) hostedWindow = win + attachedView = active.view } - win.contentView.addChildView(active.view) const zoom = win.webContents.getZoomFactor() - active.view.setBounds({ + const bounds = { x: Math.round(panelBounds.x * zoom), y: Math.round(panelBounds.y * zoom), width: Math.max(1, Math.round(panelBounds.width * zoom)), height: Math.max(1, Math.round(panelBounds.height * zoom)), - }) - active.view.setVisible(true) + } + const boundsKey = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}` + if (boundsKey !== lastAppliedBounds) { + lastAppliedBounds = boundsKey + active.view.setBounds(bounds) + active.view.setVisible(true) + } } /** Renderer-reported panel rect (null = panel hidden/unmounted). */ @@ -214,8 +227,11 @@ export function closeTab(tabId: string): void { 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.splice(index, 1) - hostedWindow?.contentView.removeChildView(tab.view) - getMainWindow()?.contentView.removeChildView(tab.view) + if (attachedView === tab.view) { + hostedWindow?.contentView.removeChildView(tab.view) + attachedView = null + lastAppliedBounds = '' + } tab.view.webContents.close() if (activeTabId === tab.id) { activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a46ce3a38af..6b0faa50590 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -90,6 +90,7 @@ import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-so import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' +import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useExecutionStore } from '@/stores/execution/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { @@ -3288,17 +3289,22 @@ export function useChat( abortControllerRef.current = abortController const currentActiveId = activeResourceIdRef.current - // The live browser panel is a client-side surface, not a - // server-resolvable resource — it must never ride resourceAttachments - // (the request schema rejects it, failing the whole send). - const currentResources = resourcesRef.current.filter((r) => r.type !== 'browser') + // The live browser panel's page state is client-held (the desktop + // app's embedded browser): its attachment carries the current URL and + // title so the server can inject them as @open_tab/@active_tab + // context. With no page loaded there is nothing to say — drop it. + const browserPageState = useBrowserSessionStore.getState().pageState + const currentResources = resourcesRef.current.filter( + (r) => r.type !== 'browser' || Boolean(browserPageState?.url) + ) const resourceAttachments = currentResources.length > 0 ? currentResources.map((r) => ({ type: r.type, id: r.id, - title: r.title, + title: r.type === 'browser' ? browserPageState?.title?.trim() || r.title : r.title, active: r.id === currentActiveId, + ...(r.type === 'browser' ? { url: browserPageState?.url } : {}), })) : undefined diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index e19cfdc6ca4..d03c215b264 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -82,10 +82,17 @@ const ResourceAttachmentSchema = z.object({ 'log', 'scheduledtask', 'generic', + 'browser', ]), 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. + */ + url: z.string().max(2048).optional(), }) const GENERIC_RESOURCE_TITLE: Record['type'], string> = { @@ -99,6 +106,12 @@ const GENERIC_RESOURCE_TITLE: Record['t log: 'Log', scheduledtask: 'Scheduled Task', generic: 'Resource', + browser: 'Browser', +} + +/** Ephemeral client-side panels are context-only: never persisted to the chat. */ +function isPersistableAttachment(resource: z.infer): boolean { + return resource.type !== 'browser' } const ChatContextSchema = z.object({ @@ -282,6 +295,20 @@ 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 live browser panel (driven by the browser subagent) is open on: ${ + title ? `"${title}" — ` : '' + }${resource.url}`, + } + } const ctx = await resolveActiveResourceContext( resource.type, resource.id, @@ -854,14 +881,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 From 68f5857bc8c36405b280bbeffdd2a922e6d7377f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 18 Jul 2026 15:10:25 -0700 Subject: [PATCH 005/109] local filesystem fixes --- .../components/special-tags/special-tags.tsx | 84 +++++++++++++++++++ .../sim/lib/copilot/tools/local-filesystem.ts | 3 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 50eb261d7a9..7049d6cd188 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -66,6 +66,7 @@ export const CREDENTIAL_TAG_TYPES = [ 'credential_id', 'link', 'secret_input', + 'folder_access', ] as const export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number] @@ -195,6 +196,11 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } + // folder_access is a value-less action chip (optional `name` hints at the + // folder the user is being asked to grant, e.g. "Desktop"). + if (value.type === 'folder_access') { + return value.name === undefined || typeof value.name === 'string' + } if (value.redacted === true) return value.value === undefined || typeof value.value === 'string' return typeof value.value === 'string' } @@ -734,6 +740,80 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +/** + * Folder icon for the local-folder grant chip (matches the credential chip + * icon sizing). + */ +const FolderGrantIcon = ({ className }: { className?: string }) => ( + + + +) + +/** + * Inline grant chip rendered for + * `{"type":"folder_access","name":"Desktop"}`. + * Clicking opens the desktop app's native folder picker (read-only grant, + * same flow as the local_mount_directory tool). Renders nothing outside the + * desktop app — there is no local filesystem bridge to grant against. + */ +function FolderAccessDisplay({ data }: { data: CredentialTagData }) { + const [picking, setPicking] = useState(false) + const [grantedName, setGrantedName] = useState(null) + + const bridge = typeof window !== 'undefined' ? window.simDesktop : undefined + if (!bridge?.localFilesystem) return null + + const hint = (data.name ?? '').trim() + const label = grantedName + ? `Access granted — ${grantedName}` + : hint + ? `Grant access to ${hint}` + : 'Grant access to a local folder' + + const handleClick = async () => { + if (picking || grantedName) return + setPicking(true) + try { + const response = await bridge.localFilesystem({ operation: 'mount_directory' }) + if (response.ok && 'mount' in response.data && response.data.mount) { + setGrantedName(response.data.mount.name) + toast.success(`Granted access to ${response.data.mount.name}`) + } + } catch { + toast.error("Couldn't open the folder picker. Please try again.") + } finally { + setPicking(false) + } + } + + return ( + + ) +} + function CredentialDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() @@ -741,6 +821,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { return } + if (data.type === 'folder_access') { + return + } + if (data.type === 'link') { // Connecting a credential mutates the workspace — hide it from read-only members. if (!data.provider || !canEdit) return null diff --git a/apps/sim/lib/copilot/tools/local-filesystem.ts b/apps/sim/lib/copilot/tools/local-filesystem.ts index c10ee7bd002..6ffa024529a 100644 --- a/apps/sim/lib/copilot/tools/local-filesystem.ts +++ b/apps/sim/lib/copilot/tools/local-filesystem.ts @@ -44,7 +44,8 @@ export function buildLocalFilesystemToolSchemas() { { name: LOCAL_FILESYSTEM_TOOL_NAMES.mountDirectory, description: - 'Open the native folder picker so the user can grant the desktop app read-only access to one local directory. The grant is encrypted with the operating-system keychain and remembered across app restarts when secure storage is available. Returns an opaque localfs:// root URI; it never returns the absolute host path. Call this only when no suitable remembered mount exists.', + 'Open the native folder picker so the user can grant the desktop app read-only access to one local directory. The grant is encrypted with the operating-system keychain and remembered across app restarts when secure storage is available. Returns an opaque localfs:// root URI; it never returns the absolute host path. ' + + 'PREFER THE INLINE GRANT CHIP over calling this tool: when the user asks about their local files (their desktop, downloads, a folder on their machine) and local_list_mounts shows no suitable mount, respond with ONE short sentence plus `{"type":"folder_access","name":"Desktop"}` (set "name" to the folder they mentioned, or omit it) — this renders a grant button the user clicks, exactly like a credential connect chip. Never reply that you lack access to local files, and never make the user ask twice: check mounts, then emit the chip. Call this tool directly only when the user has just explicitly asked you to open the picker.', input_schema: objectSchema({}), executeLocally: true, }, From 0b00bf67cb242610eb764d6d62e014955df060de Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 18 Jul 2026 16:53:30 -0700 Subject: [PATCH 006/109] Huge --- apps/desktop/src/main/browser-agent/cdp.ts | 35 +- .../src/main/browser-agent/driver.test.ts | 66 +++ apps/desktop/src/main/browser-agent/driver.ts | 179 ++++++- .../src/main/browser-agent/page-functions.ts | 67 +++ apps/desktop/src/main/config.ts | 3 + apps/desktop/src/main/index.ts | 104 ++++ apps/desktop/src/main/ipc.test.ts | 73 +++ apps/desktop/src/main/ipc.ts | 83 ++++ apps/desktop/src/main/launcher-window.test.ts | 166 +++++++ apps/desktop/src/main/launcher-window.ts | 218 ++++++++ apps/desktop/src/main/observability.ts | 1 + apps/desktop/src/main/settings-window.ts | 2 +- apps/desktop/src/main/shortcuts.test.ts | 81 +++ apps/desktop/src/main/shortcuts.ts | 103 ++++ apps/desktop/src/main/tray.test.ts | 153 ++++++ apps/desktop/src/main/tray.ts | 205 ++++++++ apps/desktop/src/preload/index.ts | 26 + apps/desktop/src/test/electron-mock.ts | 44 ++ apps/desktop/static/settings.html | 66 +++ apps/desktop/static/tray/simTemplate.png | Bin 0 -> 504 bytes apps/desktop/static/tray/simTemplate@2x.png | Bin 0 -> 926 bytes apps/sim/app/desktop/launcher/launcher.tsx | 469 ++++++++++++++++++ apps/sim/app/desktop/launcher/page.tsx | 17 + .../app/desktop/launcher/use-launcher-chat.ts | 222 +++++++++ .../browser-session/browser-session.tsx | 85 +++- .../lib/copilot/generated/tool-catalog-v1.ts | 10 +- .../lib/copilot/generated/tool-schemas-v1.ts | 4 +- packages/desktop-bridge/src/index.ts | 32 ++ 28 files changed, 2472 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/main/launcher-window.test.ts create mode 100644 apps/desktop/src/main/launcher-window.ts create mode 100644 apps/desktop/src/main/shortcuts.test.ts create mode 100644 apps/desktop/src/main/shortcuts.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/static/tray/simTemplate.png create mode 100644 apps/desktop/static/tray/simTemplate@2x.png create mode 100644 apps/sim/app/desktop/launcher/launcher.tsx create mode 100644 apps/sim/app/desktop/launcher/page.tsx create mode 100644 apps/sim/app/desktop/launcher/use-launcher-chat.ts diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index 30be14598d8..a9c9d2fead2 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -1,9 +1,12 @@ /** * CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles * the page states that would otherwise wedge automation (JS dialogs, file - * choosers) and captures screenshots that work even while the view is hidden. - * The user sees and drives the real embedded page, so there is no screencast - * and no synthetic input here. + * 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 { createLogger } from '@sim/logger' import type { WebContents } from 'electron' @@ -89,3 +92,29 @@ export async function captureScreenshot(contents: WebContents): Promise }) 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/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 644166534b0..aadd0eb1585 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -36,6 +36,72 @@ describe('parseKeyCombo', () => { }) }) +describe('buildKeyDispatchPlan', () => { + let driver: DriverModule + + beforeEach(async () => { + driver = await freshDriver() + }) + + it('carries text for printable keys so Blink inserts the character', () => { + const [down, up] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('a'), 'linux') + expect(down).toMatchObject({ type: 'keyDown', text: 'a', key: 'a', modifiers: 0 }) + expect(up).toMatchObject({ type: 'keyUp', key: 'a' }) + }) + + it('sends Enter with a carriage return so defaults fire (form submit)', () => { + const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Enter'), 'linux') + expect(down).toMatchObject({ type: 'keyDown', text: '\r', windowsVirtualKeyCode: 13 }) + }) + + it('sends editing keys as rawKeyDown without text', () => { + const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Backspace'), 'linux') + expect(down.type).toBe('rawKeyDown') + expect(down.text).toBeUndefined() + }) + + it('maps Cmd shortcuts to Blink editing commands on macOS only', () => { + const combo = driver.parseKeyCombo('Cmd+A') + const [macDown] = driver.buildKeyDispatchPlan(combo, 'darwin') + expect(macDown.commands).toEqual(['selectAll']) + expect(macDown.modifiers).toBe(4) + const [linuxDown] = driver.buildKeyDispatchPlan(combo, 'linux') + expect(linuxDown.commands).toBeUndefined() + }) + + it('treats Control shortcuts as Cmd on macOS (the model does not know the host OS)', () => { + const combo = driver.parseKeyCombo('Control+A') + const [macDown] = driver.buildKeyDispatchPlan(combo, 'darwin') + expect(macDown.commands).toEqual(['selectAll']) + expect(macDown.modifiers).toBe(4) // ctrl normalized away, meta set + // On Linux/Windows Ctrl+A is Blink-native; no rewrite. + const [linuxDown] = driver.buildKeyDispatchPlan(combo, 'linux') + expect(linuxDown.modifiers).toBe(2) + expect(linuxDown.commands).toBeUndefined() + }) + + it('does not rewrite non-editing Control combos on macOS', () => { + const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Control+K'), 'darwin') + expect(down.modifiers).toBe(2) + expect(down.commands).toBeUndefined() + }) + + it('maps Cmd+Shift+Z to redo and Cmd+Z to undo on macOS', () => { + const [redo] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Cmd+Shift+Z'), 'darwin') + expect(redo.commands).toEqual(['redo']) + const [undo] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Cmd+Z'), 'darwin') + expect(undo.commands).toEqual(['undo']) + }) + + it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => { + const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Control+Shift+K'), 'linux') + expect(down.modifiers).toBe(2 | 8) + // Modified letters must not carry text — they are shortcuts, not typing. + expect(down.type).toBe('rawKeyDown') + expect(down.text).toBeUndefined() + }) +}) + describe('executeTool', () => { let driver: DriverModule diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index c29df6d3c2e..8eea4343a46 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -4,12 +4,16 @@ * live page state. * * Perception drives through injected page functions (element registry with a - * structural outline); actuation is synthetic DOM events from those same - * functions. 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. + * 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 type { BrowserPageState, @@ -23,12 +27,14 @@ import * as cdp from '@/main/browser-agent/cdp' import { clickElement, collectSnapshot, + focusElementForTyping, getViewportInfo, hasTakeoverBanner, hoverElement, isTakeoverDone, pageContainsText, pressKeyOnPage, + readActiveElementState, readPageText, removeTakeoverBanner, scrollPage, @@ -338,6 +344,101 @@ export function parseKeyCombo(combo: string): ParsedCombo { throw new ToolError(`Unrecognized key: "${keyPart}"`) } +// --------------------------------------------------------------------------- +// Trusted key dispatch (CDP Input.dispatchKeyEvent) +// --------------------------------------------------------------------------- + +/** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */ +function cdpModifiers(combo: ParsedCombo): number { + return (combo.alt ? 1 : 0) | (combo.ctrl ? 2 : 0) | (combo.meta ? 4 : 0) | (combo.shift ? 8 : 0) +} + +function editingCommandFor(combo: ParsedCombo): string | null { + switch (combo.key.toLowerCase()) { + case 'a': + return 'selectAll' + case 'c': + return 'copy' + case 'x': + return 'cut' + case 'v': + return 'paste' + case 'z': + return combo.shift ? 'redo' : 'undo' + default: + return null + } +} + +/** + * On macOS the editing shortcuts are bound in the system menu layer, which + * CDP key events never traverse — so Blink must be told the editing command + * explicitly (same technique as Puppeteer/Playwright). The model doesn't know + * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd + * for these shortcuts: both must select all, not silently no-op. On other + * platforms Ctrl+key is handled inside Blink and needs no help. + */ +function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo { + if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) { + return combo + } + return { ...combo, ctrl: false, meta: true } +} + +function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] { + if (platform !== 'darwin' || !combo.meta) return [] + const command = editingCommandFor(combo) + return command ? [command] : [] +} + +/** + * Builds the trusted keyDown/keyUp pair for a combo. Printable keys without + * ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r" + * so it activates defaults (form submission, newline). Everything else is a + * rawKeyDown, which still drives Blink's default editing actions (Backspace + * deletes, arrows move the caret, Ctrl/Cmd+A selects all). + */ +export function buildKeyDispatchPlan( + rawCombo: ParsedCombo, + platform: NodeJS.Platform = process.platform +): [cdp.CdpKeyEvent, cdp.CdpKeyEvent] { + const combo = normalizeComboForPlatform(rawCombo, platform) + const modifiers = cdpModifiers(combo) + const base = { + modifiers, + key: combo.key, + code: combo.code, + windowsVirtualKeyCode: combo.keyCode, + nativeVirtualKeyCode: combo.keyCode, + } + const printable = combo.key.length === 1 && !combo.ctrl && !combo.meta + const text = combo.key === 'Enter' ? '\r' : printable ? combo.key : undefined + const commands = macEditingCommands(combo, platform) + const down: cdp.CdpKeyEvent = { + ...base, + type: text !== undefined ? 'keyDown' : 'rawKeyDown', + ...(text !== undefined ? { text } : {}), + ...(commands.length > 0 ? { commands } : {}), + } + return [down, { ...base, type: 'keyUp' }] +} + +/** Presses a combo through the trusted pipeline. Throws on CDP failure. */ +async function dispatchKeyCombo(contents: WebContents, combo: ParsedCombo): Promise { + const [down, up] = buildKeyDispatchPlan(combo) + await cdp.dispatchKeyEvent(contents, down) + await cdp.dispatchKeyEvent(contents, up) +} + +/** + * Post-action readback so the model sees the real effect (selection size, + * value length) instead of assuming the key "worked". + */ +async function activeElementState(contents: WebContents): Promise> { + const state = await execInPage(contents, readActiveElementState, []).catch(() => null) + return typeof state === 'object' && state !== null ? (state as Record) : {} +} + // --------------------------------------------------------------------------- // Takeover // --------------------------------------------------------------------------- @@ -504,28 +605,62 @@ async function executeToolInner( } case 'browser_type': { + const elementId = requireNum(params, 'elementId') + const text = requireStr(params, 'text') + const submit = params.submit === true const contents = session.requireTab().view.webContents - return unwrapPageResult( - await execInPage(contents, typeIntoElement, [ - requireNum(params, 'elementId'), - requireStr(params, 'text'), - params.submit === true, - ]) - ) + + // Native path: focus + select current content, then insert through the + // IME pipeline so the text REPLACES what's there — the only write path + // code editors (CodeMirror/Monaco) honor. The DOM selection set by the + // page function covers plain fields; the real select-all keystroke + // right after covers editors that track selection in their own model + // (their keymaps handle it synchronously, where DOM-selection sync is + // async and can lose a race with the insert). Falls back to the + // synthetic value-setter when CDP is unavailable. + unwrapPageResult(await execInPage(contents, focusElementForTyping, [elementId])) + try { + await dispatchKeyCombo( + contents, + parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A') + ) + await cdp.insertText(contents, text) + } catch { + return unwrapPageResult( + await execInPage(contents, typeIntoElement, [elementId, text, submit]) + ) + } + if (submit) { + await dispatchKeyCombo(contents, parseKeyCombo('Enter')).catch(() => {}) + } + const state = await activeElementState(contents) + return { typed: true, replacedExisting: true, submitted: submit, ...state } } case 'browser_press_key': { const combo = parseKeyCombo(requireStr(params, 'key')) const contents = session.requireTab().view.webContents - return await execInPage(contents, pressKeyOnPage, [ - combo.key, - combo.code, - combo.keyCode, - combo.ctrl, - combo.meta, - combo.shift, - combo.alt, - ]) + try { + await dispatchKeyCombo(contents, combo) + } catch { + // CDP unavailable (debugger detached): synthetic DOM fallback. It + // cannot trigger default editing actions, so say so in the result. + const fallback = await execInPage(contents, pressKeyOnPage, [ + combo.key, + combo.code, + combo.keyCode, + combo.ctrl, + combo.meta, + combo.shift, + combo.alt, + ]) + return { + ...(typeof fallback === 'object' && fallback !== null ? fallback : {}), + note: 'Delivered as a synthetic page event; editing shortcuts may not take effect.', + } + } + const state = await activeElementState(contents) + return { pressed: requireStr(params, 'key'), ...state } } case 'browser_scroll': { diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index c7c53938d2d..2a2e9737469 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -278,6 +278,73 @@ export function clickElement(id: number): unknown { return { clicked: true, element: label } } +/** + * 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 el = (window.__simAgentElements || [])[id] + if (!el || !el.isConnected) return { error: 'stale' } + el.scrollIntoView({ block: 'center' }) + + if (el instanceof HTMLInputElement && el.type === 'password') { + return { error: 'password' } + } + + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + el.focus() + el.select() + return { focused: true, kind: el instanceof HTMLInputElement ? 'input' : 'textarea' } + } + + // Editors often register a wrapper as the interactive element while the + // actual editable surface is a descendant. + const editable = + el instanceof HTMLElement && el.isContentEditable + ? el + : 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 active = document.activeElement as HTMLElement | null + if (!active || active === document.body) { + return { activeElement: 'body', selectedChars: 0, valueLength: 0, valuePreview: '' } + } + let value = '' + let selectedChars = 0 + if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { + value = active.value + selectedChars = Math.abs((active.selectionEnd ?? 0) - (active.selectionStart ?? 0)) + } else { + value = active.innerText || active.textContent || '' + selectedChars = window.getSelection()?.toString().length ?? 0 + } + return { + activeElement: active.tagName.toLowerCase(), + selectedChars, + valueLength: value.length, + valuePreview: value.replace(/\s+/g, ' ').trim().slice(0, 120), + } +} + export function typeIntoElement(id: number, text: string, submit: boolean): unknown { const el = (window.__simAgentElements || [])[id] if (!el || !el.isConnected) return { error: 'stale' } diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 9a1000329e3..247362a15a3 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -23,6 +23,9 @@ export interface DesktopSettings { lastRoute?: string themeBackground?: 'dark' | 'light' blockThirdPartyAnalytics?: boolean + /** Quick Ask launcher accelerator — one of the presets in shortcuts.ts, or 'disabled'. */ + launcherShortcut?: string + trayEnabled?: boolean } export type OriginValidation = { ok: true; origin: string } | { ok: false; error: string } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index fbcb0a69be8..c4b05ab155b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -11,6 +11,7 @@ import { attachContextMenu } from '@/main/context-menu' import { attachDownloadHandling } from '@/main/downloads' import { createAuthFlow, createHandoffManager } from '@/main/handoff' import { registerIpcHandlers } from '@/main/ipc' +import { createLauncherWindow } from '@/main/launcher-window' import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' import { LocalFilesystemService } from '@/main/local-filesystem' import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' @@ -25,7 +26,9 @@ import { tearDownSession, } from '@/main/session-lifecycle' import { closeSettingsWindow, openSettingsWindow } from '@/main/settings-window' +import { createLauncherShortcutManager, LAUNCHER_SHORTCUT_PRESETS } from '@/main/shortcuts' import { attachTelemetryPolicy } from '@/main/telemetry-policy' +import { installTray } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater } from '@/main/updater' import { createMainWindow, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' @@ -162,6 +165,52 @@ function main(): void { openSettingsWindow({ preloadPath, isPackaged: app.isPackaged, getMainWindow }) } + /** + * Brings the main window to front (creating it if needed), optionally + * navigating it to an in-app route first — the seam used by the tray menu + * and the launcher's open-in-Sim action. + */ + async function openMainWindowAt(route?: string): Promise { + if (!getMainWindow()) { + await createAndLoadMainWindow() + } + const win = getMainWindow() + if (!win) { + return + } + if (route) { + void win.loadURL(`${appOrigin()}${route}`).catch(() => {}) + } + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + // Panel-type windows never activate the app, so opening from the + // launcher needs an explicit activation to take over from the app the + // user was in. + app.focus({ steal: true }) + } + + const launcher = createLauncherWindow({ + appOrigin, + partition: () => partitionForOrigin(appOrigin()), + preloadPath, + isPackaged: app.isPackaged, + themeBackground: () => config.get('themeBackground'), + openMainWindow: () => void openMainWindowAt(), + events, + }) + + function toggleLauncher(): void { + // The launcher shares the app partition; make sure its session policies + // (permissions, downloads, telemetry) exist before the window loads. + configureSessionForOrigin(appOrigin()) + launcher.toggle() + } + + const launcherShortcut = createLauncherShortcutManager(toggleLauncher) + async function applyOrigin(raw: string) { const previousOrigin = appOrigin() const result = config.setOrigin(raw) @@ -176,6 +225,9 @@ function main(): void { events.record('origin_changed') await localFilesystem.forgetAll() handoff.clear() + // The launcher window is bound to the old origin's partition; the next + // summon recreates it against the new origin. + launcher.destroy() const win = getMainWindow() if (win) { mainWindow = null @@ -249,6 +301,37 @@ function main(): void { closeSettings: closeSettingsWindow, applyOrigin, localFilesystem, + launcher: { + openChat: (target) => { + launcher.hide() + const route = target.chatId + ? `/workspace/${target.workspaceId}/chat/${target.chatId}` + : `/workspace/${target.workspaceId}/home` + void openMainWindowAt(route) + }, + openApp: () => { + launcher.hide() + void openMainWindowAt() + }, + hide: () => launcher.hide(), + resize: (height) => launcher.resize(height), + }, + launcherShortcut: { + get: () => ({ + shortcut: launcherShortcut.current(), + presets: [...LAUNCHER_SHORTCUT_PRESETS], + status: launcherShortcut.status(), + }), + set: (raw) => { + const status = launcherShortcut.apply(raw) + config.set('launcherShortcut', launcherShortcut.current()) + return { + shortcut: launcherShortcut.current(), + presets: [...LAUNCHER_SHORTCUT_PRESETS], + status, + } + }, + }, }) await createAndLoadMainWindow() installApplicationMenu({ @@ -262,7 +345,28 @@ function main(): void { checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events }), eventLogPath: events.filePath, }) + launcherShortcut.apply(config.get('launcherShortcut')) + if (config.get('trayEnabled') ?? true) { + installTray({ + partition: () => partitionForOrigin(appOrigin()), + appOrigin, + lastRoute: () => config.get('lastRoute'), + launcherShortcut: () => launcherShortcut.current(), + openMainWindow: (route) => void openMainWindowAt(route), + toggleLauncher, + openSettings, + checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events }), + }) + } initUpdater({ getWindow: getMainWindow, events }) + + // Prewarm Quick Ask a moment after startup so its first summon is instant + // (window + remote route already loaded). Deferred so it never competes + // with the main window's initial load. + setTimeout(() => { + configureSessionForOrigin(appOrigin()) + launcher.prewarm() + }, 3000) }) } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 0bcb4312794..835d62ad973 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -48,6 +48,24 @@ describe('registerIpcHandlers', () => { localFilesystem: new LocalFilesystemService({ chooseDirectory: vi.fn(async () => null), }), + launcher: { + openChat: vi.fn(), + openApp: vi.fn(), + hide: vi.fn(), + resize: vi.fn(), + }, + launcherShortcut: { + get: vi.fn(() => ({ + shortcut: 'Alt+Space', + presets: ['Alt+Space'], + status: 'registered' as const, + })), + set: vi.fn(() => ({ + shortcut: 'Control+Space', + presets: ['Alt+Space'], + status: 'registered' as const, + })), + }, } registerIpcHandlers(deps) }) @@ -138,4 +156,59 @@ describe('registerIpcHandlers', () => { expect(() => handler?.(appEvent, 'not-an-object')).not.toThrow() expect(() => handler?.(appEvent, { action: 'reload' })).not.toThrow() }) + + it('restricts launcher channels to the app origin and validates ids', () => { + const { on } = collectHandlers() + const openChat = on.get('launcher:open-chat') + + openChat?.(evilEvent, { workspaceId: 'ws1' }) + openChat?.(fileEvent, { workspaceId: 'ws1' }) + expect(deps.launcher.openChat).not.toHaveBeenCalled() + + // Ids that could escape the URL path are rejected. + openChat?.(appEvent, { workspaceId: 'ws1/../../admin' }) + openChat?.(appEvent, { workspaceId: 'ws1', chatId: 'c1?x=1' }) + openChat?.(appEvent, { workspaceId: '' }) + openChat?.(appEvent, 'not-an-object') + expect(deps.launcher.openChat).not.toHaveBeenCalled() + + openChat?.(appEvent, { workspaceId: 'ws1', chatId: 'chat_2-a' }) + expect(deps.launcher.openChat).toHaveBeenCalledWith({ + workspaceId: 'ws1', + chatId: 'chat_2-a', + }) + openChat?.(appEvent, { workspaceId: 'ws1' }) + expect(deps.launcher.openChat).toHaveBeenCalledWith({ workspaceId: 'ws1' }) + + on.get('launcher:open-app')?.(evilEvent) + expect(deps.launcher.openApp).not.toHaveBeenCalled() + on.get('launcher:open-app')?.(appEvent) + expect(deps.launcher.openApp).toHaveBeenCalledTimes(1) + + on.get('launcher:close')?.(evilEvent) + expect(deps.launcher.hide).not.toHaveBeenCalled() + on.get('launcher:close')?.(appEvent) + expect(deps.launcher.hide).toHaveBeenCalledTimes(1) + + on.get('launcher:resize')?.(appEvent, 'tall') + on.get('launcher:resize')?.(appEvent, Number.NaN) + expect(deps.launcher.resize).not.toHaveBeenCalled() + on.get('launcher:resize')?.(appEvent, 400) + expect(deps.launcher.resize).toHaveBeenCalledWith(400) + }) + + it('restricts launcher shortcut settings to bundled local pages', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('settings:launcher-shortcut-get')?.(appEvent)).toBeNull() + expect(await invoke.get('settings:launcher-shortcut-get')?.(fileEvent)).toMatchObject({ + shortcut: 'Alt+Space', + status: 'registered', + }) + expect(await invoke.get('settings:launcher-shortcut-set')?.(appEvent, 'Control+Space')).toBe( + null + ) + expect(await invoke.get('settings:launcher-shortcut-set')?.(fileEvent, 42)).toBeNull() + await invoke.get('settings:launcher-shortcut-set')?.(fileEvent, 'Control+Space') + expect(deps.launcherShortcut.set).toHaveBeenCalledWith('Control+Space') + }) }) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 45e253eefc3..cbf9ebbc1fd 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,3 +1,4 @@ +import type { LauncherShortcutSettings } from '@sim/desktop-bridge' import { isBrowserToolName } from '@sim/browser-protocol' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' import { app, ipcMain } from 'electron' @@ -8,6 +9,33 @@ import type { LocalFilesystemService } from '@/main/local-filesystem' import { openExternalSafe } from '@/main/navigation' import { ensureMicrophoneAccess } from '@/main/window' +/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ +const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +export interface LauncherOpenChatTarget { + workspaceId: string + chatId?: string +} + +/** + * Validates the launcher's open-chat payload. Both ids are embedded into a + * loadURL path, so they are allowlisted to opaque-token characters — no + * slashes, dots, or percent escapes. + */ +export function parseLauncherOpenChatTarget(raw: unknown): LauncherOpenChatTarget | null { + if (typeof raw !== 'object' || raw === null) { + return null + } + const { workspaceId, chatId } = raw as { workspaceId?: unknown; chatId?: unknown } + if (typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId)) { + return null + } + if (chatId !== undefined && (typeof chatId !== 'string' || !ID_PATTERN.test(chatId))) { + return null + } + return { workspaceId, ...(chatId !== undefined ? { chatId } : {}) } +} + export interface IpcDeps { config: ConfigStore appOrigin: () => string @@ -17,6 +45,16 @@ export interface IpcDeps { closeSettings: () => void applyOrigin: (raw: string) => Promise localFilesystem: LocalFilesystemService + launcher: { + openChat: (target: LauncherOpenChatTarget) => void + openApp: () => void + hide: () => void + resize: (height: number) => void + } + launcherShortcut: { + get: () => LauncherShortcutSettings + set: (shortcut: string) => LauncherShortcutSettings + } } function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { @@ -142,4 +180,49 @@ export function registerIpcHandlers(deps: IpcDeps): void { } return deps.applyOrigin(raw) }) + + ipcMain.handle('settings:launcher-shortcut-get', (event) => { + if (!isLocalPageSender(event)) { + return null + } + return deps.launcherShortcut.get() + }) + + ipcMain.handle('settings:launcher-shortcut-set', (event, raw: unknown) => { + if (!isLocalPageSender(event) || typeof raw !== 'string') { + return null + } + return deps.launcherShortcut.set(raw) + }) + + ipcMain.on('launcher:open-chat', (event, raw: unknown) => { + if (!isAppOriginSender(event, deps.appOrigin())) { + return + } + const target = parseLauncherOpenChatTarget(raw) + if (target) { + deps.launcher.openChat(target) + } + }) + + ipcMain.on('launcher:open-app', (event) => { + if (isAppOriginSender(event, deps.appOrigin())) { + deps.launcher.openApp() + } + }) + + ipcMain.on('launcher:close', (event) => { + if (isAppOriginSender(event, deps.appOrigin())) { + deps.launcher.hide() + } + }) + + ipcMain.on('launcher:resize', (event, height: unknown) => { + if (!isAppOriginSender(event, deps.appOrigin())) { + return + } + if (typeof height === 'number' && Number.isFinite(height)) { + deps.launcher.resize(height) + } + }) } diff --git a/apps/desktop/src/main/launcher-window.test.ts b/apps/desktop/src/main/launcher-window.test.ts new file mode 100644 index 00000000000..529cdfc55fa --- /dev/null +++ b/apps/desktop/src/main/launcher-window.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +// Same module instance the vi.mock factory returns, with mock-typed statics. +import { BrowserWindow } from '@/test/electron-mock' +import { + clampLauncherHeight, + createLauncherWindow, + LAUNCHER_MAX_HEIGHT, + LAUNCHER_MIN_HEIGHT, + LAUNCHER_WIDTH, + launcherBoundsFor, + type LauncherWindowDeps, +} from '@/main/launcher-window' + +type MockWindow = InstanceType + +function makeDeps(): LauncherWindowDeps { + return { + appOrigin: () => 'https://sim.ai', + partition: () => 'persist:sim', + preloadPath: '/app/preload.cjs', + isPackaged: true, + themeBackground: () => 'dark', + openMainWindow: vi.fn(), + events: { filePath: '/tmp/events.log', record: vi.fn() }, + } +} + +function handlerOf(win: MockWindow, source: 'window' | 'webContents', event: string) { + const calls = source === 'window' ? win.on.mock.calls : win.webContents.on.mock.calls + const entry = calls.find(([name]: unknown[]) => name === event) + return entry?.[1] as ((...args: unknown[]) => void) | undefined +} + +describe('launcherBoundsFor', () => { + const workArea = { x: 0, y: 25, width: 1728, height: 1092 } + + it('centers horizontally and sits a quarter down the work area', () => { + const bounds = launcherBoundsFor(workArea, LAUNCHER_MIN_HEIGHT) + expect(bounds.x).toBe(Math.round((1728 - LAUNCHER_WIDTH) / 2)) + expect(bounds.y).toBe(Math.round(25 + 1092 * 0.25)) + expect(bounds.width).toBe(LAUNCHER_WIDTH) + expect(bounds.height).toBe(LAUNCHER_MIN_HEIGHT) + }) + + it('clamps the height into the panel range', () => { + expect(clampLauncherHeight(10)).toBe(LAUNCHER_MIN_HEIGHT) + expect(clampLauncherHeight(10_000)).toBe(LAUNCHER_MAX_HEIGHT) + expect(clampLauncherHeight(Number.NaN)).toBe(LAUNCHER_MIN_HEIGHT) + expect(clampLauncherHeight(300.6)).toBe(301) + }) +}) + +describe('createLauncherWindow', () => { + beforeEach(() => { + BrowserWindow.instances.length = 0 + BrowserWindow.lastOptions = undefined + }) + + it('creates a non-activating panel over all workspaces and loads the launcher route', () => { + const deps = makeDeps() + const launcher = createLauncherWindow(deps) + launcher.toggle() + + expect(BrowserWindow.instances).toHaveLength(1) + const win = BrowserWindow.instances[0] + expect(BrowserWindow.lastOptions).toMatchObject({ + type: 'panel', + frame: false, + resizable: false, + skipTaskbar: true, + hiddenInMissionControl: true, + show: false, + }) + expect(win.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }) + expect(win.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver') + expect(win.webContents.loadURL).toHaveBeenCalledWith('https://sim.ai/desktop/launcher') + expect(win.show).toHaveBeenCalledTimes(1) + expect(win.webContents.send).toHaveBeenCalledWith('launcher:shown') + }) + + it('toggle hides a visible panel and re-shows a hidden one without recreating', () => { + const launcher = createLauncherWindow(makeDeps()) + launcher.toggle() + const win = BrowserWindow.instances[0] + + win.isVisible.mockReturnValue(true) + launcher.toggle() + expect(win.hide).toHaveBeenCalledTimes(1) + + win.isVisible.mockReturnValue(false) + launcher.toggle() + expect(BrowserWindow.instances).toHaveLength(1) + expect(win.show).toHaveBeenCalledTimes(2) + }) + + it('hides on blur unless DevTools has focus', () => { + const launcher = createLauncherWindow(makeDeps()) + launcher.toggle() + const win = BrowserWindow.instances[0] + const onBlur = handlerOf(win, 'window', 'blur') + + win.webContents.isDevToolsOpened.mockReturnValue(true) + onBlur?.() + expect(win.hide).not.toHaveBeenCalled() + + win.webContents.isDevToolsOpened.mockReturnValue(false) + onBlur?.() + expect(win.hide).toHaveBeenCalledTimes(1) + }) + + it('falls back to the main window when the route returns an HTTP error', () => { + const deps = makeDeps() + const launcher = createLauncherWindow(deps) + launcher.toggle() + const win = BrowserWindow.instances[0] + win.isVisible.mockReturnValue(true) + + const onNavigate = handlerOf(win, 'webContents', 'did-navigate') + onNavigate?.({}, 'https://sim.ai/desktop/launcher', 404) + + expect(win.hide).toHaveBeenCalledTimes(1) + expect(deps.openMainWindow).toHaveBeenCalledTimes(1) + expect(deps.events.record).toHaveBeenCalledWith('launcher_load_failed', { + code: 404, + reason: 'http', + }) + }) + + it('retries the load on the next summon after a failure', () => { + const launcher = createLauncherWindow(makeDeps()) + launcher.toggle() + const win = BrowserWindow.instances[0] + win.isVisible.mockReturnValue(true) + handlerOf(win, 'webContents', 'did-navigate')?.({}, 'https://sim.ai/desktop/launcher', 404) + + win.isVisible.mockReturnValue(false) + launcher.toggle() + expect(win.webContents.loadURL).toHaveBeenCalledTimes(2) + }) + + it('resize clamps into the panel range and keeps position', () => { + const launcher = createLauncherWindow(makeDeps()) + launcher.toggle() + const win = BrowserWindow.instances[0] + launcher.resize(10_000) + expect(win.setBounds).toHaveBeenCalledWith( + expect.objectContaining({ height: LAUNCHER_MAX_HEIGHT, width: LAUNCHER_WIDTH }) + ) + }) + + it('destroy tears down and the next toggle recreates', () => { + const launcher = createLauncherWindow(makeDeps()) + launcher.toggle() + const first = BrowserWindow.instances[0] + launcher.destroy() + expect(first.destroy).toHaveBeenCalledTimes(1) + launcher.toggle() + expect(BrowserWindow.instances).toHaveLength(2) + }) +}) diff --git a/apps/desktop/src/main/launcher-window.ts b/apps/desktop/src/main/launcher-window.ts new file mode 100644 index 00000000000..57b93cb7836 --- /dev/null +++ b/apps/desktop/src/main/launcher-window.ts @@ -0,0 +1,218 @@ +import { createLogger } from '@sim/logger' +import type { Display, Rectangle } from 'electron' +import { BrowserWindow, nativeTheme, screen } from 'electron' +import type { EventRecorder } from '@/main/observability' +import { scrubUrl } from '@/main/observability' +import { backgroundColorFor, createSecureWebPreferences } from '@/main/window' + +const logger = createLogger('DesktopLauncher') + +export const LAUNCHER_ROUTE = '/desktop/launcher' +export const LAUNCHER_WIDTH = 680 +export const LAUNCHER_MIN_HEIGHT = 96 +export const LAUNCHER_MAX_HEIGHT = 600 + +/** + * Spotlight placement: horizontally centered on the given display, top edge + * about a quarter of the way down the work area. + */ +export function launcherBoundsFor(workArea: Rectangle, height: number): Rectangle { + const clamped = clampLauncherHeight(height) + return { + x: Math.round(workArea.x + (workArea.width - LAUNCHER_WIDTH) / 2), + y: Math.round(workArea.y + workArea.height * 0.25), + width: LAUNCHER_WIDTH, + height: clamped, + } +} + +export function clampLauncherHeight(height: number): number { + if (!Number.isFinite(height)) { + return LAUNCHER_MIN_HEIGHT + } + return Math.min(LAUNCHER_MAX_HEIGHT, Math.max(LAUNCHER_MIN_HEIGHT, Math.round(height))) +} + +export interface LauncherWindowDeps { + appOrigin: () => string + partition: () => string + preloadPath: string + isPackaged: boolean + themeBackground: () => 'dark' | 'light' | undefined + /** Fallback when the launcher route is unavailable (older self-hosted server, offline). */ + openMainWindow: () => void + events: EventRecorder +} + +export interface LauncherWindowHandle { + /** Summon (on the display with the cursor) or dismiss the panel. */ + toggle(): void + hide(): void + /** + * Create and load the panel offscreen without showing it, so the first + * summon is instant instead of waiting on window creation + the remote + * route load. No-op if already created. + */ + prewarm(): void + /** Renderer-driven growth as a response streams in; clamped. */ + resize(height: number): void + isVisible(): boolean + /** Tears the window down (origin change, quit). Next toggle recreates it. */ + destroy(): void +} + +/** + * The Quick Ask panel: a non-activating floating window (Electron `panel` + * type) that appears over full-screen apps on every Space, summoned by the + * global shortcut or the tray. It loads the remote `/desktop/launcher` route + * in the app partition, so the page is authenticated exactly like the main + * window. Dismissal is hide-not-close — the page (and any streaming response) + * survives across summons until the origin changes or the app quits. + */ +export function createLauncherWindow(deps: LauncherWindowDeps): LauncherWindowHandle { + let win: BrowserWindow | null = null + let loadFailed = false + + const activeDisplay = (): Display => screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + + const create = (): BrowserWindow => { + const panel = new BrowserWindow({ + ...launcherBoundsFor(activeDisplay().workArea, LAUNCHER_MIN_HEIGHT), + type: 'panel', + frame: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + hiddenInMissionControl: true, + acceptFirstMouse: true, + roundedCorners: true, + show: false, + backgroundColor: backgroundColorFor(deps.themeBackground(), nativeTheme.shouldUseDarkColors), + webPreferences: createSecureWebPreferences( + deps.partition(), + deps.preloadPath, + deps.isPackaged + ), + }) + panel.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }) + panel.setAlwaysOnTop(true, 'screen-saver') + + panel.on('blur', () => { + // DevTools focus counts as window blur; hiding then would make the + // panel undebuggable in dev. + if (!panel.webContents.isDevToolsOpened()) { + panel.hide() + } + }) + + // did-fail-load covers network-level failures (offline); an HTTP error + // status (older self-hosted server without the route) still "loads", so + // it is caught from the navigation's response code instead. + panel.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { + if (!isMainFrame || code === -3 /* ERR_ABORTED: superseded navigation */) { + return + } + logger.warn('Launcher route failed to load', { code, description, url: scrubUrl(url) }) + deps.events.record('launcher_load_failed', { code, reason: description }) + loadFailed = true + if (panel.isVisible()) { + panel.hide() + deps.openMainWindow() + } + }) + panel.webContents.on('did-navigate', (_event, url, httpResponseCode) => { + if (httpResponseCode >= 400) { + logger.warn('Launcher route returned an error status', { + status: httpResponseCode, + url: scrubUrl(url), + }) + deps.events.record('launcher_load_failed', { code: httpResponseCode, reason: 'http' }) + loadFailed = true + if (panel.isVisible()) { + panel.hide() + deps.openMainWindow() + } + } + }) + panel.webContents.on('render-process-gone', (_event, details) => { + if (details.reason !== 'clean-exit') { + logger.warn('Launcher renderer gone; recreating on next summon', { + reason: details.reason, + }) + } + panel.destroy() + }) + panel.on('closed', () => { + if (win === panel) { + win = null + } + }) + return panel + } + + const load = (panel: BrowserWindow) => { + loadFailed = false + void panel.webContents.loadURL(`${deps.appOrigin()}${LAUNCHER_ROUTE}`).catch(() => {}) + } + + const show = (panel: BrowserWindow) => { + panel.setBounds(launcherBoundsFor(activeDisplay().workArea, LAUNCHER_MIN_HEIGHT)) + panel.show() + panel.webContents.send('launcher:shown') + } + + return { + toggle() { + if (win && !win.isDestroyed()) { + if (win.isVisible()) { + win.hide() + return + } + if (loadFailed) { + load(win) + } + show(win) + return + } + win = create() + load(win) + show(win) + }, + prewarm() { + if (win && !win.isDestroyed()) { + return + } + // Built hidden and positioned offscreen; toggle() will place + show it + // on the active display. Loading now means the remote route is already + // painted by the time the user first summons. + win = create() + load(win) + }, + hide() { + if (win && !win.isDestroyed() && win.isVisible()) { + win.hide() + } + }, + resize(height: number) { + if (!win || win.isDestroyed()) { + return + } + const bounds = win.getBounds() + win.setBounds({ ...bounds, height: clampLauncherHeight(height) }) + }, + isVisible() { + return Boolean(win && !win.isDestroyed() && win.isVisible()) + }, + destroy() { + if (win && !win.isDestroyed()) { + win.destroy() + } + win = null + }, + } +} diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts index bbca0dc4509..54dda9675de 100644 --- a/apps/desktop/src/main/observability.ts +++ b/apps/desktop/src/main/observability.ts @@ -21,6 +21,7 @@ export type DesktopEventName = | 'sign_out' | 'origin_changed' | 'handoff_started' + | 'launcher_load_failed' export interface EventRecorder { readonly filePath: string diff --git a/apps/desktop/src/main/settings-window.ts b/apps/desktop/src/main/settings-window.ts index 5337bd466e5..2a428668159 100644 --- a/apps/desktop/src/main/settings-window.ts +++ b/apps/desktop/src/main/settings-window.ts @@ -25,7 +25,7 @@ export function openSettingsWindow(deps: SettingsWindowDeps): void { settingsWindow = new BrowserWindow({ title: 'Sim Settings', width: 460, - height: 290, + height: 430, resizable: false, minimizable: false, maximizable: false, diff --git a/apps/desktop/src/main/shortcuts.test.ts b/apps/desktop/src/main/shortcuts.test.ts new file mode 100644 index 00000000000..b3f84e6d942 --- /dev/null +++ b/apps/desktop/src/main/shortcuts.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { globalShortcut } from 'electron' +import { + createLauncherShortcutManager, + DEFAULT_LAUNCHER_SHORTCUT, + LAUNCHER_SHORTCUT_DISABLED, + LAUNCHER_SHORTCUT_PRESETS, + normalizeLauncherShortcut, +} from '@/main/shortcuts' + +describe('normalizeLauncherShortcut', () => { + it('accepts every preset unchanged', () => { + for (const preset of LAUNCHER_SHORTCUT_PRESETS) { + expect(normalizeLauncherShortcut(preset)).toBe(preset) + } + }) + + it('falls back to the default for unknown or malformed values', () => { + expect(normalizeLauncherShortcut(undefined)).toBe(DEFAULT_LAUNCHER_SHORTCUT) + expect(normalizeLauncherShortcut('')).toBe(DEFAULT_LAUNCHER_SHORTCUT) + expect(normalizeLauncherShortcut('MediaPlayPause')).toBe(DEFAULT_LAUNCHER_SHORTCUT) + expect(normalizeLauncherShortcut('Cmd+Shift+P')).toBe(DEFAULT_LAUNCHER_SHORTCUT) + }) +}) + +describe('createLauncherShortcutManager', () => { + beforeEach(() => { + vi.mocked(globalShortcut.register).mockClear().mockReturnValue(true) + vi.mocked(globalShortcut.unregister).mockClear() + }) + + it('registers the default shortcut and reports success', () => { + const onActivate = vi.fn() + const manager = createLauncherShortcutManager(onActivate) + expect(manager.apply(undefined)).toBe('registered') + expect(manager.current()).toBe(DEFAULT_LAUNCHER_SHORTCUT) + expect(globalShortcut.register).toHaveBeenCalledWith(DEFAULT_LAUNCHER_SHORTCUT, onActivate) + }) + + it('reports failure when another app owns the accelerator', () => { + vi.mocked(globalShortcut.register).mockReturnValue(false) + const manager = createLauncherShortcutManager(vi.fn()) + expect(manager.apply('Alt+Space')).toBe('failed') + expect(manager.status()).toBe('failed') + }) + + it('releases the previous registration when rebinding', () => { + const manager = createLauncherShortcutManager(vi.fn()) + manager.apply('Alt+Space') + manager.apply('Control+Space') + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+Space') + expect(manager.current()).toBe('Control+Space') + }) + + it('does not unregister a shortcut it never held', () => { + vi.mocked(globalShortcut.register).mockReturnValue(false) + const manager = createLauncherShortcutManager(vi.fn()) + manager.apply('Alt+Space') + manager.apply('Control+Space') + expect(globalShortcut.unregister).not.toHaveBeenCalled() + }) + + it('supports disabling and re-enabling', () => { + const manager = createLauncherShortcutManager(vi.fn()) + manager.apply('Alt+Space') + expect(manager.apply(LAUNCHER_SHORTCUT_DISABLED)).toBe('disabled') + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+Space') + expect(manager.apply('Alt+Space')).toBe('registered') + }) + + it('dispose releases the registration', () => { + const manager = createLauncherShortcutManager(vi.fn()) + manager.apply('Alt+Space') + manager.dispose() + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+Space') + expect(manager.status()).toBe('disabled') + }) +}) diff --git a/apps/desktop/src/main/shortcuts.ts b/apps/desktop/src/main/shortcuts.ts new file mode 100644 index 00000000000..eff1188e396 --- /dev/null +++ b/apps/desktop/src/main/shortcuts.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { app, globalShortcut } from 'electron' + +const logger = createLogger('DesktopShortcuts') + +export const LAUNCHER_SHORTCUT_DISABLED = 'disabled' +export const DEFAULT_LAUNCHER_SHORTCUT = 'Alt+Space' + +/** + * The rebindable presets offered in settings. A fixed allowlist (rather than a + * free-form recorder) keeps persisted values trivially validatable and avoids + * users binding chords Electron can't register. + */ +export const LAUNCHER_SHORTCUT_PRESETS: readonly string[] = [ + 'Alt+Space', + 'Command+Shift+Space', + 'Control+Space', + LAUNCHER_SHORTCUT_DISABLED, +] + +export type LauncherShortcutStatus = 'registered' | 'failed' | 'disabled' + +/** + * Normalizes a persisted shortcut value against the preset allowlist. Unknown + * or malformed values (hand-edited settings file) fall back to the default so + * the launcher never silently loses its hotkey. + */ +export function normalizeLauncherShortcut(raw: string | undefined): string { + if (typeof raw === 'string' && LAUNCHER_SHORTCUT_PRESETS.includes(raw)) { + return raw + } + return DEFAULT_LAUNCHER_SHORTCUT +} + +export interface LauncherShortcutManager { + /** The currently applied (normalized) shortcut value. */ + current(): string + /** + * Registration outcome of the last apply. 'failed' means another app owns + * the accelerator (the OS rejects the registration silently) — settings + * surfaces this so the user knows to rebind. + */ + status(): LauncherShortcutStatus + /** (Re)registers the given shortcut, releasing the previous one. */ + apply(raw: string | undefined): LauncherShortcutStatus + /** Releases the registration (called from will-quit). */ + dispose(): void +} + +/** + * Owns the global Quick Ask accelerator. Registration goes through Electron's + * globalShortcut, which returns false (without throwing) when another app + * already holds the combo — that outcome is kept as observable state instead + * of being swallowed. + */ +export function createLauncherShortcutManager(onActivate: () => void): LauncherShortcutManager { + let applied: string = LAUNCHER_SHORTCUT_DISABLED + let status: LauncherShortcutStatus = 'disabled' + + const unregisterApplied = () => { + if (applied !== LAUNCHER_SHORTCUT_DISABLED && status === 'registered') { + try { + globalShortcut.unregister(applied) + } catch (error) { + logger.warn('Failed to unregister launcher shortcut', { shortcut: applied, error }) + } + } + } + + const manager: LauncherShortcutManager = { + current: () => applied, + status: () => status, + apply(raw) { + const next = normalizeLauncherShortcut(raw) + unregisterApplied() + applied = next + if (next === LAUNCHER_SHORTCUT_DISABLED) { + status = 'disabled' + return status + } + let registered = false + try { + registered = globalShortcut.register(next, onActivate) + } catch (error) { + logger.error('Launcher shortcut registration threw', { shortcut: next, error }) + } + status = registered ? 'registered' : 'failed' + if (!registered) { + logger.warn('Launcher shortcut unavailable (held by another app?)', { shortcut: next }) + } + return status + }, + dispose() { + unregisterApplied() + applied = LAUNCHER_SHORTCUT_DISABLED + status = 'disabled' + }, + } + + app.on('will-quit', () => manager.dispose()) + + return manager +} diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts new file mode 100644 index 00000000000..ddefba4daa8 --- /dev/null +++ b/apps/desktop/src/main/tray.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { session } from 'electron' +// Same module instance the vi.mock factory returns, with mock-typed statics. +import { Tray } from '@/test/electron-mock' +import { + buildTrayMenuTemplate, + chatRoute, + installTray, + newChatRoute, + parseRecentChats, + type TrayDeps, +} from '@/main/tray' + +function makeDeps(overrides: Partial = {}): TrayDeps { + return { + partition: () => 'persist:sim', + appOrigin: () => 'https://sim.ai', + lastRoute: () => '/workspace/ws1/home', + launcherShortcut: () => 'Alt+Space', + openMainWindow: vi.fn(), + toggleLauncher: vi.fn(), + openSettings: vi.fn(), + checkForUpdates: vi.fn(), + ...overrides, + } +} + +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) + 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([]) + }) +}) + +describe('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('deep-links chats into their workspace', () => { + expect(chatRoute({ id: 'c1', title: 't', workspaceId: 'ws9' })).toBe('/workspace/ws9/chat/c1') + }) +}) + +describe('buildTrayMenuTemplate', () => { + it('includes actions, recents, and quit', () => { + const deps = makeDeps() + const template = buildTrayMenuTemplate(deps, [ + { id: 'c1', title: 'Fix the sync', workspaceId: 'ws1' }, + ]) + const labels = template.map((item) => item.label ?? item.role ?? item.type) + expect(labels).toEqual([ + 'Open Sim', + 'New Chat', + 'Quick Ask', + 'separator', + 'Recent Chats', + 'Fix the sync', + 'separator', + 'Settings…', + 'Check for Updates…', + 'separator', + 'Quit Sim', + ]) + + const quickAsk = template.find((item) => item.label === 'Quick Ask') + expect(quickAsk?.accelerator).toBe('Alt+Space') + ;(quickAsk?.click as () => void)() + expect(deps.toggleLauncher).toHaveBeenCalledTimes(1) + + const chat = template.find((item) => item.label === 'Fix the sync') + ;(chat?.click as () => void)() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/chat/c1') + }) + + it('omits the recents section when empty and the hint when disabled', () => { + const template = buildTrayMenuTemplate(makeDeps({ launcherShortcut: () => 'disabled' }), []) + expect(template.some((item) => item.label === 'Recent Chats')).toBe(false) + expect(template.find((item) => item.label === 'Quick Ask')?.accelerator).toBeUndefined() + }) +}) + +describe('installTray', () => { + beforeEach(() => { + Tray.instances.length = 0 + }) + + 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)) + }) +}) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000000..18f4209b2b0 --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,205 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { MenuItemConstructorOptions } from 'electron' +import { app, Menu, nativeImage, session, Tray } from 'electron' +import { isSafeInternalPath } from '@/main/config' + +const logger = createLogger('DesktopTray') + +const RECENT_CHATS_LIMIT = 5 +const CHATS_FETCH_TIMEOUT_MS = 1500 +const CHATS_API_PATH = '/api/copilot/chats' + +export interface RecentChat { + id: string + title: string + workspaceId: string +} + +/** + * 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_LIMIT +): 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, + }) + } + return result +} + +/** + * Route for a tray-initiated "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 { + if (isSafeInternalPath(lastRoute)) { + const match = /^\/workspace\/([^/?#]+)/.exec(lastRoute) + if (match) { + return `/workspace/${match[1]}/home` + } + } + return '/workspace' +} + +export function chatRoute(chat: RecentChat): string { + return `/workspace/${chat.workspaceId}/chat/${chat.id}` +} + +export interface TrayDeps { + partition: () => string + appOrigin: () => string + lastRoute: () => string | undefined + /** Shortcut hint shown next to Quick Ask ('disabled' hides the hint). */ + launcherShortcut: () => string + openMainWindow: (route?: string) => void + toggleLauncher: () => void + openSettings: () => void + checkForUpdates: () => void +} + +export function buildTrayMenuTemplate( + deps: TrayDeps, + recentChats: RecentChat[] +): MenuItemConstructorOptions[] { + const shortcut = deps.launcherShortcut() + const template: MenuItemConstructorOptions[] = [ + { label: 'Open Sim', click: () => deps.openMainWindow() }, + { label: 'New Chat', click: () => deps.openMainWindow(newChatRoute(deps.lastRoute())) }, + { + label: 'Quick Ask', + // Display-only hint: the actual binding is the globalShortcut + // registration; tray menu accelerators on macOS render but never fire. + ...(shortcut !== 'disabled' ? { accelerator: shortcut } : {}), + click: () => deps.toggleLauncher(), + }, + { type: 'separator' }, + ] + if (recentChats.length > 0) { + template.push({ label: 'Recent Chats', enabled: false }) + for (const chat of recentChats) { + template.push({ + label: chat.title.length > 60 ? `${chat.title.slice(0, 57)}…` : chat.title, + click: () => deps.openMainWindow(chatRoute(chat)), + }) + } + template.push({ type: 'separator' }) + } + template.push( + { label: 'Settings…', click: () => deps.openSettings() }, + { label: 'Check for Updates…', click: () => deps.checkForUpdates() }, + { type: 'separator' }, + { label: 'Quit Sim', role: 'quit' } + ) + return template +} + +export interface TrayHandle { + 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 icon = nativeImage.createFromPath(iconPath) + if (icon.isEmpty()) { + logger.error('Tray icon missing; skipping tray install', { iconPath }) + return null + } + icon.setTemplateImage(true) + + const tray = new Tray(icon) + tray.setToolTip('Sim') + + let cachedChats: RecentChat[] = [] + let refreshing = false + + 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), + }) + 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 + try { + cachedChats = await fetchRecentChats() + } catch (error) { + // Signed out, offline, or older server — the menu still works, just + // without the recents section (or with the last good one). + logger.info('Recent chats unavailable for tray menu', { error }) + } finally { + refreshing = false + } + } + + /** + * Pop the menu SYNCHRONOUSLY from the cached chat list so the click feels + * instant, then refresh the cache in the background for the next open. The + * previous approach awaited the network fetch first, adding up to the fetch + * timeout (~1.5s) of dead time before anything appeared. + */ + const popMenu = () => { + if (tray.isDestroyed()) return + tray.popUpContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, cachedChats))) + void refreshChats() + } + + // Warm the cache so even the first click can show recents. + void refreshChats() + + tray.on('click', popMenu) + tray.on('right-click', popMenu) + + return { + destroy() { + if (!tray.isDestroyed()) { + tray.destroy() + } + }, + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6ef1b2c202d..c2f210fd36f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -7,6 +7,7 @@ import type { } from '@sim/browser-protocol' import type { DesktopUpdateStatus, + LauncherShortcutSettings, LocalFilesystemRequest, LocalFilesystemResponse, SimDesktopApi, @@ -42,8 +43,33 @@ const api: SimDesktopApi = { ipcRenderer.invoke('settings:get'), settingsSave: (origin: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('settings:save', origin), + settingsGetLauncherShortcut: (): Promise => + ipcRenderer.invoke('settings:launcher-shortcut-get'), + settingsSaveLauncherShortcut: (shortcut: string): Promise => + ipcRenderer.invoke('settings:launcher-shortcut-set', shortcut), localFilesystem: (request: LocalFilesystemRequest): Promise => ipcRenderer.invoke('desktop:local-filesystem', request), + launcher: { + openChat: (target: { workspaceId: string; chatId?: string }): void => { + ipcRenderer.send('launcher:open-chat', target) + }, + openApp: (): void => { + ipcRenderer.send('launcher:open-app') + }, + close: (): void => { + ipcRenderer.send('launcher:close') + }, + resize: (height: number): void => { + ipcRenderer.send('launcher:resize', height) + }, + onShown: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('launcher:shown', listener) + return () => { + ipcRenderer.removeListener('launcher:shown', listener) + } + }, + }, browserAgent: { executeTool: ( tool: BrowserToolName, diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 54645ebcc4a..9866c822c48 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -14,6 +14,7 @@ export const app = { 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(), @@ -81,6 +82,42 @@ export const screen = { bounds: { x: 0, y: 0, width: 1728, height: 1117 }, workArea: { x: 0, y: 25, width: 1728, height: 1092 }, })), + getCursorScreenPoint: vi.fn(() => ({ x: 100, y: 100 })), + getDisplayNearestPoint: vi.fn(() => ({ + bounds: { x: 0, y: 0, width: 1728, height: 1117 }, + workArea: { x: 0, y: 25, width: 1728, height: 1092 }, + })), +} + +export const globalShortcut = { + register: vi.fn(() => true), + unregister: vi.fn(), + isRegistered: vi.fn(() => false), + unregisterAll: vi.fn(), +} + +export const nativeImage = { + createFromPath: vi.fn(() => ({ + isEmpty: vi.fn(() => false), + setTemplateImage: vi.fn(), + })), + createEmpty: vi.fn(() => ({ + isEmpty: vi.fn(() => true), + setTemplateImage: vi.fn(), + })), +} + +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) } function createWebContentsMock() { @@ -143,6 +180,7 @@ export class BrowserWindow { executeJavaScript: vi.fn(() => Promise.resolve(true)), send: vi.fn(), setWindowOpenHandler: vi.fn(), + isDevToolsOpened: vi.fn(() => false), session: { addWordToSpellCheckerDictionary: vi.fn() }, } on = vi.fn() @@ -151,16 +189,22 @@ export class BrowserWindow { isMinimized = vi.fn(() => false) isFullScreen = vi.fn(() => false) isMaximized = vi.fn(() => false) + isVisible = vi.fn(() => false) getNormalBounds = vi.fn(() => ({ x: 0, y: 0, width: 1360, height: 860 })) + getBounds = vi.fn(() => ({ x: 524, y: 298, width: 680, 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() + setVisibleOnAllWorkspaces = vi.fn() + setAlwaysOnTop = vi.fn() getSize = vi.fn(() => [1180, 850]) getContentSize = vi.fn(() => [1180, 850]) contentView = { diff --git a/apps/desktop/static/settings.html b/apps/desktop/static/settings.html index ed845da3f48..b0c75198467 100644 --- a/apps/desktop/static/settings.html +++ b/apps/desktop/static/settings.html @@ -69,12 +69,34 @@ input:focus { border-color: var(--accent); } + select { + width: 100%; + border: 1px solid var(--border); + background: transparent; + color: var(--fg); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + outline: none; + } + select:focus { + border-color: var(--accent); + } + .section { + margin-top: 20px; + } .hint { font-size: 11px; color: var(--muted); line-height: 1.5; margin-top: 8px; } + .warning { + font-size: 12px; + color: var(--error); + margin-top: 8px; + min-height: 16px; + } .error { font-size: 12px; color: var(--error); @@ -117,6 +139,18 @@

Server

Changing the server keeps a separate sign-in per server.
+ +
+

Quick Ask

+ + +
+ Summons the Quick Ask panel from anywhere, even when Sim is in the background. Changes + apply immediately. +
+
+
+
@@ -125,6 +159,15 @@

Server

const bridge = window.simDesktop const input = document.getElementById('origin') const errorEl = document.getElementById('error') + const shortcutSelect = document.getElementById('shortcut') + const shortcutWarning = document.getElementById('shortcut-warning') + + const SHORTCUT_LABELS = { + 'Alt+Space': '⌥ Space', + 'Command+Shift+Space': '⌘ ⇧ Space', + 'Control+Space': '⌃ Space', + disabled: 'Disabled', + } bridge?.settingsGet().then((settings) => { input.value = settings.origin @@ -132,6 +175,29 @@

Server

input.select() }) + function renderShortcut(state) { + if (!state) return + shortcutSelect.replaceChildren( + ...state.presets.map((preset) => { + const option = document.createElement('option') + option.value = preset + option.textContent = SHORTCUT_LABELS[preset] || preset + option.selected = preset === state.shortcut + return option + }) + ) + shortcutWarning.textContent = + state.status === 'failed' + ? 'This shortcut is in use by another app (like ChatGPT or Raycast). Pick a different one.' + : '' + } + + bridge?.settingsGetLauncherShortcut().then(renderShortcut) + + shortcutSelect.addEventListener('change', async () => { + renderShortcut(await bridge?.settingsSaveLauncherShortcut(shortcutSelect.value)) + }) + async function save() { errorEl.textContent = '' const result = await bridge?.settingsSave(input.value) 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/sim/app/desktop/launcher/launcher.tsx b/apps/sim/app/desktop/launcher/launcher.tsx new file mode 100644 index 00000000000..9867b67c528 --- /dev/null +++ b/apps/sim/app/desktop/launcher/launcher.tsx @@ -0,0 +1,469 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSession } from '@/lib/auth/auth-client' +import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' +import { type LauncherTurn, useLauncherChat } from '@/app/desktop/launcher/use-launcher-chat' +import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown' +import { + type ContentSegment, + parseSpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' +import { useSpeechToText } from '@/hooks/use-speech-to-text' + +const RECENT_CHATS_SHOWN = 5 + +/** Transcript cap: window max height (600) minus the input bar + padding. */ +const TRANSCRIPT_MAX_HEIGHT_PX = 500 + +function launcherBridge() { + return typeof window !== 'undefined' ? window.simDesktop?.launcher : undefined +} + +/** + * Quick Ask panel UI. Idle state shows a prompt input, workspace picker, and + * recent chats; submitting streams the response inline (text + suggested + * options) with a one-line working indicator and an Open in Sim escape hatch + * for anything that needs the full app. + */ +export function Launcher() { + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + if (!mounted) { + return
+ } + if (!launcherBridge()) { + return ( + +

+ Quick Ask is part of the Sim desktop app. Download it at sim.ai. +

+
+ ) + } + return +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ) +} + +function LauncherPanel() { + const { data: session, isPending: isSessionPending } = useSession() + const isAuthenticated = !isSessionPending && Boolean(session?.user) + const { data: workspacesData } = useWorkspacesWithMetadata(isAuthenticated) + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + const [draft, setDraft] = useState('') + const { state, send, reset } = useLauncherChat() + const inputRef = useRef(null) + const rootRef = useRef(null) + const transcriptRef = useRef(null) + + const workspaces = workspacesData?.workspaces ?? [] + const workspaceId = useMemo(() => { + if (selectedWorkspaceId && workspaces.some((w) => w.id === selectedWorkspaceId)) { + return selectedWorkspaceId + } + const recent = WorkspaceRecencyStorage.getMostRecent() + if (recent && workspaces.some((w) => w.id === recent)) { + return recent + } + const lastActive = workspacesData?.lastActiveWorkspaceId + if (lastActive && workspaces.some((w) => w.id === lastActive)) { + return lastActive + } + return workspaces[0]?.id ?? null + }, [selectedWorkspaceId, workspaces, workspacesData?.lastActiveWorkspaceId]) + + const isStreaming = state.status === 'streaming' + const hasConversation = state.turns.length > 0 + + const { data: recentChats } = useMothershipChats( + !hasConversation && workspaceId ? workspaceId : undefined + ) + + /** Each summon starts fresh: clear any finished conversation, focus input. */ + useEffect(() => { + const bridge = launcherBridge() + if (!bridge) return + return bridge.onShown(() => { + if (!isStreaming) { + reset() + setDraft('') + } + inputRef.current?.focus() + }) + }, [reset, isStreaming]) + + useEffect(() => { + inputRef.current?.focus() + }, [isAuthenticated]) + + /** + * Keep the panel window sized to the content (main process clamps). The + * root is intentionally NOT viewport-capped: its natural height IS the + * content height, so the ResizeObserver fires as content grows and the + * window follows. Internal scrolling only happens inside the transcript, + * which carries its own fixed max-height. + */ + useEffect(() => { + const root = rootRef.current + const bridge = launcherBridge() + if (!root || !bridge) return + const report = () => bridge.resize(Math.ceil(root.getBoundingClientRect().height)) + report() + const observer = new ResizeObserver(report) + observer.observe(root) + return () => observer.disconnect() + }, []) + + /** Pin the transcript to the latest content while a response streams. */ + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turn/working change + useEffect(() => { + const el = transcriptRef.current + if (el) { + el.scrollTop = el.scrollHeight + } + }, [state.turns, state.working]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + launcherBridge()?.close() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + const sendMessage = useCallback( + (text: string) => { + const trimmed = text.trim() + if (!trimmed || !workspaceId) return + void send(trimmed, workspaceId) + }, + [workspaceId, send] + ) + + const submit = useCallback(() => { + if (!draft.trim()) return + sendMessage(draft) + setDraft('') + }, [draft, sendMessage]) + + // Voice input: the exact same speech-to-text pipeline as the mothership + // chat composer (ElevenLabs Scribe via /api/speech/token, gated on the + // server having the key). The transcript is appended to whatever the user + // had already typed, captured as a prefix when listening starts. + const sttPrefixRef = useRef('') + const { + isListening, + isSupported: isSttSupported, + toggleListening: rawToggleListening, + } = useSpeechToText({ + onTranscript: (text) => { + const prefix = sttPrefixRef.current + setDraft(prefix ? `${prefix} ${text}` : text) + }, + workspaceId: workspaceId ?? undefined, + }) + + const toggleListening = useCallback(() => { + if (!isListening) { + sttPrefixRef.current = draft.trim() + inputRef.current?.focus() + } + rawToggleListening() + }, [isListening, draft, rawToggleListening]) + + const openInSim = useCallback(() => { + if (!workspaceId) return + launcherBridge()?.openChat({ + workspaceId, + ...(state.chatId ? { chatId: state.chatId } : {}), + }) + }, [workspaceId, state.chatId]) + + if (!isSessionPending && !session?.user) { + return ( + +
+

Sign in to Sim to use Quick Ask.

+ +
+
+ ) + } + + return ( + +
+
+