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

hi'], + ['javascript:alert(1)'], + ['about:blank'], + ['android://token@com.example/'], + ['not a url'], + [''], + ])('refuses %s, which cannot host a credential', (input) => { + expect(normalizeOrigin(input)).toBeNull() + }) +}) + +describe('exact-origin identity', () => { + it('reduces only identical sites to the same origin', () => { + // Each of these is a different site as far as filling is concerned. + // Widening any of them is how a password reaches somewhere it should not. + expect(normalizeOrigin('https://example.com')).not.toBe( + normalizeOrigin('https://sub.example.com') + ) + expect(normalizeOrigin('https://accounts.google.com')).not.toBe( + normalizeOrigin('https://google.com') + ) + expect(normalizeOrigin('https://example.com')).not.toBe(normalizeOrigin('http://example.com')) + expect(normalizeOrigin('https://example.com')).not.toBe( + normalizeOrigin('https://example.com:8443') + ) + expect(normalizeOrigin('https://example.com/a')).toBe(normalizeOrigin('https://EXAMPLE.com/b')) + }) +}) + +describe('normalizeUsername', () => { + it('trims but preserves case', () => { + expect(normalizeUsername(' Ada ')).toBe('Ada') + expect(normalizeUsername('ada')).not.toBe(normalizeUsername('Ada')) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/origin.ts b/apps/desktop/src/main/browser-credentials/origin.ts new file mode 100644 index 00000000000..b84c2b992c7 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/origin.ts @@ -0,0 +1,38 @@ +/** + * Exact-origin normalization for credential matching. + * + * Version one matches on `scheme + host + effective port` and nothing looser: + * `https://accounts.google.com` is not `https://google.com`, `https://x.com` is + * not `http://x.com`, and a subdomain is a different site. Broader affiliation + * is a deliberate non-goal — it is the kind of rule that is easy to widen by + * accident and hard to explain to a user, and getting it wrong means a + * password typed into the wrong site. + */ + +/** Ports that are implied by the scheme and must not change identity. */ +const DEFAULT_PORTS: Record = { 'https:': '443', 'http:': '80' } + +/** + * The canonical origin for `value`, or null when it cannot host a credential. + * + * Rejects everything that is not http(s): `file:`, `data:`, `javascript:`, and + * opaque origins have no trustworthy identity to bind a password to. + */ +export function normalizeOrigin(value: string): string | null { + let url: URL + try { + url = new URL(value) + } catch { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null + if (!url.hostname) return null + + const port = url.port && url.port !== DEFAULT_PORTS[url.protocol] ? `:${url.port}` : '' + return `${url.protocol}//${url.hostname.toLowerCase()}${port}` +} + +/** Usernames compare case-sensitively but ignore surrounding whitespace. */ +export function normalizeUsername(username: string): string { + return username.trim() +} diff --git a/apps/desktop/src/main/browser-credentials/os-auth.test.ts b/apps/desktop/src/main/browser-credentials/os-auth.test.ts new file mode 100644 index 00000000000..cc7f433ae63 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/os-auth.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const promptTouchID = vi.fn(async () => undefined) +const canPromptTouchID = vi.fn(() => true) +const showMessageBox = vi.fn(async () => ({ response: 1 })) + +vi.mock('electron', () => ({ + systemPreferences: { + get canPromptTouchID() { + return canPromptTouchID + }, + get promptTouchID() { + return promptTouchID + }, + }, + dialog: { + get showMessageBox() { + return showMessageBox + }, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + +const { authorizeForSecret, revokeSecretAuthorization } = await import( + '@/main/browser-credentials/os-auth' +) + +const GRACE_MS = 30_000 + +const realPlatform = process.platform + +/** + * Touch ID is reachable only on darwin, so the suite pins the platform rather + * than inheriting the runner's. Without this the biometric expectations below + * pass on a Mac and fail on Linux CI, where the gate sends every call to the + * fallback dialog instead. + */ +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function request(credentialId: string) { + return { credentialId, reason: 'show a saved password', action: 'Show password' } +} + +describe('authorizeForSecret', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + revokeSecretAuthorization() + setPlatform('darwin') + canPromptTouchID.mockReturnValue(true) + promptTouchID.mockResolvedValue(undefined) + }) + + afterEach(() => { + setPlatform(realPlatform) + }) + + it('asks the OS the first time a credential is used', async () => { + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('does not ask again while the proof of presence is fresh', async () => { + await authorizeForSecret(request('c1')) + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + + // The user proved they were here seconds ago and the plaintext is likely + // still on their screen; a second prompt would protect nothing. + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('confines a grant to the credential it was granted for', async () => { + await authorizeForSecret(request('c1')) + await expect(authorizeForSecret(request('c2'))).resolves.toBe(true) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('asks again once the grant lapses', async () => { + vi.useFakeTimers() + await authorizeForSecret(request('c1')) + + vi.advanceTimersByTime(GRACE_MS) + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('holds the grant right up to the boundary', async () => { + vi.useFakeTimers() + await authorizeForSecret(request('c1')) + + vi.advanceTimersByTime(GRACE_MS - 1) + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(1) + }) + + it('grants nothing when the user declines', async () => { + promptTouchID.mockRejectedValueOnce(new Error('cancelled')) + await expect(authorizeForSecret(request('c1'))).resolves.toBe(false) + + // A refusal must not be cached as a grant, nor as a standing denial. + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('asks again after the credential is explicitly revoked', async () => { + await authorizeForSecret(request('c1')) + revokeSecretAuthorization('c1') + await authorizeForSecret(request('c1')) + + expect(promptTouchID).toHaveBeenCalledTimes(2) + }) + + it('revokes every credential when given no id', async () => { + await authorizeForSecret(request('c1')) + await authorizeForSecret(request('c2')) + revokeSecretAuthorization() + + await authorizeForSecret(request('c1')) + await authorizeForSecret(request('c2')) + expect(promptTouchID).toHaveBeenCalledTimes(4) + }) + + it('labels the fallback dialog with the action it is authorizing', async () => { + canPromptTouchID.mockReturnValue(false) + await authorizeForSecret({ + credentialId: 'c1', + reason: 'copy a saved password', + action: 'Copy password', + }) + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Copy password?', + buttons: ['Cancel', 'Copy password'], + detail: expect.stringContaining('copy a saved password'), + }) + ) + }) + + it('fails closed when the fallback dialog cannot be shown', async () => { + canPromptTouchID.mockReturnValue(false) + showMessageBox.mockRejectedValueOnce(new Error('no window')) + + await expect(authorizeForSecret(request('c1'))).resolves.toBe(false) + }) + + it('never reaches for Touch ID off darwin', async () => { + // Electron exposes canPromptTouchID on every platform; only the darwin + // guard keeps a non-Mac build out of the biometric path. + setPlatform('linux') + + await expect(authorizeForSecret(request('c1'))).resolves.toBe(true) + + expect(promptTouchID).not.toHaveBeenCalled() + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts new file mode 100644 index 00000000000..c4c3ed830ad --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { dialog, systemPreferences } from 'electron' + +const logger = createLogger('BrowserCredentialAuth') + +/** + * How long proving presence for one credential stands before Sim asks again. + * + * Matches the reveal window in the settings UI, so the prompt comes back at + * the same moment an on-screen password re-masks. Within the window the user + * has already proven they are at the keyboard, and the plaintext they proved + * their way to is typically still on screen — asking a second time to put that + * same string on the clipboard is friction that buys nothing, and teaches + * people to approve prompts without reading them. + */ +const AUTH_GRACE_MS = 30_000 + +/** Credential id to the moment its proof of presence lapses. */ +const provenUntil = new Map() + +export interface SecretAuthRequest { + /** + * The credential the grant covers. Proving presence for one saved password + * says nothing about any other, so grants never widen past the id they were + * granted for. + */ + credentialId: string + /** Completes "Sim is about to ..." in the prompt. */ + reason: string + /** Confirm-button label and title for the non-biometric fallback. */ + action: string +} + +function hasFreshProof(credentialId: string): boolean { + const expiry = provenUntil.get(credentialId) + if (expiry === undefined) return false + if (Date.now() >= expiry) { + provenUntil.delete(credentialId) + return false + } + return true +} + +/** + * Drops standing grants, so a re-import or a delete cannot leave a grant + * attached to an id that now means something else. + * + * Called with no id, it revokes everything. + */ +export function revokeSecretAuthorization(credentialId?: string): void { + if (credentialId === undefined) provenUntil.clear() + else provenUntil.delete(credentialId) +} + +/** + * The authorization step in front of revealing or copying a saved password. + * + * Showing a password is the one place where plaintext leaves the main process + * for the Sim renderer, so it is not enough that the page asked nicely — the + * person at the keyboard has to prove they are there, in a surface the + * renderer cannot drive or fake. + * + * Touch ID is used when the machine has it. Where it does not (a Mac mini + * without a Touch ID keyboard, or a user who has not enrolled), the fallback + * is a native modal that the main process owns. That is genuinely weaker than + * biometric auth — it proves presence, not identity — so it is a deliberate, + * documented trade rather than a silent one, and it still cannot be triggered + * without a real click in the page. + * + * A grant lapses on its own after {@link AUTH_GRACE_MS}; it never removes the + * user-gesture requirement the IPC layer enforces, so it shortens the prompt + * for a present user rather than opening a path for an absent one. + */ +export async function authorizeForSecret({ + credentialId, + reason, + action, +}: SecretAuthRequest): Promise { + if (hasFreshProof(credentialId)) return true + if (!(await promptForSecret(reason, action))) return false + provenUntil.set(credentialId, Date.now() + AUTH_GRACE_MS) + return true +} + +async function promptForSecret(reason: string, action: string): Promise { + if (process.platform === 'darwin' && systemPreferences.canPromptTouchID?.()) { + try { + await systemPreferences.promptTouchID(reason) + return true + } catch { + // A cancelled or failed prompt is a refusal, not an error to report. + return false + } + } + + try { + const { response } = await dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', action], + defaultId: 1, + cancelId: 0, + message: `${action}?`, + detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`, + noLink: true, + }) + return response === 1 + } catch (error) { + // Fail closed: if the confirmation cannot be shown, nothing is revealed. + logger.warn('Could not present the credential confirmation') + return false + } +} diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts new file mode 100644 index 00000000000..a763792d22b --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -0,0 +1,211 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { CredentialVault } from '@/main/browser-credentials/vault' + +/** + * A stand-in for Electron `safeStorage`. It is deliberately reversible so the + * vault's own logic can be exercised; the tests that matter for secrecy assert + * that the vault always routes through this provider and refuses to write + * anything when it reports itself unavailable. + */ +function encryption(available = true) { + return { + isEncryptionAvailable: vi.fn(() => available), + encryptString: vi.fn((value: string) => Buffer.from(`sealed:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^sealed:/, '')), + } +} + +let directory: string +let vaultPath: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-vault-test-')) + vaultPath = join(directory, 'browser-credentials.json') +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const CANDIDATES = [ + { origin: 'https://example.com/login', username: 'ada', password: 'hunter2' }, + { origin: 'https://other.test', username: 'grace', password: 'correct-horse' }, +] + +describe('CredentialVault', () => { + it('stores and lists credentials without their passwords', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + + await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({ + added: 2, + updated: 0, + skipped: 0, + }) + + const listed = await vault.list() + expect(listed).toHaveLength(2) + expect(listed[0]).toMatchObject({ origin: 'https://example.com', username: 'ada' }) + expect(listed.every((entry) => !('password' in entry))).toBe(true) + }) + + it('writes only through the encryption provider, and never plaintext', async () => { + const provider = encryption() + const vault = new CredentialVault(vaultPath, provider) + + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect(provider.encryptString).toHaveBeenCalled() + const onDisk = await readFile(vaultPath, 'utf8') + expect(onDisk).not.toContain('hunter2') + expect(onDisk).not.toContain('correct-horse') + expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) }) + }) + + it('writes the vault file owner-only', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect((await stat(vaultPath)).mode & 0o077).toBe(0) + }) + + it('refuses to store anything when secure storage is unavailable', async () => { + // No plaintext fallback: a password file Sim cannot encrypt is worse than + // a feature the user does not get. + const provider = encryption(false) + const vault = new CredentialVault(vaultPath, provider) + + expect(vault.isAvailable()).toBe(false) + await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({ + added: 0, + updated: 0, + skipped: 2, + }) + expect(provider.encryptString).not.toHaveBeenCalled() + await expect(readFile(vaultPath, 'utf8')).rejects.toThrow() + }) + + it('treats exact origin plus username as one credential', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + // A different subdomain and a different username are both new credentials. + await expect( + vault.importCredentials( + [ + { origin: 'https://sub.example.com', username: 'ada', password: 'x' }, + { origin: 'https://example.com', username: 'grace', password: 'y' }, + ], + 'keep-existing' + ) + ).resolves.toMatchObject({ added: 2 }) + }) + + it('keeps the existing password on conflict by default', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await expect( + vault.importCredentials( + [{ origin: 'https://example.com', username: 'ada', password: 'changed' }], + 'keep-existing' + ) + ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 }) + + const stored = await vault.readForFill( + (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '', + 'https://example.com' + ) + expect(stored?.password).toBe('hunter2') + }) + + it('replaces the password on conflict when asked', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await expect( + vault.importCredentials( + [{ origin: 'https://example.com', username: 'ada', password: 'changed' }], + 'replace' + ) + ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 }) + + const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '' + expect((await vault.readForFill(id, 'https://example.com'))?.password).toBe('changed') + }) + + it('skips candidates with no usable origin or an empty password', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + + await expect( + vault.importCredentials( + [ + { origin: 'android://token@com.example/', username: 'a', password: 'p' }, + { origin: 'https://example.com', username: 'b', password: '' }, + ], + 'keep-existing' + ) + ).resolves.toEqual({ added: 0, updated: 0, skipped: 2 }) + }) + + it('only returns a password for the origin it belongs to', async () => { + // The last guard before plaintext exists: an id alone is not enough. + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '' + + expect(await vault.readForFill(id, 'https://example.com')).toMatchObject({ + password: 'hunter2', + }) + expect(await vault.readForFill(id, 'https://evil.test')).toBeNull() + expect(await vault.readForFill('no-such-id', 'https://example.com')).toBeNull() + }) + + it('lists matches for one exact origin only', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + expect(await vault.listForOrigin('https://example.com/login')).toHaveLength(1) + expect(await vault.listForOrigin('https://sub.example.com')).toHaveLength(0) + expect(await vault.listForOrigin('not-a-url')).toHaveLength(0) + }) + + it('forgets one credential', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + const id = (await vault.list())[0].id + + await expect(vault.delete(id)).resolves.toBe(true) + await expect(vault.delete(id)).resolves.toBe(false) + expect(await vault.list()).toHaveLength(1) + }) + + it('destroys everything on clear', async () => { + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + await vault.clear() + + expect(await vault.list()).toEqual([]) + await expect(readFile(vaultPath, 'utf8')).rejects.toThrow() + // Clearing an already-empty vault is not an error. + await expect(vault.clear()).resolves.toBeUndefined() + }) + + it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => { + const provider = encryption() + provider.decryptString = vi.fn(() => { + throw new Error('wrong key') + }) + const vault = new CredentialVault(vaultPath, encryption()) + await vault.importCredentials(CANDIDATES, 'keep-existing') + + const brokenVault = new CredentialVault(vaultPath, provider) + await expect(brokenVault.list()).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts new file mode 100644 index 00000000000..3d949235aa7 --- /dev/null +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -0,0 +1,286 @@ +import { readFile } from 'node:fs/promises' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { generateId } from '@sim/utils/id' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin' + +/** + * The encrypted credential store. + * + * The whole record set is encrypted as one blob with Electron `safeStorage` + * (Keychain on macOS), written atomically with restrictive permissions. There + * is no plaintext fallback: when OS-backed encryption is unavailable the vault + * reports itself unavailable and refuses to store anything, because a password + * file Sim cannot encrypt is worse than a feature the user does not get. + * + * Records are decrypted per operation rather than cached. The data is small and + * local, and not holding a decrypted password list in memory for the life of + * the process is worth far more than the saved microseconds. + */ + +const VAULT_VERSION = 1 + +export interface CredentialRecord { + id: string + origin: string + username: string + password: string + /** The site's own icon as a `data:` URL, copied from the source browser. */ + icon?: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' +} + +/** How an import should treat a credential that already exists. */ +export type ConflictPolicy = 'keep-existing' | 'replace' + +export interface ImportCandidate { + origin: string + username: string + password: string + icon?: string +} + +export interface ImportOutcome { + added: number + updated: number + skipped: number +} + +interface EncryptedVaultEnvelope { + version: typeof VAULT_VERSION + ciphertext: string +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +function isCredentialRecord(value: unknown): value is CredentialRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + return ( + typeof record.id === 'string' && + typeof record.origin === 'string' && + typeof record.username === 'string' && + typeof record.password === 'string' && + typeof record.createdAt === 'string' && + typeof record.updatedAt === 'string' && + (record.source === 'chrome' || record.source === 'manual') + ) +} + +function toMetadata(record: CredentialRecord): BrowserCredentialMetadata { + return { + id: record.id, + origin: record.origin, + username: record.username, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + source: record.source, + ...(record.icon ? { icon: record.icon } : {}), + } +} + +export class CredentialVault { + constructor( + private readonly filePath: string, + private readonly encryption: EncryptionProvider = safeStorage, + private readonly now: () => Date = () => new Date() + ) {} + + /** + * Whether credentials can be stored at all. The UI must hide or disable + * password features when this is false rather than degrading to plaintext. + */ + isAvailable(): boolean { + // Guarded: on a Linux box with no keyring this throws rather than + // returning false, and an unguarded call propagated out of a password + // import. The site directory has always defended against it; this did not. + try { + return this.encryption.isEncryptionAvailable() + } catch { + return false + } + } + + /** + * Decrypted records. Private on purpose — nothing outside this class should + * hold a list of passwords, and callers get metadata instead. + */ + private async read(): Promise { + if (!this.isAvailable()) return [] + try { + const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as + | Partial + | undefined + if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') return [] + const parsed = JSON.parse( + this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + ) as unknown + return Array.isArray(parsed) ? parsed.filter(isCredentialRecord) : [] + } catch { + // A missing, corrupt, or undecryptable vault reads as empty rather than + // throwing: the browser must stay usable, and a failed write is where + // the user is told something is wrong. + return [] + } + } + + private async write(records: CredentialRecord[]): Promise { + if (!this.isAvailable()) return false + const envelope: EncryptedVaultEnvelope = { + version: VAULT_VERSION, + ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + } + await writeJsonFileAtomically(this.filePath, envelope) + return true + } + + /** Every stored credential, without passwords, newest activity first. */ + async list(): Promise { + const records = await this.read() + return records + .map(toMetadata) + .sort( + (left, right) => + left.origin.localeCompare(right.origin) || left.username.localeCompare(right.username) + ) + } + + /** Credentials whose origin exactly matches `origin`, without passwords. */ + async listForOrigin(origin: string): Promise { + const normalized = normalizeOrigin(origin) + if (normalized === null) return [] + return (await this.read()) + .filter((record) => record.origin === normalized) + .map(toMetadata) + .sort((left, right) => left.username.localeCompare(right.username)) + } + + /** + * The username and password for one credential. + * + * Main-process only, and only for an authorized fill. These values must + * never be returned through the preload bridge, written to a log, put on a + * span, or persisted anywhere outside this vault. + */ + async readForFill( + id: string, + origin: string + ): Promise<{ username: string; password: string } | null> { + const normalized = normalizeOrigin(origin) + if (normalized === null) return null + // The origin is re-checked here, at the last moment before plaintext is + // produced, so a stale or swapped id cannot pull a password for a site + // other than the one the fill was authorized against. + const record = (await this.read()).find( + (candidate) => candidate.id === id && candidate.origin === normalized + ) + return record ? { username: record.username, password: record.password } : null + } + + /** + * The password for one credential, by id alone. + * + * Unlike {@link readForFill} there is no origin to check against, because + * the password manager lists credentials rather than matching a page. That + * makes this the weakest-guarded read in the vault, so its only caller must + * be the reveal path, behind OS authentication. + */ + async revealPassword(id: string): Promise { + return (await this.read()).find((record) => record.id === id)?.password ?? null + } + + async delete(id: string): Promise { + const records = await this.read() + const remaining = records.filter((record) => record.id !== id) + if (remaining.length === records.length) return false + return this.write(remaining) + } + + /** + * Merges imported credentials. + * + * Identity is exact origin plus username, per the import design: the same + * login on the same site is the same credential, and everything else is a + * new one. A conflicting entry is kept or replaced according to `policy` — + * never silently duplicated. + */ + async importCredentials( + candidates: ImportCandidate[], + policy: ConflictPolicy + ): Promise { + if (!this.isAvailable()) return { added: 0, updated: 0, skipped: candidates.length } + + const records = await this.read() + const byIdentity = new Map( + records.map((record) => [`${record.origin}\u0000${record.username}`, record]) + ) + const timestamp = this.now().toISOString() + const outcome: ImportOutcome = { added: 0, updated: 0, skipped: 0 } + let iconsAdded = false + + for (const candidate of candidates) { + const origin = normalizeOrigin(candidate.origin) + const username = normalizeUsername(candidate.username) + if (origin === null || candidate.password.length === 0) { + outcome.skipped += 1 + continue + } + + const identity = `${origin}\u0000${username}` + const existing = byIdentity.get(identity) + if (existing) { + if (policy === 'keep-existing' || existing.password === candidate.password) { + // A re-import still refreshes a missing icon; that is not a + // credential change, so it does not count as an update. + if (candidate.icon && !existing.icon) { + existing.icon = candidate.icon + iconsAdded = true + } + outcome.skipped += 1 + continue + } + existing.password = candidate.password + existing.updatedAt = timestamp + existing.source = 'chrome' + if (candidate.icon) existing.icon = candidate.icon + outcome.updated += 1 + continue + } + + const record: CredentialRecord = { + id: generateId(), + origin, + username, + password: candidate.password, + ...(candidate.icon ? { icon: candidate.icon } : {}), + createdAt: timestamp, + updatedAt: timestamp, + source: 'chrome', + } + byIdentity.set(identity, record) + records.push(record) + outcome.added += 1 + } + + if (outcome.added === 0 && outcome.updated === 0 && !iconsAdded) return outcome + if (!(await this.write(records))) { + return { added: 0, updated: 0, skipped: candidates.length } + } + return outcome + } + + /** + * Destroys the vault. Sign-out runs this so the next Sim account on this + * machine cannot inherit the previous user's passwords. + */ + async clear(): Promise { + await removeFileIfPresent(this.filePath) + } +} diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts new file mode 100644 index 00000000000..800aecfc61a --- /dev/null +++ b/apps/desktop/src/main/browser-import/browser-sources.ts @@ -0,0 +1,99 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * The Chromium-family browsers Sim can import from on macOS. + * + * Every one of these shares Chromium's profile layout and its `OSCrypt` + * encryption, so a single reader handles all of them. What actually differs + * between browsers is only two things: where the user-data directory lives, + * and which login-Keychain item holds the Safe Storage password. That is what + * this table is. + * + * Safari is deliberately absent and cannot be added here. It stores cookies in + * an undocumented binary format inside a TCC-protected container (reading it + * would require Full Disk Access, a far broader grant than a Keychain prompt), + * and its passwords are iCloud Keychain items with per-item ACLs rather than a + * database — there is no key to derive and nothing to decrypt. The supported + * path for Safari is a user-driven CSV export. + */ +export interface BrowserSource { + /** Stable, opaque identifier used to namespace profile ids. */ + id: string + /** Product name as the user knows it. */ + label: string + /** User-data directory, relative to the home directory. */ + userDataSegments: readonly string[] + /** The login-Keychain generic-password item holding the Safe Storage key. */ + keychain: { service: string; account: string } +} + +export const BROWSER_SOURCES: readonly BrowserSource[] = [ + { + id: 'chrome', + label: 'Chrome', + userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'], + keychain: { service: 'Chrome Safe Storage', account: 'Chrome' }, + }, + { + id: 'arc', + label: 'Arc', + userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'], + keychain: { service: 'Arc Safe Storage', account: 'Arc' }, + }, + { + id: 'dia', + label: 'Dia', + userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'], + keychain: { service: 'Dia Safe Storage', account: 'Dia' }, + }, + { + id: 'brave', + label: 'Brave', + userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'], + keychain: { service: 'Brave Safe Storage', account: 'Brave' }, + }, + { + id: 'edge', + label: 'Microsoft Edge', + userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'], + keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' }, + }, + { + id: 'vivaldi', + label: 'Vivaldi', + userDataSegments: ['Library', 'Application Support', 'Vivaldi'], + keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' }, + }, + { + id: 'chromium', + label: 'Chromium', + userDataSegments: ['Library', 'Application Support', 'Chromium'], + keychain: { service: 'Chromium Safe Storage', account: 'Chromium' }, + }, +] as const + +export function userDataDirFor(source: BrowserSource, home: string = homedir()): string { + return join(home, ...source.userDataSegments) +} + +/** + * Splits a bridge profile id back into its browser and profile directory. + * + * Ids are namespaced (`arc:Profile 1`) because profile directory names repeat + * across browsers — every one of them has a `Default`. Returns null for + * anything malformed; the caller then resolves against discovered profiles + * anyway, so a bad id can never become a path. + */ +export function parseProfileId(profileId: string): { sourceId: string; directory: string } | null { + const separator = profileId.indexOf(':') + if (separator <= 0 || separator === profileId.length - 1) return null + return { + sourceId: profileId.slice(0, separator), + directory: profileId.slice(separator + 1), + } +} + +export function formatProfileId(sourceId: string, directory: string): string { + return `${sourceId}:${directory}` +} diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts new file mode 100644 index 00000000000..2ef3d603e31 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts @@ -0,0 +1,249 @@ +import { createCipheriv, createHash } from 'node:crypto' +import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { sleep } from '@sim/utils/helpers' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' + +/** + * Exercises the reader against a synthetic Chrome profile. Fixtures are built + * here on purpose: a test must never read a developer's real Chrome profile or + * touch their Keychain. + */ +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +const KEY = deriveEncryptionKey('test-safe-storage-password') +const NOW_SECONDS = 1_800_000_000 + +async function stagingDirectories(): Promise> { + const entries = await readdir(tmpdir()) + return new Set(entries.filter((entry) => entry.startsWith('sim-chrome-import-'))) +} + +/** + * Staging copies still present that were not there at `before`. + * + * Every reader in this folder stages through the shared `os.tmpdir()` under one + * prefix, and Vitest runs their suites in parallel workers, so a single snapshot + * can catch a sibling's copy mid-read and read as a leak. A leak of our own + * never clears, so this settles instead of sampling once. + */ +async function stagingLeftBehindSince(before: ReadonlySet): Promise { + let leftover: string[] = [] + for (let attempt = 0; attempt < 20; attempt++) { + leftover = [...(await stagingDirectories())].filter((entry) => !before.has(entry)) + if (leftover.length === 0) return leftover + await sleep(25) + } + return leftover +} + +function chromeTime(unixSeconds: number): bigint { + return BigInt(unixSeconds + 11_644_473_600) * 1_000_000n +} + +function encryptV10(plaintext: Buffer): Uint8Array { + const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()]) +} + +interface FixtureCookie { + hostKey: string + name: string + encryptedValue?: Uint8Array + value?: string + expiresUtc?: bigint + hasExpires?: number + isPersistent?: number + isSecure?: number + sameSite?: number + path?: string +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-chrome-cookies-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeCookieDatabase(cookies: FixtureCookie[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Cookies') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE cookies ( + creation_utc INTEGER NOT NULL, host_key TEXT NOT NULL, name TEXT NOT NULL, + value TEXT NOT NULL, path TEXT NOT NULL, expires_utc INTEGER NOT NULL, + is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL, + has_expires INTEGER NOT NULL, is_persistent INTEGER NOT NULL, + samesite INTEGER NOT NULL, encrypted_value BLOB + ) + `) + const insert = database.prepare(` + INSERT INTO cookies (creation_utc, host_key, name, value, path, expires_utc, + is_secure, is_httponly, has_expires, is_persistent, samesite, encrypted_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + for (const cookie of cookies) { + insert.run( + chromeTime(NOW_SECONDS - 100), + cookie.hostKey, + cookie.name, + cookie.value ?? '', + cookie.path ?? '/', + cookie.expiresUtc ?? chromeTime(NOW_SECONDS + 3600), + cookie.isSecure ?? 1, + 1, + cookie.hasExpires ?? 1, + cookie.isPersistent ?? 1, + cookie.sameSite ?? 1, + cookie.encryptedValue ?? null + ) + } + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => { + it('decrypts a v10 cookie and preserves its attributes', async () => { + const path = await writeCookieDatabase([ + { + hostKey: '.example.com', + name: 'session', + encryptedValue: encryptV10(Buffer.from('token-123')), + }, + ]) + + const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(result.rowsSeen).toBe(1) + expect(result.cookies).toHaveLength(1) + const [cookie] = result.cookies + expect(cookie).toMatchObject({ + url: 'https://example.com/', + name: 'session', + value: 'token-123', + domain: '.example.com', + secure: true, + httpOnly: true, + sameSite: 'lax', + }) + expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 3600, 3) + }) + + it('reads Chrome timestamps that overflow a safe integer', async () => { + const path = await writeCookieDatabase([ + { + hostKey: 'example.com', + name: 'a', + encryptedValue: encryptV10(Buffer.from('v')), + expiresUtc: chromeTime(NOW_SECONDS + 86_400), + }, + ]) + + const [cookie] = (await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies + expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 86_400, 3) + }) + + it('removes a verified domain-bound prefix', async () => { + const plaintext = Buffer.concat([ + createHash('sha256').update('.example.com').digest(), + Buffer.from('bound'), + ]) + const path = await writeCookieDatabase([ + { hostKey: '.example.com', name: 'a', encryptedValue: encryptV10(plaintext) }, + ]) + + expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe('bound') + }) + + it('falls back to the plain value column for unencrypted rows', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', value: 'legacy-plaintext' }, + ]) + + expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe( + 'legacy-plaintext' + ) + }) + + it('counts unreadable and expired rows instead of failing the import', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'good', encryptedValue: encryptV10(Buffer.from('v')) }, + { hostKey: 'example.com', name: 'bad', encryptedValue: Buffer.from('v20-not-supported') }, + { + hostKey: 'example.com', + name: 'old', + encryptedValue: encryptV10(Buffer.from('v')), + expiresUtc: chromeTime(NOW_SECONDS - 10), + }, + ]) + + const result = await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(result.rowsSeen).toBe(3) + expect(result.cookies.map(({ name }) => name)).toEqual(['good']) + expect(result.skipped['decrypt-failed']).toBe(1) + expect(result.skipped.expired).toBe(1) + }) + + it('reports an unrecognised schema rather than guessing', async () => { + const path = join(directory, 'Cookies') + const { DatabaseSync } = await import('node:sqlite') + const database = new DatabaseSync(path) + database.exec('CREATE TABLE something_else (a TEXT)') + database.close() + + await expect(readBrowserCookies(path, KEY, NOW_SECONDS)).rejects.toMatchObject({ + code: 'unsupported-schema', + }) + }) + + it('reports an unreadable source rather than throwing raw', async () => { + await expect( + readBrowserCookies(join(directory, 'absent', 'Cookies'), KEY, NOW_SECONDS) + ).rejects.toMatchObject({ code: 'profile-unreadable' }) + }) + + it('leaves the source database untouched', async () => { + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) }, + ]) + const before = await stat(path) + + await readBrowserCookies(path, KEY, NOW_SECONDS) + + const after = await stat(path) + expect(after.size).toBe(before.size) + expect(after.mtimeMs).toBe(before.mtimeMs) + }) + + it('deletes its decrypted working copy', async () => { + const before = await stagingDirectories() + const path = await writeCookieDatabase([ + { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) }, + ]) + + await readBrowserCookies(path, KEY, NOW_SECONDS) + + expect(await stagingLeftBehindSince(before)).toEqual([]) + }) + + it('cleans up even when the read fails', async () => { + const before = await stagingDirectories() + await writeFile(join(directory, 'Cookies'), 'not a database') + + await expect(readBrowserCookies(join(directory, 'Cookies'), KEY, NOW_SECONDS)).rejects.toThrow() + + expect(await stagingLeftBehindSince(before)).toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.ts b/apps/desktop/src/main/browser-import/chromium-cookies.ts new file mode 100644 index 00000000000..74e79b6cd0b --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-cookies.ts @@ -0,0 +1,86 @@ +import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto' +import { translateCookieRow } from '@/main/browser-import/cookie-translate' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { + type ChromiumCookieRow, + type CookieSkipCounts, + emptySkipCounts, + type ImportableCookie, + toNumber, + toText, +} from '@/main/browser-import/types' + +/** + * Decrypts a Chrome profile's cookies. The source database is copied and read + * read-only by {@link queryBrowserDatabase}; nothing here writes to Chrome. + */ + +const MAX_COOKIE_ROWS = 50_000 + +const COOKIE_QUERY = ` + SELECT host_key, name, path, expires_utc, is_secure, is_httponly, + has_expires, is_persistent, samesite, encrypted_value, value + FROM cookies + LIMIT ${MAX_COOKIE_ROWS} +` + +export interface ReadCookiesResult { + cookies: ImportableCookie[] + skipped: CookieSkipCounts + /** Rows examined, so the caller can tell "no cookies" from "none survived". */ + rowsSeen: number +} + +function toRow(raw: Record): ChromiumCookieRow { + return { + hostKey: toText(raw.host_key), + name: toText(raw.name), + path: toText(raw.path), + expiresUtc: toNumber(raw.expires_utc), + isSecure: toNumber(raw.is_secure) !== 0, + isHttpOnly: toNumber(raw.is_httponly) !== 0, + hasExpires: toNumber(raw.has_expires) !== 0, + isPersistent: toNumber(raw.is_persistent) !== 0, + sameSite: toNumber(raw.samesite), + } +} + +/** + * Rows that fail to decrypt or cannot be aimed at a single host are counted, + * not thrown — one unreadable cookie should not cost the user the other + * thousand. + */ +export async function readBrowserCookies( + cookiesPath: string, + key: Buffer, + nowSeconds: number = Date.now() / 1000 +): Promise { + const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', COOKIE_QUERY) + const cookies: ImportableCookie[] = [] + const skipped = emptySkipCounts() + + for (const raw of rows) { + const row = toRow(raw) + const encrypted = raw.encrypted_value + let value: string | null + if (encrypted instanceof Uint8Array && encrypted.length > 0) { + value = decryptChromiumValue(Buffer.from(encrypted), key, { domain: row.hostKey }) + } else { + // Pre-encryption rows keep their value in the plain `value` column. + value = toText(raw.value) + } + if (value === null) { + skipped['decrypt-failed'] += 1 + continue + } + + const outcome = translateCookieRow(row, value, nowSeconds) + if (outcome.ok) { + cookies.push(outcome.cookie) + } else { + skipped[outcome.reason] += 1 + } + } + + return { cookies, skipped, rowsSeen: rows.length } +} diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.test.ts b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts new file mode 100644 index 00000000000..7fcf4dbd109 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts @@ -0,0 +1,88 @@ +import { createCipheriv, createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { + decryptChromiumValue, + deriveEncryptionKey, + readSafeStoragePassword, +} from '@/main/browser-import/chromium-crypto' + +/** Produces a blob in the same shape Chrome writes on macOS. */ +function encryptV10(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()]) +} + +const KEY = deriveEncryptionKey('test-safe-storage-password') + +describe('deriveEncryptionKey', () => { + it('derives a deterministic AES-128 key', () => { + expect(KEY).toHaveLength(16) + expect(deriveEncryptionKey('test-safe-storage-password').equals(KEY)).toBe(true) + expect(deriveEncryptionKey('a-different-password').equals(KEY)).toBe(false) + }) +}) + +describe('decryptChromiumValue', () => { + it('round-trips a v10 value', () => { + const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY) + expect(decryptChromiumValue(encrypted, KEY)).toBe('session-token-abc') + }) + + it('strips a domain hash prefix once it verifies against the host', () => { + const domain = '.example.com' + const plaintext = Buffer.concat([ + createHash('sha256').update(domain).digest(), + Buffer.from('bound-value'), + ]) + expect(decryptChromiumValue(encryptV10(plaintext, KEY), KEY, { domain })).toBe('bound-value') + }) + + it('leaves values from profiles without domain binding intact', () => { + // Cutting a fixed 32 bytes would silently corrupt every value written by + // an older Chrome, so the prefix is only removed when it is really a hash. + const encrypted = encryptV10(Buffer.from('a'.repeat(40)), KEY) + expect(decryptChromiumValue(encrypted, KEY, { domain: '.example.com' })).toBe('a'.repeat(40)) + }) + + it('rejects a blob that is not a supported scheme', () => { + expect(decryptChromiumValue(Buffer.from('v20somethingelse'), KEY)).toBeNull() + expect(decryptChromiumValue(Buffer.alloc(0), KEY)).toBeNull() + expect(decryptChromiumValue(Buffer.from('v10'), KEY)).toBeNull() + }) + + it('rejects a body that is not a whole number of AES blocks', () => { + expect( + decryptChromiumValue(Buffer.concat([Buffer.from('v10'), Buffer.alloc(7)]), KEY) + ).toBeNull() + }) + + it('does not return plaintext when decrypted with the wrong key', () => { + const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY) + expect(decryptChromiumValue(encrypted, deriveEncryptionKey('wrong'))).not.toBe( + 'session-token-abc' + ) + }) +}) + +describe('readSafeStoragePassword', () => { + const ARC_ITEM = { service: 'Arc Safe Storage', account: 'Arc' } + + it('reads the item belonging to the browser being imported', async () => { + // Each browser names its own item. Asking for the wrong one would prompt + // the user about a browser they did not choose. + const read = vi.fn().mockResolvedValue('arc-password') + + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBe('arc-password') + expect(read).toHaveBeenCalledExactlyOnceWith('Arc Safe Storage', 'Arc') + }) + + it('fails closed when the item is denied or missing', async () => { + const read = vi.fn().mockRejectedValue(new Error('User denied access')) + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull() + }) + + it('treats an empty password as unavailable', async () => { + const read = vi.fn().mockResolvedValue(' ') + await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull() + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.ts b/apps/desktop/src/main/browser-import/chromium-crypto.ts new file mode 100644 index 00000000000..53f18920668 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-crypto.ts @@ -0,0 +1,157 @@ +import { execFile } from 'node:child_process' +import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto' +import { promisify } from 'node:util' + +/** + * Chrome's macOS value encryption (Chromium's `OSCrypt`, scheme `v10`). + * + * The key is derived from a random password Chrome stores in the login + * Keychain. Reading it goes through the documented `security` tool, so macOS + * applies its own Keychain ACL — the user gets the standard "allow access" + * prompt for a binary that is not on the item's ACL, and a denial is a hard + * failure here. Nothing in this module weakens or works around that check. + * + * On Windows this scheme does not apply: Chrome's App-Bound Encryption is + * designed to stop other applications decrypting profile data, and the + * importer deliberately does not attempt it. + */ + +const execFileAsync = promisify(execFile) + +/** Fixed Chromium `OSCrypt` parameters for macOS. */ +const KEY_SALT = 'saltysalt' +const KEY_ITERATIONS = 1003 +const KEY_LENGTH = 16 +/** Chromium uses 16 spaces as the IV rather than a per-value nonce. */ +const INITIALIZATION_VECTOR = Buffer.alloc(16, 0x20) +const V10_PREFIX = Buffer.from('v10') +const AES_BLOCK_SIZE = 16 +/** Length of the SHA-256(domain) modern Chrome prepends to cookie plaintext. */ +const DOMAIN_HASH_LENGTH = 32 + +/** + * The user could sit on the Keychain prompt for a while, so this is a + * safety net against a wedged child process, not a UI deadline. + */ +const KEYCHAIN_TIMEOUT_MS = 120_000 + +export type KeychainPasswordReader = (service: string, account: string) => Promise + +const readKeychainItem: KeychainPasswordReader = async (service, account) => { + const { stdout } = await execFileAsync( + '/usr/bin/security', + ['find-generic-password', '-w', '-s', service, '-a', account], + { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 64 * 1024 } + ) + return stdout.trim() +} + +/** + * The Safe Storage password for one browser's Keychain item, or null when the + * item does not exist or the user refused access. + * + * Each browser names its own item (`Arc Safe Storage`, `Brave Safe Storage`, + * and so on), so the item is passed in rather than guessed at: asking for the + * wrong one would prompt the user about a browser they did not choose. + * + * The return value is a secret. It must never be logged, persisted, or sent + * anywhere. Callers should derive a key and drop the reference immediately. + */ +export async function readSafeStoragePassword( + item: { service: string; account: string }, + read: KeychainPasswordReader = readKeychainItem +): Promise { + try { + const password = (await read(item.service, item.account)).trim() + return password.length > 0 ? password : null + } catch { + // Item absent, or access denied. Fail closed. + return null + } +} + +/** Derives the AES-128 key. The result is key material — zero it after use. */ +export function deriveEncryptionKey(safeStoragePassword: string): Buffer { + return pbkdf2Sync(safeStoragePassword, KEY_SALT, KEY_ITERATIONS, KEY_LENGTH, 'sha1') +} + +/** + * Strips Chromium's PKCS#7 padding, tolerating values that carry none. + * + * A wrong key usually surfaces here or as mojibake rather than as a thrown + * error, which is why the caller treats an implausible result as a failed + * decrypt rather than trusting it. + */ +function stripPkcs7Padding(plaintext: Buffer): Buffer { + const padding = plaintext.at(-1) + if (padding === undefined || padding === 0 || padding > AES_BLOCK_SIZE) return plaintext + if (padding > plaintext.length) return plaintext + return plaintext.subarray(0, plaintext.length - padding) +} + +/** + * Removes the SHA-256(domain) prefix modern Chrome binds to cookie plaintext, + * but only after confirming the prefix really is that hash. + * + * Verifying rather than blindly dropping 32 bytes is what makes this work + * across Chrome versions: profiles written before domain binding have no + * prefix, and cutting one off them would silently corrupt every value. + */ +function stripVerifiedDomainHash(plaintext: Buffer, hostKey: string): Buffer { + if (plaintext.length < DOMAIN_HASH_LENGTH) return plaintext + const prefix = plaintext.subarray(0, DOMAIN_HASH_LENGTH) + const bareHost = hostKey.replace(/^\./, '') + for (const candidate of [hostKey, bareHost, `.${bareHost}`]) { + if (createHash('sha256').update(candidate).digest().equals(prefix)) { + return plaintext.subarray(DOMAIN_HASH_LENGTH) + } + } + return plaintext +} + +export interface DecryptOptions { + /** + * The row's `host_key`. Supply it for cookies so a domain-bound prefix can + * be recognised and removed; omit it for values that carry no prefix. + */ + domain?: string +} + +/** + * Decrypts one `v10` value, or returns null when the blob is not a supported + * scheme or does not decrypt to usable text. + * + * Never throws: a single unreadable row must not abort an import, and the + * caller counts failures instead. A null is also how a `v20`/app-bound value + * surfaces, which is the signal that this profile needs a different path + * rather than a weaker one. + */ +export function decryptChromiumValue( + encrypted: Buffer, + key: Buffer, + { domain }: DecryptOptions = {} +): string | null { + const body = encrypted.subarray(V10_PREFIX.length) + if ( + encrypted.length <= V10_PREFIX.length || + !encrypted.subarray(0, V10_PREFIX.length).equals(V10_PREFIX) || + body.length % AES_BLOCK_SIZE !== 0 + ) { + return null + } + try { + const decipher = createDecipheriv('aes-128-cbc', key, INITIALIZATION_VECTOR) + decipher.setAutoPadding(false) + let plaintext = stripPkcs7Padding(Buffer.concat([decipher.update(body), decipher.final()])) + if (domain !== undefined) { + plaintext = stripVerifiedDomainHash(plaintext, domain) + } + const value = plaintext.toString('utf8') + // A wrong key decrypts to bytes that are not a cookie value. Rejecting + // replacement characters and control bytes keeps that garbage out of the + // browser profile instead of storing it as a live cookie. + return value.includes('\uFFFD') || /[\u0000-\u001F\u007F]/.test(value) ? null : value + } catch { + return null + } +} diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.test.ts b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts new file mode 100644 index 00000000000..f2ec2679a31 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons' + +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +/** A minimal but genuinely valid 1x1 PNG. */ +const PNG = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6300010000050001' + + '0d0a2db40000000049454e44ae426082', + 'hex' +) + +interface FixtureIcon { + pageUrl: string + width?: number + data?: Uint8Array +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-favicons-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeFaviconDatabase(icons: FixtureIcon[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Favicons') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE icon_mapping (id INTEGER PRIMARY KEY, page_url TEXT, icon_id INTEGER); + CREATE TABLE favicon_bitmaps (id INTEGER PRIMARY KEY, icon_id INTEGER, image_data BLOB, width INTEGER); + `) + const mapping = database.prepare('INSERT INTO icon_mapping (page_url, icon_id) VALUES (?, ?)') + const bitmap = database.prepare( + 'INSERT INTO favicon_bitmaps (icon_id, image_data, width) VALUES (?, ?, ?)' + ) + icons.forEach((icon, index) => { + mapping.run(icon.pageUrl, index + 1) + bitmap.run(index + 1, icon.data ?? PNG, icon.width ?? 32) + }) + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserFavicons', () => { + it('returns an icon keyed by origin as a data URL', async () => { + const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/login' }]) + + const icons = await readBrowserFavicons(path, new Set(['https://example.com'])) + + expect(icons.get('https://example.com')).toMatch(/^data:image\/png;base64,/) + }) + + it('reads only the origins being imported', async () => { + // A password import must not drag the browser's whole history along. + const path = await writeFaviconDatabase([ + { pageUrl: 'https://wanted.test/' }, + { pageUrl: 'https://unrelated.test/' }, + ]) + + const icons = await readBrowserFavicons(path, new Set(['https://wanted.test'])) + + expect([...icons.keys()]).toEqual(['https://wanted.test']) + }) + + it('does nothing when no origins are requested', async () => { + const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/' }]) + + await expect(readBrowserFavicons(path, new Set())).resolves.toEqual(new Map()) + }) + + it('prefers the bitmap closest to the size actually displayed', async () => { + const large = Buffer.concat([PNG, Buffer.alloc(16)]) + const path = await writeFaviconDatabase([ + { pageUrl: 'https://example.com/', width: 16 }, + { pageUrl: 'https://example.com/other', width: 32, data: large }, + ]) + + const icon = await readBrowserFavicons(path, new Set(['https://example.com'])) + + expect(icon.get('https://example.com')).toBe( + `data:image/png;base64,${large.toString('base64')}` + ) + }) + + it('ignores rows that are not usable icons', async () => { + const path = await writeFaviconDatabase([ + { pageUrl: 'https://notpng.test/', data: Buffer.from('') }, + { pageUrl: 'https://huge.test/', data: Buffer.concat([PNG, Buffer.alloc(17 * 1024)]) }, + { pageUrl: 'https://oversized.test/', width: 512 }, + ]) + + const icons = await readBrowserFavicons( + path, + new Set(['https://notpng.test', 'https://huge.test', 'https://oversized.test']) + ) + + expect(icons.size).toBe(0) + }) + + it('treats an unreadable favicon store as simply having no icons', async () => { + // Icons are decoration; failing to read them must not fail an import. + await expect( + readBrowserFavicons(join(directory, 'absent'), new Set(['https://example.com'])) + ).resolves.toEqual(new Map()) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.ts b/apps/desktop/src/main/browser-import/chromium-favicons.ts new file mode 100644 index 00000000000..304991909cc --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-favicons.ts @@ -0,0 +1,87 @@ +import { normalizeOrigin } from '@/main/browser-credentials/origin' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Reads site icons out of a Chromium profile's `Favicons` database. + * + * Deliberately local. The obvious way to get a favicon is to ask a public + * service for it, which would hand that service the domain of every site the + * user has a password for — the exact list this feature exists to protect. + * The browser being imported from already has these icons on disk, so they + * come from there and never touch the network. + */ + +/** Chromium stores several sizes per icon; this is the one worth keeping. */ +const PREFERRED_SIZE = 32 +const MAX_SIZE = 128 +/** A favicon larger than this is not worth carrying inside the vault. */ +const MAX_BYTES = 16 * 1024 +const MAX_ROWS = 20_000 + +const FAVICON_QUERY = ` + SELECT icon_mapping.page_url AS page_url, + favicon_bitmaps.image_data AS image_data, + favicon_bitmaps.width AS width + FROM icon_mapping + JOIN favicon_bitmaps ON favicon_bitmaps.icon_id = icon_mapping.icon_id + WHERE favicon_bitmaps.image_data IS NOT NULL + LIMIT ${MAX_ROWS} +` + +/** PNG magic number — the only format written back out as a data URL. */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function isPng(data: Uint8Array): boolean { + return data.length > PNG_SIGNATURE.length && PNG_SIGNATURE.equals(data.subarray(0, 8)) +} + +/** How far a bitmap's width is from the size worth keeping. */ +function sizeDistance(width: number): number { + return Math.abs((width || PREFERRED_SIZE) - PREFERRED_SIZE) +} + +/** + * Site icons for `origins`, keyed by origin, as `data:` URLs. + * + * Only the requested origins are returned, so a profile's entire browsing + * history does not come along with a password import. Never throws: an + * unreadable or unfamiliar favicon store just means no icons. + */ +export async function readBrowserFavicons( + faviconsPath: string, + origins: ReadonlySet +): Promise> { + if (origins.size === 0) return new Map() + + let rows: Record[] + try { + rows = await queryBrowserDatabase(faviconsPath, 'Favicons', FAVICON_QUERY) + } catch { + // Icons are decoration. Failing to read them must not fail the import. + return new Map() + } + + const best = new Map() + for (const row of rows) { + const origin = normalizeOrigin(toText(row.page_url)) + if (origin === null || !origins.has(origin)) continue + + const data = row.image_data + if (!(data instanceof Uint8Array) || data.length > MAX_BYTES || !isPng(data)) continue + + const width = toNumber(row.width) + if (width > MAX_SIZE) continue + + const distance = sizeDistance(width) + const current = best.get(origin) + if (!current || distance < current.distance) best.set(origin, { distance, data }) + } + + return new Map( + [...best].map(([origin, { data }]) => [ + origin, + `data:image/png;base64,${Buffer.from(data).toString('base64')}`, + ]) + ) +} diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.test.ts b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts new file mode 100644 index 00000000000..00b992b3933 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts @@ -0,0 +1,152 @@ +import { createCipheriv } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' +import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords' + +/** + * Runs against a synthetic `Login Data` database. Tests never read a + * developer's real Chrome profile or Keychain. + */ +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +const KEY = deriveEncryptionKey('test-safe-storage-password') + +function encryptV10(plaintext: string): Uint8Array { + const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20)) + return Buffer.concat([Buffer.from('v10'), cipher.update(Buffer.from(plaintext)), cipher.final()]) +} + +interface FixtureLogin { + signonRealm: string + username: string + passwordValue?: Uint8Array + blacklisted?: number + originUrl?: string +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-chrome-logins-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeLoginDatabase(logins: FixtureLogin[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Login Data') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE logins ( + origin_url TEXT NOT NULL, signon_realm TEXT NOT NULL, + username_value TEXT NOT NULL, password_value BLOB, + blacklisted_by_user INTEGER NOT NULL + ) + `) + const insert = database.prepare( + 'INSERT INTO logins (origin_url, signon_realm, username_value, password_value, blacklisted_by_user) VALUES (?, ?, ?, ?, ?)' + ) + for (const login of logins) { + insert.run( + login.originUrl ?? `${login.signonRealm}login`, + login.signonRealm, + login.username, + login.passwordValue ?? null, + login.blacklisted ?? 0 + ) + } + database.close() + return path +} + +describe.skipIf(!sqliteAvailable)('readBrowserPasswords', () => { + it('decrypts saved logins', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: 'https://example.com/', + username: 'ada', + passwordValue: encryptV10('hunter2'), + }, + ]) + + const result = await readBrowserPasswords(path, KEY) + + expect(result.rowsSeen).toBe(1) + expect(result.credentials).toEqual([ + { origin: 'https://example.com/', username: 'ada', password: 'hunter2' }, + ]) + }) + + it('does not strip a prefix from password plaintext', async () => { + // Unlike cookies, saved passwords carry no domain-bound prefix. Removing + // 32 bytes here would silently corrupt every password. + const password = 'x'.repeat(48) + const path = await writeLoginDatabase([ + { + signonRealm: 'https://example.com/', + username: 'ada', + passwordValue: encryptV10(password), + }, + ]) + + expect((await readBrowserPasswords(path, KEY)).credentials[0].password).toBe(password) + }) + + it('skips never-saved sites, empty rows, and undecryptable values', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: 'https://good.test/', + username: 'ada', + passwordValue: encryptV10('keep'), + }, + { signonRealm: 'https://blocked.test/', username: '', blacklisted: 1 }, + { signonRealm: 'https://empty.test/', username: 'a' }, + { + signonRealm: 'https://broken.test/', + username: 'b', + passwordValue: Buffer.from('v20-app-bound'), + }, + ]) + + const result = await readBrowserPasswords(path, KEY) + + expect(result.rowsSeen).toBe(4) + expect(result.credentials.map(({ origin }) => origin)).toEqual(['https://good.test/']) + expect(result.skipped).toBe(3) + }) + + it('falls back to the origin url when there is no signon realm', async () => { + const path = await writeLoginDatabase([ + { + signonRealm: '', + originUrl: 'https://fallback.test/login', + username: 'ada', + passwordValue: encryptV10('p'), + }, + ]) + + expect((await readBrowserPasswords(path, KEY)).credentials[0].origin).toBe( + 'https://fallback.test/login' + ) + }) + + it('reports an unrecognised schema rather than guessing', async () => { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'Login Data') + const database = new DatabaseSync(path) + database.exec('CREATE TABLE not_logins (a TEXT)') + database.close() + + await expect(readBrowserPasswords(path, KEY)).rejects.toMatchObject({ + code: 'unsupported-schema', + }) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.ts b/apps/desktop/src/main/browser-import/chromium-passwords.ts new file mode 100644 index 00000000000..f2408641671 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-passwords.ts @@ -0,0 +1,70 @@ +import type { ImportCandidate } from '@/main/browser-credentials/vault' +import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto' +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Decrypts a Chrome profile's saved passwords. + * + * Same Keychain key and `v10` scheme as the cookie reader, with one + * difference: password plaintext carries no domain-bound prefix, so no + * `domain` is passed to the decryptor and nothing is stripped. + * + * The values produced here are the most sensitive material the importer + * handles. They are passed straight to the encrypted vault and are never + * logged, counted by site, or returned to a renderer. + */ + +const MAX_LOGIN_ROWS = 20_000 + +const LOGIN_QUERY = ` + SELECT signon_realm, origin_url, username_value, password_value, blacklisted_by_user + FROM logins + LIMIT ${MAX_LOGIN_ROWS} +` + +export interface ReadPasswordsResult { + credentials: ImportCandidate[] + skipped: number + /** Rows examined, so the caller can tell "no passwords" from "none decrypted". */ + rowsSeen: number +} + +export async function readBrowserPasswords( + loginDataPath: string, + key: Buffer +): Promise { + const rows = await queryBrowserDatabase(loginDataPath, 'Login Data', LOGIN_QUERY) + const credentials: ImportCandidate[] = [] + let skipped = 0 + + for (const raw of rows) { + // "Never saved for this site" rows exist to suppress Chrome's save prompt + // and carry no usable password. + if (toNumber(raw.blacklisted_by_user) !== 0) { + skipped += 1 + continue + } + + const encrypted = raw.password_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + skipped += 1 + continue + } + + const password = decryptChromiumValue(Buffer.from(encrypted), key) + if (password === null || password.length === 0) { + skipped += 1 + continue + } + + // `signon_realm` is Chrome's authority for where a credential belongs; + // `origin_url` is the page it was captured on. Federated and Android + // realms do not reduce to an http(s) origin and are dropped by the vault's + // own normalization rather than being coerced into one here. + const origin = toText(raw.signon_realm) || toText(raw.origin_url) + credentials.push({ origin, username: toText(raw.username_value), password }) + } + + return { credentials, skipped, rowsSeen: rows.length } +} diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.test.ts b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts new file mode 100644 index 00000000000..2d62feb7760 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + BROWSER_SOURCES, + type BrowserSource, + userDataDirFor, +} from '@/main/browser-import/browser-sources' +import { + listAllBrowserProfiles, + listBrowserProfiles, +} from '@/main/browser-import/chromium-profiles' + +const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource +const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'sim-browser-profiles-test-')) +}) + +afterEach(async () => { + await rm(home, { recursive: true, force: true }) +}) + +/** Writes a profile directory holding a database at `relativePath`. */ +async function addProfile( + source: BrowserSource, + dir: string, + relativePath = join('Network', 'Cookies') +): Promise { + const path = join(userDataDirFor(source, home), dir, relativePath) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, '') +} + +async function writeLocalState( + source: BrowserSource, + infoCache: Record +): Promise { + const userDataDir = userDataDirFor(source, home) + await mkdir(userDataDir, { recursive: true }) + await writeFile( + join(userDataDir, 'Local State'), + JSON.stringify({ profile: { info_cache: infoCache } }) + ) +} + +describe('listBrowserProfiles', () => { + it('returns display names, default profile first, with namespaced ids', async () => { + await addProfile(CHROME, 'Profile 2') + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name: 'Person 1' }, 'Profile 2': { name: 'Work' } }) + + const profiles = await listBrowserProfiles(CHROME, home) + + expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([ + // `Person 1` is Chromium's placeholder, so it reads as unnamed. + { id: 'chrome:Default', label: '' }, + { id: 'chrome:Profile 2', label: 'Work' }, + ]) + expect(profiles[0].cookiesPath).toBe( + join(userDataDirFor(CHROME, home), 'Default/Network/Cookies') + ) + expect(profiles[0].source.id).toBe('chrome') + }) + + it('orders numbered profiles numerically rather than as strings', async () => { + for (const dir of ['Profile 10', 'Profile 2', 'Default']) await addProfile(CHROME, dir) + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual([ + 'chrome:Default', + 'chrome:Profile 2', + 'chrome:Profile 10', + ]) + }) + + it('finds the cookie database at the pre-M96 location', async () => { + await addProfile(CHROME, 'Default', 'Cookies') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.cookiesPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Cookies')) + }) + + it('finds the saved-password database alongside the cookies', async () => { + await addProfile(CHROME, 'Default') + await addProfile(CHROME, 'Default', 'Login Data') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.loginDataPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Login Data')) + }) + + it('lists a profile that has only saved passwords', async () => { + // Passwords and cookies import independently, so a profile with one and + // not the other must still be offered. + await addProfile(CHROME, 'Default', 'Login Data') + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile).toMatchObject({ id: 'chrome:Default', cookiesPath: null }) + }) + + it('omits profiles with no readable database', async () => { + await mkdir(join(userDataDirFor(CHROME, home), 'Profile 3'), { recursive: true }) + await addProfile(CHROME, 'Default') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('ignores internal profiles', async () => { + await addProfile(CHROME, 'System Profile') + await addProfile(CHROME, 'Guest Profile') + await addProfile(CHROME, 'Default') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('refuses profile keys that would escape the user-data directory', async () => { + // Local State is data this process does not own, so a crafted key must not + // become a path. + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { + Default: { name: 'Person 1' }, + '../../../../etc': { name: 'Escaped' }, + '/absolute/elsewhere': { name: 'Absolute' }, + }) + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('falls back to directory names when Local State is unreadable', async () => { + await addProfile(CHROME, 'Default') + await writeFile(join(userDataDirFor(CHROME, home), 'Local State'), 'not json{{') + + const profiles = await listBrowserProfiles(CHROME, home) + expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([ + { id: 'chrome:Default', label: '' }, + ]) + }) + + it('skips a browser\u2019s internal profiles', async () => { + // Arc keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory, + // so only the name gives it away. + await addProfile(ARC, 'Default') + await addProfile(ARC, 'Profile 1') + await addProfile(ARC, 'Profile 2') + await writeLocalState(ARC, { + Default: { name: 'Your Chromium' }, + 'Profile 1': { name: '__ARC_SYSTEM_PROFILE' }, + 'Profile 2': { name: 'Microtrades' }, + }) + + const profiles = await listBrowserProfiles(ARC, home) + + expect(profiles.map(({ id }) => id)).toEqual(['arc:Default', 'arc:Profile 2']) + }) + + it.each([['Your Chrome'], ['Your Chromium'], ['Person 1'], ['Default'], ['Chrome']])( + 'treats the placeholder name %s as unnamed', + async (name) => { + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name } }) + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.label).toBe('') + } + ) + + it('keeps a name the user actually chose', async () => { + await addProfile(CHROME, 'Default') + await writeLocalState(CHROME, { Default: { name: 'sim.ai' } }) + + const [profile] = await listBrowserProfiles(CHROME, home) + expect(profile.label).toBe('sim.ai') + }) + + it('reports no profiles when the browser is not installed', async () => { + await expect(listBrowserProfiles(ARC, home)).resolves.toEqual([]) + }) +}) + +describe('listAllBrowserProfiles', () => { + it('collects profiles from every installed browser', async () => { + await addProfile(CHROME, 'Default') + await addProfile(ARC, 'Default') + await addProfile(ARC, 'Profile 1') + await writeLocalState(ARC, { Default: { name: 'Personal' }, 'Profile 1': { name: 'Work' } }) + + const profiles = await listAllBrowserProfiles([CHROME, ARC], home) + + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default', 'arc:Default', 'arc:Profile 1']) + expect(profiles.map(({ source }) => source.label)).toEqual(['Chrome', 'Arc', 'Arc']) + }) + + it('keeps every profile distinct even though each browser has a Default', async () => { + // The namespaced id is what stops one browser's Default from resolving to + // another's. + await addProfile(CHROME, 'Default') + await addProfile(ARC, 'Default') + + const profiles = await listAllBrowserProfiles([CHROME, ARC], home) + expect(new Set(profiles.map(({ id }) => id)).size).toBe(2) + }) + + it('does not let one broken browser hide the others', async () => { + await addProfile(CHROME, 'Default') + const broken: BrowserSource = { + ...ARC, + // A path that cannot be enumerated stands in for a damaged install. + userDataSegments: ['\u0000invalid'], + } + + const profiles = await listAllBrowserProfiles([broken, CHROME], home) + expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default']) + }) + + it('reports nothing when no supported browser is installed', async () => { + await expect(listAllBrowserProfiles([CHROME, ARC], home)).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.ts b/apps/desktop/src/main/browser-import/chromium-profiles.ts new file mode 100644 index 00000000000..c955b1dd7d9 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-profiles.ts @@ -0,0 +1,178 @@ +import { constants } from 'node:fs' +import { access, readdir, readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + BROWSER_SOURCES, + type BrowserSource, + formatProfileId, + userDataDirFor, +} from '@/main/browser-import/browser-sources' +import type { BrowserProfile } from '@/main/browser-import/types' + +/** + * Discovers profiles across the Chromium-family browsers on this device. + * + * Strictly read-only: a browser's own directory is never written to, and a + * profile is only offered when its database is actually readable, so the UI + * cannot advertise an import that is guaranteed to fail. + */ + +/** Chromium moved the cookie database under `Network/` in M96. */ +const COOKIE_DB_RELATIVE_PATHS = [join('Network', 'Cookies'), 'Cookies'] +/** Saved passwords stayed at the profile root across that move. */ +const LOGIN_DB_RELATIVE_PATHS = ['Login Data'] +/** Site icons, used to give saved passwords a recognisable face. */ +const FAVICON_DB_RELATIVE_PATHS = ['Favicons'] +/** Page titles, read only to learn what the imported sites are called. */ +const HISTORY_DB_RELATIVE_PATHS = ['History'] +/** Internal profiles that never hold a user's browsing session. */ +const IGNORED_PROFILE_DIRS = new Set(['System Profile', 'Guest Profile']) +const PROFILE_DIR_PATTERN = /^(Default|Profile \d{1,4})$/ +const MAX_PROFILES_PER_BROWSER = 32 + +/** + * Names a browser uses for its own machinery rather than for a person. Arc + * keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory, so the + * directory name alone does not reveal it — offering it as something to import + * would be meaningless at best. + */ +function isInternalProfileName(name: string): boolean { + return name.startsWith('__') +} + +/** + * Whether a profile's name is just Chromium's placeholder rather than + * something the user chose. `Your Chrome`, `Your Chromium`, and `Person 1` all + * mean "unnamed" — showing them as a profile's identity is noise, and shows + * the same string twice when two browsers are both unnamed. + */ +function isGenericProfileName(name: string, directory: string, browserLabel: string): boolean { + return ( + name === directory || + name === browserLabel || + /^Person \d+$/.test(name) || + /^Your [\w ]+$/.test(name) + ) +} + +async function isReadableFile(path: string): Promise { + try { + await access(path, constants.R_OK) + return true + } catch { + return false + } +} + +async function resolveFirstReadable( + profileDir: string, + relativePaths: readonly string[] +): Promise { + for (const relative of relativePaths) { + const candidate = join(profileDir, relative) + if (await isReadableFile(candidate)) return candidate + } + return null +} + +/** + * A browser's display names, keyed by profile directory. + * + * `Local State` is JSON this process does not own, so every key is checked + * against {@link PROFILE_DIR_PATTERN} before it is used in a path — that check + * is what keeps a crafted key from escaping the user-data directory. + */ +async function readProfileDisplayNames(userDataDir: string): Promise> { + const names = new Map() + try { + const raw = await readFile(join(userDataDir, 'Local State'), 'utf8') + const infoCache = (JSON.parse(raw) as { profile?: { info_cache?: unknown } }).profile + ?.info_cache + if (infoCache && typeof infoCache === 'object' && !Array.isArray(infoCache)) { + for (const [dir, info] of Object.entries(infoCache as Record)) { + if (!PROFILE_DIR_PATTERN.test(dir)) continue + const name = (info as { name?: unknown })?.name + names.set(dir, typeof name === 'string' && name.trim().length > 0 ? name.trim() : dir) + } + } + } catch { + // No or unreadable Local State: fall back to directory scanning below. + } + return names +} + +async function listProfileDirNames(userDataDir: string): Promise { + try { + const entries = await readdir(userDataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory() && PROFILE_DIR_PATTERN.test(entry.name)) + .map((entry) => entry.name) + } catch { + return [] + } +} + +/** `Default` first, then `Profile 2`, `Profile 10`, … in numeric order. */ +function compareProfileDirs(left: string, right: string): number { + if (left === right) return 0 + if (left === 'Default') return -1 + if (right === 'Default') return 1 + const index = (dir: string): number => Number(dir.slice('Profile '.length)) + return index(left) - index(right) +} + +/** + * Profiles belonging to one browser. Resolves to an empty list when that + * browser is not installed — absence is a normal state, not an error. + */ +export async function listBrowserProfiles( + source: BrowserSource, + home: string = homedir() +): Promise { + const userDataDir = userDataDirFor(source, home) + const displayNames = await readProfileDisplayNames(userDataDir) + const dirNames = new Set([...displayNames.keys(), ...(await listProfileDirNames(userDataDir))]) + + const profiles: BrowserProfile[] = [] + for (const dir of [...dirNames].sort(compareProfileDirs).slice(0, MAX_PROFILES_PER_BROWSER)) { + if (IGNORED_PROFILE_DIRS.has(dir) || !PROFILE_DIR_PATTERN.test(dir)) continue + const name = displayNames.get(dir) ?? dir + if (isInternalProfileName(name)) continue + const profileDir = join(userDataDir, dir) + const cookiesPath = await resolveFirstReadable(profileDir, COOKIE_DB_RELATIVE_PATHS) + const loginDataPath = await resolveFirstReadable(profileDir, LOGIN_DB_RELATIVE_PATHS) + const faviconsPath = await resolveFirstReadable(profileDir, FAVICON_DB_RELATIVE_PATHS) + const historyPath = await resolveFirstReadable(profileDir, HISTORY_DB_RELATIVE_PATHS) + if (cookiesPath === null && loginDataPath === null) continue + profiles.push({ + id: formatProfileId(source.id, dir), + directory: dir, + // Empty means "the user never named this one", which lets the caller + // fall back to the browser's name instead of printing a placeholder. + label: isGenericProfileName(name, dir, source.label) ? '' : name, + source, + cookiesPath, + loginDataPath, + faviconsPath, + historyPath, + }) + } + return profiles +} + +/** + * Every importable profile on this device, across every supported browser. + * + * One browser failing to enumerate must not hide the others — a broken Brave + * install should not cost the user their Chrome and Arc profiles. + */ +export async function listAllBrowserProfiles( + sources: readonly BrowserSource[] = BROWSER_SOURCES, + home: string = homedir() +): Promise { + const perBrowser = await Promise.all( + sources.map((source) => listBrowserProfiles(source, home).catch((): BrowserProfile[] => [])) + ) + return perBrowser.flat() +} diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.test.ts b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts new file mode 100644 index 00000000000..e31bfdd56b1 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts @@ -0,0 +1,314 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + isCoveredByDomain, + MAX_IMPORTED_SITES, + readBrowserSites, +} from '@/main/browser-import/chromium-site-names' + +const sqliteAvailable = await import('node:sqlite').then( + () => true, + () => false +) + +interface FixturePage { + url: string + /** Omitted for the rows Chromium stores with no title at all. */ + title?: string + visitCount?: number + /** Chromium's own flag for redirect hops nobody chose to open. */ + hidden?: boolean +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-site-names-test-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function writeHistoryDatabase(pages: FixturePage[]): Promise { + const { DatabaseSync } = await import('node:sqlite') + const path = join(directory, 'History') + const database = new DatabaseSync(path) + database.exec(` + CREATE TABLE urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url LONGVARCHAR, + title LONGVARCHAR, + visit_count INTEGER DEFAULT 0 NOT NULL, + typed_count INTEGER DEFAULT 0 NOT NULL, + last_visit_time INTEGER NOT NULL, + hidden INTEGER DEFAULT 0 NOT NULL + ); + `) + const insert = database.prepare( + 'INSERT INTO urls (url, title, visit_count, last_visit_time, hidden) VALUES (?, ?, ?, 0, ?)' + ) + for (const page of pages) { + insert.run(page.url, page.title ?? null, page.visitCount ?? 1, page.hidden ? 1 : 0) + } + database.close() + return path +} + +function hostnames(sites: { hostname: string }[]): string[] { + return sites.map((site) => site.hostname) +} + +describe.skipIf(!sqliteAvailable)('readBrowserSites', () => { + it('learns a site’s name from the part of its titles that never changes', async () => { + const path = await writeHistoryDatabase([ + { + url: 'https://mail.google.com/mail/u/0/#inbox', + title: 'Inbox (12) - ada@example.com - Gmail', + }, + { url: 'https://mail.google.com/mail/u/0/#sent', title: 'Sent - ada@example.com - Gmail' }, + { + url: 'https://mail.google.com/mail/u/0/#drafts', + title: 'Drafts (2) - ada@example.com - Gmail', + }, + ]) + + const sites = await readBrowserSites(path, new Set(['mail.google.com'])) + + // Nothing here hardcodes Gmail — it is the only segment on every page. + expect(sites[0]?.name).toBe('Gmail') + }) + + it('finds a name that leads the title rather than trailing it', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://github.com/', title: 'GitHub - Where the world builds software' }, + { url: 'https://github.com/pulls', title: 'GitHub - Pull requests' }, + ]) + + const sites = await readBrowserSites(path, new Set(['github.com'])) + + expect(sites[0]?.name).toBe('GitHub') + }) + + it('handles a title with no separator at all', async () => { + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites[0]?.name).toBe('Example') + }) + + it('prefers the shorter candidate when pages are split evenly', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://linear.app/a', title: 'Linear - Issue tracking' }, + { url: 'https://linear.app/b', title: 'Issue tracking - Linear' }, + ]) + + const sites = await readBrowserSites(path, new Set(['linear.app'])) + + expect(sites[0]?.name).toBe('Linear') + }) + + it('rejects a whole-title tagline as a name while still offering the site', async () => { + const long = 'A very long marketing sentence that is plainly not what this site is called' + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: long }]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['example.com']) + expect(sites[0]?.name).toBeUndefined() + }) + + it('offers a host that is visited but has never carried a title', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://untitled.example.com/feed', visitCount: 7 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites).toEqual([{ hostname: 'untitled.example.com', name: undefined, visits: 7 }]) + }) + + it('ignores pages that are not on the web', async () => { + const path = await writeHistoryDatabase([ + { url: 'chrome-extension://abc/page.html', title: 'Extension' }, + { url: 'file:///Users/ada/notes.html', title: 'Notes' }, + ]) + + const sites = await readBrowserSites(path, new Set(['abc', ''])) + + expect(sites).toEqual([]) + }) + + it('imports the same list every time for the same profile', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://example.com/a', title: 'Alpha - Example' }, + { url: 'https://example.com/b', title: 'Beta - Sample' }, + ]) + + const first = await readBrowserSites(path, new Set(['example.com'])) + const second = await readBrowserSites(path, new Set(['example.com'])) + + expect(first).toEqual(second) + }) + + /** + * The regression this pins: the shared reader enables BigInt reads, so every + * SQLite integer arrives as `bigint` and a `typeof value === 'number'` guard + * scored every imported site zero, flattening the omnibox ordering. + */ + it('carries the source browser’s visit count across as a real number', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://example.com/', title: 'Example', visitCount: 4242 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites[0]?.visits).toBe(4242) + expect(typeof sites[0]?.visits).toBe('number') + }) + + it('ranks a site used across many pages above one reached by refreshing a single page', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://deep.example.com/dashboard', title: 'Deep', visitCount: 100 }, + { url: 'https://broad.example.com/a', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/b', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/c', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/d', title: 'Broad', visitCount: 30 }, + { url: 'https://broad.example.com/e', title: 'Broad', visitCount: 30 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['broad.example.com', 'deep.example.com']) + expect(sites[0]?.visits).toBe(150) + }) + + it('orders the most-used first and settles ties alphabetically', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://zeta.example.com/', title: 'Zeta', visitCount: 10 }, + { url: 'https://alpha.example.com/', title: 'Alpha', visitCount: 10 }, + { url: 'https://middle.example.com/', title: 'Middle', visitCount: 50 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual([ + 'middle.example.com', + 'alpha.example.com', + 'zeta.example.com', + ]) + }) + + it('leaves out the redirect hops Chromium hides from its own omnibox', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://redirect.example.com/', title: 'Redirect', visitCount: 900, hidden: true }, + { url: 'https://real.example.com/', title: 'Real', visitCount: 3 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['real.example.com']) + }) + + it('admits a subdomain the imported apex domain covers', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://www.google.com/search?q=sim', title: 'sim - Google Search' }, + ]) + + // The cookie jar contributes `.google.com` with its leading dot stripped; + // the page the user actually opened is the subdomain. + const sites = await readBrowserSites(path, new Set(['google.com'])) + + expect(hostnames(sites)).toEqual(['www.google.com']) + }) + + it('imports only hosts the imported domains cover, never the rest of the history', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://mail.google.com/', title: 'Gmail' }, + { url: 'https://somewhere-private.example/', title: 'Private' }, + ]) + + const sites = await readBrowserSites(path, new Set(['google.com'])) + + expect(hostnames(sites)).toEqual(['mail.google.com']) + }) + + it('drops the profile’s most-visited host when nothing imported covers it', async () => { + const path = await writeHistoryDatabase([ + { url: 'https://uncovered.example.org/', title: 'Uncovered', visitCount: 5000 }, + { url: 'https://docs.example.com/', title: 'Docs', visitCount: 2 }, + ]) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(hostnames(sites)).toEqual(['docs.example.com']) + }) + + it('contributes no more hosts than one import may, keeping the most-used', async () => { + const overflow = MAX_IMPORTED_SITES + 50 + const pages = Array.from({ length: overflow }, (_, index) => ({ + url: `https://site-${String(index).padStart(3, '0')}.example.com/`, + title: `Site ${index}`, + visitCount: overflow - index, + })) + const path = await writeHistoryDatabase(pages) + + const sites = await readBrowserSites(path, new Set(['example.com'])) + + expect(sites).toHaveLength(MAX_IMPORTED_SITES) + expect(sites[0]?.hostname).toBe('site-000.example.com') + expect(sites.at(-1)?.hostname).toBe( + `site-${String(MAX_IMPORTED_SITES - 1).padStart(3, '0')}.example.com` + ) + }) + + it('survives an unreadable history rather than failing the import', async () => { + const sites = await readBrowserSites(join(directory, 'absent'), new Set(['example.com'])) + + expect(sites).toEqual([]) + }) + + it('asks for nothing when no domain was imported to cover it', async () => { + const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }]) + + expect(await readBrowserSites(path, new Set())).toEqual([]) + }) +}) + +describe('isCoveredByDomain', () => { + it('covers a host that is itself an imported domain', () => { + expect(isCoveredByDomain('example.com', new Set(['example.com']))).toBe(true) + }) + + it('covers a subdomain of an imported domain, however deep', () => { + const domains = new Set(['example.com']) + + expect(isCoveredByDomain('mail.example.com', domains)).toBe(true) + expect(isCoveredByDomain('a.b.c.example.com', domains)).toBe(true) + }) + + it('does not cover an apex whose subdomain is all that was imported', () => { + expect(isCoveredByDomain('example.com', new Set(['mail.example.com']))).toBe(false) + }) + + it('matches on label boundaries, not bare string suffixes', () => { + expect(isCoveredByDomain('notexample.com', new Set(['example.com']))).toBe(false) + }) + + it('covers nothing when no domain was imported', () => { + expect(isCoveredByDomain('example.com', new Set())).toBe(false) + }) + + /** + * The documented boundary: coverage is a pure label walk with no public + * suffix list, so a cookie on a registry suffix such as `github.io` admits + * every user site under it. Should a PSL guard ever be added, this test is + * meant to fail loudly rather than let the change land unnoticed. + */ + it('lets a public-suffix domain cover the sites beneath it', () => { + expect(isCoveredByDomain('alice.github.io', new Set(['github.io']))).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.ts b/apps/desktop/src/main/browser-import/chromium-site-names.ts new file mode 100644 index 00000000000..2485e6e9077 --- /dev/null +++ b/apps/desktop/src/main/browser-import/chromium-site-names.ts @@ -0,0 +1,205 @@ +import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source' +import { toNumber, toText } from '@/main/browser-import/types' + +/** + * Reads which sites a Chromium profile's owner actually uses, out of its + * `History` database. + * + * This is the seed for the omnibox, and history is the only honest source for + * it. The obvious alternative — the cookie jar — is not a list of sites anyone + * visits: it is every origin that ever set state, which is dominated by ad + * networks, analytics, and embedded widgets the user never navigated to. Rows + * in `urls` are pages someone opened, so trackers are structurally absent. + * + * Cookies still get a say, as a filter: only hosts covered by a cookie the + * import brought over come back, so what is remembered stays within the data + * the user chose to bring. Sim's browser records no history of its own, and + * none is reconstructed here — no visit times, no URLs beyond the host, no + * sequence. What survives is a host, what its own titles call it, and how much + * it is used relative to the others. + */ + +/** + * Rows scanned from one profile. Set well past the size of a real history — + * Chrome expires visits at 90 days — so the cut is a guard against a pathological + * file rather than something a normal profile meets. + * + * Ordering by `visit_count` before the cut matters: per-host counts are summed, + * so truncating has to drop the rows that contribute least to any sum. A profile + * large enough to hit this still ranks on its most-visited pages. + */ +const MAX_ROWS = 200_000 +/** Longer than this is a page title, not what the site is called. */ +const MAX_NAME_LENGTH = 40 +/** Hosts one import may contribute, most-used first. Bounds the favicon lookup too. */ +export const MAX_IMPORTED_SITES = 200 + +/** + * `hidden` marks rows Chromium itself keeps out of autocomplete — redirect + * hops and other URLs nobody chose to open. Excluding them is the same + * predicate Chrome's own omnibox uses. + */ +const SITE_QUERY = ` + SELECT url, title, visit_count + FROM urls + WHERE hidden = 0 + ORDER BY visit_count DESC + LIMIT ${MAX_ROWS} +` + +/** Separators sites put between the page and their own name. */ +const TITLE_SEPARATOR = /\s+[|·•‧–—]\s+|\s+-\s+|\s+:\s+/ + +/** A host the source browser's owner visited, and what it is called there. */ +export interface ImportedSite { + hostname: string + name?: string + /** + * The source browser's own visit count. An aggregate popularity signal used + * to order suggestions — never a timestamp, a URL, or a sequence of visits. + */ + visits: number +} + +function hostnameOf(url: string): string | null { + try { + const { hostname, protocol } = new URL(url) + // Extension and file pages are not sites the omnibox can offer. + if (protocol !== 'https:' && protocol !== 'http:') return null + return hostname || null + } catch { + return null + } +} + +/** + * `visit_count` for one row. Goes through {@link toNumber} because the shared + * reader enables BigInt reads, so every SQLite integer arrives as `bigint` — a + * bare `typeof value === 'number'` guard silently scores every site zero. + */ +function visitsOf(value: unknown): number { + const count = toNumber(value) + return count > 0 ? Math.floor(count) : 0 +} + +/** + * Whether a cookie covers this host. + * + * Cookie hosts arrive with the domain-cookie dot already stripped, so + * `.google.com` reaches here as `google.com` while the page the user opened is + * `www.google.com` or `mail.google.com`. Matching on equality alone would miss + * every site not served from its apex — which is most of them — so each of the + * host's parent suffixes is tried. Walking labels keeps this O(labels) per row + * rather than scanning the whole cookie set. + */ +export function isCoveredByDomain(hostname: string, domains: ReadonlySet): boolean { + if (domains.has(hostname)) return true + let dot = hostname.indexOf('.') + while (dot !== -1) { + if (domains.has(hostname.slice(dot + 1))) return true + dot = hostname.indexOf('.', dot + 1) + } + return false +} + +/** + * The parts of a page title that could be the site's name. + * + * A title is typically the page, then the site: "Inbox (12) - Gmail". Which + * end holds the name is not consistent enough to pick by position — "GitHub - + * Where software is built" puts it first — so every segment is a candidate and + * frequency decides between them. + */ +function nameCandidates(title: string): string[] { + return title + .split(TITLE_SEPARATOR) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment.length <= MAX_NAME_LENGTH) +} + +/** + * The site's name is the part of its titles that does not change: every Gmail + * page ends in "Gmail" while the rest of each title differs, so the segment + * appearing across the most distinct pages of a host is its name. Ties go to + * the shorter candidate, which prefers "GitHub" over a tagline, and then to + * alphabetical order so the same profile always imports the same name. + */ +function bestName(candidates: Map>): string | undefined { + let best: { name: string; pages: number } | null = null + for (const [candidate, seenIn] of candidates) { + const pages = seenIn.size + if ( + !best || + pages > best.pages || + (pages === best.pages && + (candidate.length < best.name.length || + (candidate.length === best.name.length && candidate < best.name))) + ) { + best = { name: candidate, pages } + } + } + return best?.name +} + +/** + * The most-used hosts in this profile's history that `domains` covers, keyed by + * the host actually visited — the same string the omnibox and the favicon store + * use, so lookups downstream cannot miss. + * + * Never throws. A profile with no readable history contributes no sites, and + * the caller carries on with whatever the rest of the import found. + */ +export async function readBrowserSites( + historyPath: string, + domains: ReadonlySet +): Promise { + if (domains.size === 0) return [] + + let rows: Record[] + try { + rows = await queryBrowserDatabase(historyPath, 'History', SITE_QUERY) + } catch { + // History is a nicety layered on the cookies that were already imported. + // Chrome holding a lock on it must not fail the import. + return [] + } + + /** host -> candidate name -> the distinct page titles that produced it. */ + const tally = new Map>>() + const visits = new Map() + + for (const row of rows) { + const hostname = hostnameOf(toText(row.url)) + if (hostname === null || !isCoveredByDomain(hostname, domains)) continue + + // Summed across the host's pages, not maxed: how much a site is used is + // every page opened there, so a site visited broadly must not lose to one + // visited only through a single much-refreshed page. + visits.set(hostname, (visits.get(hostname) ?? 0) + visitsOf(row.visit_count)) + + const title = toText(row.title) + if (title === '') continue + const candidates = tally.get(hostname) ?? new Map>() + for (const candidate of nameCandidates(title)) { + const seenIn = candidates.get(candidate) ?? new Set() + seenIn.add(title) + candidates.set(candidate, seenIn) + } + tally.set(hostname, candidates) + } + + const sites: ImportedSite[] = [] + for (const [hostname, count] of visits) { + const candidates = tally.get(hostname) + sites.push({ + hostname, + name: candidates ? bestName(candidates) : undefined, + visits: count, + }) + } + + // Most-used first, then alphabetical so the same profile always imports the + // same list, and only as many as the directory is willing to carry. + sites.sort((a, b) => b.visits - a.visits || a.hostname.localeCompare(b.hostname)) + return sites.slice(0, MAX_IMPORTED_SITES) +} diff --git a/apps/desktop/src/main/browser-import/cookie-translate.test.ts b/apps/desktop/src/main/browser-import/cookie-translate.test.ts new file mode 100644 index 00000000000..7b6ee7b08c6 --- /dev/null +++ b/apps/desktop/src/main/browser-import/cookie-translate.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { chromeTimeToUnixSeconds, translateCookieRow } from '@/main/browser-import/cookie-translate' +import type { ChromiumCookieRow } from '@/main/browser-import/types' + +const NOW_SECONDS = 1_800_000_000 + +/** Chrome's microsecond-since-1601 encoding of a Unix timestamp. */ +function chromeTime(unixSeconds: number): number { + return (unixSeconds + 11_644_473_600) * 1_000_000 +} + +function row(overrides: Partial = {}): ChromiumCookieRow { + return { + hostKey: 'www.example.com', + name: 'session', + path: '/', + expiresUtc: chromeTime(NOW_SECONDS + 3600), + isSecure: true, + isHttpOnly: true, + hasExpires: true, + isPersistent: true, + sameSite: 1, + ...overrides, + } +} + +describe('chromeTimeToUnixSeconds', () => { + it('converts from the 1601 epoch', () => { + expect(chromeTimeToUnixSeconds(chromeTime(1_700_000_000))).toBe(1_700_000_000) + }) +}) + +describe('translateCookieRow', () => { + it('preserves the security attributes of a host-only cookie', () => { + const outcome = translateCookieRow(row(), 'abc', NOW_SECONDS) + + expect(outcome).toEqual({ + ok: true, + cookie: { + url: 'https://www.example.com/', + name: 'session', + value: 'abc', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: NOW_SECONDS + 3600, + }, + }) + }) + + it('omits domain for host-only cookies so they are not widened to subdomains', () => { + const outcome = translateCookieRow(row({ hostKey: 'www.example.com' }), 'abc', NOW_SECONDS) + expect(outcome.ok && 'domain' in outcome.cookie).toBe(false) + }) + + it('carries the leading dot through for domain cookies', () => { + const outcome = translateCookieRow(row({ hostKey: '.example.com' }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.domain).toBe('.example.com') + expect(outcome.ok && outcome.cookie.url).toBe('https://example.com/') + }) + + it('uses an http target for cookies that are not Secure', () => { + const outcome = translateCookieRow(row({ isSecure: false }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.url).toBe('http://www.example.com/') + expect(outcome.ok && outcome.cookie.secure).toBe(false) + }) + + it.each([ + [-1, 'unspecified'], + [0, 'no_restriction'], + [1, 'lax'], + [2, 'strict'], + [99, 'unspecified'], + ])('maps Chrome samesite %i to %s', (sameSite, expected) => { + const outcome = translateCookieRow(row({ sameSite }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.sameSite).toBe(expected) + }) + + it('drops expired cookies', () => { + const outcome = translateCookieRow( + row({ expiresUtc: chromeTime(NOW_SECONDS - 1) }), + 'abc', + NOW_SECONDS + ) + expect(outcome).toEqual({ ok: false, reason: 'expired' }) + }) + + it('keeps session cookies without an expiry', () => { + const outcome = translateCookieRow( + row({ hasExpires: false, isPersistent: false }), + 'abc', + NOW_SECONDS + ) + expect(outcome.ok && 'expirationDate' in outcome.cookie).toBe(false) + }) + + it('normalizes a path that is missing its leading slash', () => { + const outcome = translateCookieRow(row({ path: 'account' }), 'abc', NOW_SECONDS) + expect(outcome.ok && outcome.cookie.path).toBe('/account') + expect(outcome.ok && outcome.cookie.url).toBe('https://www.example.com/account') + }) + + it.each([ + ['evil.com/path@good.com'], + ['good.com:8443'], + ['user:pass@good.com'], + [''], + ['.'], + ['has space.com'], + ])('refuses a host_key that cannot be trusted to name one host: %s', (hostKey) => { + expect(translateCookieRow(row({ hostKey }), 'abc', NOW_SECONDS)).toEqual({ + ok: false, + reason: 'invalid-target', + }) + }) + + it('drops a row with neither a name nor a value', () => { + expect(translateCookieRow(row({ name: '' }), '', NOW_SECONDS)).toEqual({ + ok: false, + reason: 'empty', + }) + }) +}) diff --git a/apps/desktop/src/main/browser-import/cookie-translate.ts b/apps/desktop/src/main/browser-import/cookie-translate.ts new file mode 100644 index 00000000000..91dbdf47cff --- /dev/null +++ b/apps/desktop/src/main/browser-import/cookie-translate.ts @@ -0,0 +1,109 @@ +import type { + ChromiumCookieRow, + CookieSkipReason, + ImportableCookie, +} from '@/main/browser-import/types' + +/** + * Translates Chrome cookie rows into the shape Electron's cookie API accepts. + * + * Two rules govern everything here. Security attributes are preserved exactly + * — `Secure`, `HttpOnly`, `SameSite`, host-only versus domain scope, and path + * are carried across unchanged, and a cookie is dropped rather than imported + * under weaker terms. And the destination is derived, never trusted: a corrupt + * or hostile `host_key` must not be able to steer a cookie at a host other + * than the one it came from. + */ + +/** Chrome stores timestamps as microseconds since 1601-01-01 UTC. */ +const WINDOWS_EPOCH_OFFSET_SECONDS = 11_644_473_600 +const MICROSECONDS_PER_SECOND = 1_000_000 + +export function chromeTimeToUnixSeconds(chromeTime: number): number { + return chromeTime / MICROSECONDS_PER_SECOND - WINDOWS_EPOCH_OFFSET_SECONDS +} + +/** Chrome's `samesite` column: -1 unspecified, 0 None, 1 Lax, 2 Strict. */ +function toSameSite(value: number): ImportableCookie['sameSite'] { + switch (value) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +/** + * Builds the URL the cookie is written against, or null when the row cannot + * be trusted to describe one host. + * + * `host_key` comes from a database this process does not own, so the parsed + * URL is checked back against it: anything that reparses to a different host, + * or that smuggles in credentials, a port, or extra path structure, is + * refused instead of being normalised into something plausible. + */ +export function buildCookieUrl(hostKey: string, path: string, secure: boolean): string | null { + const bareHost = hostKey.replace(/^\./, '').toLowerCase() + if (bareHost.length === 0 || bareHost.length > 253) return null + const normalizedPath = path.startsWith('/') ? path : `/${path}` + try { + const url = new URL(`${secure ? 'https' : 'http'}://${bareHost}${normalizedPath}`) + if (url.hostname !== bareHost || url.username || url.password || url.port) return null + return url.toString() + } catch { + return null + } +} + +export type TranslationOutcome = + | { ok: true; cookie: ImportableCookie } + | { ok: false; reason: CookieSkipReason } + +/** + * Converts one decrypted row, or explains why it was dropped. + * + * `nowSeconds` is injected so expiry is evaluated against a single instant for + * the whole import rather than drifting row to row. + */ +export function translateCookieRow( + row: ChromiumCookieRow, + value: string, + nowSeconds: number +): TranslationOutcome { + if (row.name.length === 0 && value.length === 0) { + return { ok: false, reason: 'empty' } + } + + const url = buildCookieUrl(row.hostKey, row.path, row.isSecure) + if (url === null) { + return { ok: false, reason: 'invalid-target' } + } + + const cookie: ImportableCookie = { + url, + name: row.name, + value, + // A leading dot is Chrome's marker for a domain cookie. Host-only cookies + // omit `domain` entirely so Electron scopes them to the URL's host — + // sending the bare host instead would widen them to every subdomain. + ...(row.hostKey.startsWith('.') ? { domain: row.hostKey } : {}), + path: row.path.startsWith('/') ? row.path : `/${row.path}`, + secure: row.isSecure, + httpOnly: row.isHttpOnly, + sameSite: toSameSite(row.sameSite), + } + + if (row.hasExpires && row.isPersistent) { + const expirationDate = chromeTimeToUnixSeconds(row.expiresUtc) + if (!Number.isFinite(expirationDate) || expirationDate <= nowSeconds) { + return { ok: false, reason: 'expired' } + } + cookie.expirationDate = expirationDate + } + + return { ok: true, cookie } +} diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts new file mode 100644 index 00000000000..2c66c825f8d --- /dev/null +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -0,0 +1,928 @@ +import { describe, expect, it, vi } from 'vitest' +import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources' +import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies' +import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords' +import type { ImportedSite } from '@/main/browser-import/chromium-site-names' +import { + type ImportServiceDeps, + importChromeCookies, + importChromeData, + importChromePasswords, + listImportableProfiles, + toDisplayProfiles, +} from '@/main/browser-import/import-service' +import { + type BrowserProfile, + emptySkipCounts, + type ImportableCookie, + ImportFailure, +} from '@/main/browser-import/types' + +const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource +const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource +const DIA = BROWSER_SOURCES.find(({ id }) => id === 'dia') as BrowserSource + +const PROFILES: BrowserProfile[] = [ + { + id: 'chrome:Default', + directory: 'Default', + label: 'Person 1', + source: CHROME, + cookiesPath: '/chrome/Default/Cookies', + loginDataPath: '/chrome/Default/Login Data', + faviconsPath: '/chrome/Default/Favicons', + historyPath: '/chrome/Default/History', + }, + { + id: 'arc:Profile 2', + directory: 'Profile 2', + label: 'Work', + source: ARC, + cookiesPath: '/arc/Profile 2/Cookies', + loginDataPath: '/arc/Profile 2/Login Data', + faviconsPath: '/arc/Profile 2/Favicons', + historyPath: '/arc/Profile 2/History', + }, +] + +function cookie(name = 'session'): ImportableCookie { + return { + url: 'https://example.com/', + name, + value: 'value', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + } +} + +function read(overrides: Partial = {}): ReadCookiesResult { + return { cookies: [cookie()], skipped: emptySkipCounts(), rowsSeen: 1, ...overrides } +} + +function readPasswords(overrides: Partial = {}): ReadPasswordsResult { + return { + credentials: [{ origin: 'https://example.com', username: 'ada', password: 'hunter2' }], + skipped: 0, + rowsSeen: 1, + ...overrides, + } +} + +function createDeps(overrides: Partial = {}): ImportServiceDeps { + return { + platform: 'darwin', + listProfiles: async () => PROFILES, + readSafeStoragePassword: async () => 'safe-storage-password', + readCookies: async () => read(), + writeCookies: async (cookies) => ({ imported: cookies.length, failed: 0 }), + readPasswords: async () => readPasswords(), + readFavicons: async () => new Map(), + readSites: async () => [], + rememberSites: async () => {}, + vault: { + isAvailable: () => true, + importCredentials: async (candidates) => ({ + added: candidates.length, + updated: 0, + skipped: 0, + }), + }, + ...overrides, + } +} + +describe('toDisplayProfiles', () => { + function profile(source: BrowserSource, directory: string, label: string): BrowserProfile { + return { + id: `${source.id}:${directory}`, + directory, + label, + source, + cookiesPath: '/cookies', + loginDataPath: null, + faviconsPath: null, + historyPath: null, + } + } + + it('leads with the browser, which is the identity that matters', () => { + // Regression: labelling by profile name alone produced a list like + // "Your Chrome / sim.ai / Your Chromium / Microtrades" with no way to tell + // which browser any of them belonged to. + const labels = toDisplayProfiles([ + profile(CHROME, 'Default', ''), + profile(CHROME, 'Profile 1', 'sim.ai'), + profile(ARC, 'Default', ''), + profile(ARC, 'Profile 2', 'Microtrades'), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Chrome', 'Chrome · sim.ai', 'Arc', 'Arc · Microtrades']) + }) + + it('uses the browser alone when the profile was never named', () => { + expect(toDisplayProfiles([profile(CHROME, 'Default', '')])[0].label).toBe('Chrome') + }) + + it('keeps two unnamed profiles of one browser distinguishable', () => { + const labels = toDisplayProfiles([ + profile(CHROME, 'Default', ''), + profile(CHROME, 'Profile 1', ''), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Chrome · Default', 'Chrome · Profile 1']) + expect(new Set(labels).size).toBe(2) + }) + + it('does not disambiguate across browsers, which are already distinct', () => { + const labels = toDisplayProfiles([ + profile(ARC, 'Default', ''), + profile(DIA, 'Default', ''), + ]).map(({ label }) => label) + + expect(labels).toEqual(['Arc', 'Dia']) + }) + + it('names an unnamed profile by its directory in a profile-only picker', () => { + // The browser dropdown already says "Chrome", so the profile dropdown + // beside it needs something to show rather than a blank row. + expect( + toDisplayProfiles([profile(CHROME, 'Default', '')]).map(({ profileLabel }) => profileLabel) + ).toEqual(['Default']) + + expect( + toDisplayProfiles([profile(CHROME, 'Profile 1', 'sim.ai')]).map( + ({ profileLabel }) => profileLabel + ) + ).toEqual(['sim.ai']) + }) +}) + +describe('listImportableProfiles', () => { + it('exposes ids, labels, and the owning browser — never a profile path', async () => { + await expect(listImportableProfiles(createDeps())).resolves.toEqual([ + { + id: 'chrome:Default', + label: 'Chrome · Person 1', + browserId: 'chrome', + browserLabel: 'Chrome', + profileLabel: 'Person 1', + }, + { + id: 'arc:Profile 2', + label: 'Arc · Work', + browserId: 'arc', + browserLabel: 'Arc', + profileLabel: 'Work', + }, + ]) + }) + + it('is empty off macOS without consulting the disk', async () => { + const listProfiles = vi.fn() + await expect( + listImportableProfiles(createDeps({ platform: 'win32', listProfiles })) + ).resolves.toEqual([]) + expect(listProfiles).not.toHaveBeenCalled() + }) + + it('reports no profiles rather than throwing when discovery fails', async () => { + const listProfiles = async () => { + throw new Error('unreadable') + } + await expect(listImportableProfiles(createDeps({ listProfiles }))).resolves.toEqual([]) + }) +}) + +describe('importChromeCookies', () => { + it('imports the requested profile and reports counts', async () => { + const readCookies = vi.fn(async () => read({ cookies: [cookie('a'), cookie('b')] })) + const result = await importChromeCookies('arc:Profile 2', createDeps({ readCookies })) + + expect(result).toEqual({ cookiesImported: 2, cookiesSkipped: 0 }) + expect(readCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer)) + }) + + it('defaults to the first profile when none is named', async () => { + const readCookies = vi.fn(async () => read()) + await importChromeCookies(undefined, createDeps({ readCookies })) + expect(readCookies).toHaveBeenCalledWith('/chrome/Default/Cookies', expect.any(Buffer)) + }) + + it('refuses an unknown profile instead of falling back to the default', async () => { + const readCookies = vi.fn(async () => read()) + const result = await importChromeCookies('../../elsewhere', createDeps({ readCookies })) + + expect(result.error).toBe('chrome-not-found') + expect(readCookies).not.toHaveBeenCalled() + }) + + it('counts cookies the browser profile rejected as skipped', async () => { + const deps = createDeps({ + readCookies: async () => + read({ + cookies: [cookie('a'), cookie('b'), cookie('c')], + skipped: { ...emptySkipCounts(), expired: 4 }, + }), + writeCookies: async () => ({ imported: 2, failed: 1 }), + }) + + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 2, + cookiesSkipped: 5, + }) + }) + + it('zeroes the derived key once the rows are decrypted', async () => { + let observed: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + observed = key + expect(key.some((byte) => byte !== 0)).toBe(true) + return read() + }, + }) + + await importChromeCookies(undefined, deps) + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it('zeroes the derived key even when reading throws', async () => { + let observed: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + observed = key + throw new ImportFailure('profile-unreadable', 'locked') + }, + }) + + await importChromeCookies(undefined, deps) + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it.each([ + ['unsupported-platform', createDeps({ platform: 'win32' })], + ['chrome-not-found', createDeps({ listProfiles: async () => [] })], + ['keychain-unavailable', createDeps({ readSafeStoragePassword: async () => null })], + ] as const)('fails closed with %s', async (error, deps) => { + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error, + }) + }) + + it('never reaches the Keychain on an unsupported platform', async () => { + const readSafeStoragePassword = vi.fn() + await importChromeCookies(undefined, createDeps({ platform: 'linux', readSafeStoragePassword })) + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('surfaces a reader failure as its own category', async () => { + const deps = createDeps({ + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown table shape') + }, + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unsupported-schema', + }) + }) + + it('reports rows that all failed to decrypt as an import failure', async () => { + const deps = createDeps({ + readCookies: async () => + read({ cookies: [], skipped: { ...emptySkipCounts(), 'decrypt-failed': 9 }, rowsSeen: 9 }), + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 9, + error: 'nothing-imported', + }) + }) + + it('treats a genuinely empty profile as a success', async () => { + const deps = createDeps({ readCookies: async () => read({ cookies: [], rowsSeen: 0 }) }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + }) + }) + + it('does not leak an unexpected error to the caller', async () => { + const deps = createDeps({ + writeCookies: async () => { + throw new Error('/Users/someone/Library/Application Support/Google/Chrome exploded') + }, + }) + await expect(importChromeCookies(undefined, deps)).resolves.toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + }) + + it('reports a profile with no cookie database rather than reading another one', async () => { + const deps = createDeps({ + listProfiles: async () => [ + { + id: 'chrome:Default', + directory: 'Default', + label: 'Person 1', + source: CHROME, + cookiesPath: null, + loginDataPath: '/chrome/Login Data', + faviconsPath: null, + historyPath: null, + }, + ], + }) + await expect(importChromeCookies(undefined, deps)).resolves.toMatchObject({ + error: 'profile-unreadable', + }) + }) +}) + +describe('importChromePasswords', () => { + it('stores decrypted passwords in the vault and reports counts', async () => { + const importCredentials = vi.fn(async () => ({ added: 2, updated: 1, skipped: 0 })) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [ + { origin: 'https://example.com', username: 'ada', password: 'a' }, + { origin: 'https://other.test', username: 'grace', password: 'b' }, + ], + skipped: 3, + rowsSeen: 5, + }), + vault: { isAvailable: () => true, importCredentials }, + }) + + await expect(importChromePasswords('arc:Profile 2', 'replace', deps)).resolves.toEqual({ + passwordsAdded: 2, + passwordsUpdated: 1, + passwordsSkipped: 3, + }) + expect(importCredentials).toHaveBeenCalledWith(expect.any(Array), 'replace') + }) + + it('attaches each site\u2019s icon from the same profile', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const readFavicons = vi.fn( + async () => new Map([['https://example.com', 'data:image/png;base64,AA']]) + ) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [{ origin: 'https://example.com/login', username: 'ada', password: 'p' }], + }), + readFavicons, + vault: { isAvailable: () => true, importCredentials }, + }) + + await importChromePasswords('chrome:Default', 'keep-existing', deps) + + // Only the origins being imported are looked up, never the whole history. + expect(readFavicons).toHaveBeenCalledWith( + '/chrome/Default/Favicons', + new Set(['https://example.com']) + ) + expect(importCredentials).toHaveBeenCalledWith( + [expect.objectContaining({ icon: 'data:image/png;base64,AA' })], + 'keep-existing' + ) + }) + + it('imports without icons when the profile has no favicon store', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const readFavicons = vi.fn() + const deps = createDeps({ + listProfiles: async () => [{ ...PROFILES[0], faviconsPath: null, historyPath: null }], + readFavicons, + vault: { isAvailable: () => true, importCredentials }, + }) + + await importChromePasswords(undefined, 'keep-existing', deps) + + expect(readFavicons).not.toHaveBeenCalled() + expect(importCredentials).toHaveBeenCalledWith( + [expect.not.objectContaining({ icon: expect.anything() })], + 'keep-existing' + ) + }) + + it('still imports passwords when the favicon store will not read', async () => { + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const deps = createDeps({ + readFavicons: async () => { + throw new Error('corrupt') + }, + vault: { isAvailable: () => true, importCredentials }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toMatchObject({ + passwordsAdded: 1, + }) + }) + + it('reads the requested profile\u2019s password database', async () => { + const readPasswordsSpy = vi.fn(async () => readPasswords()) + await importChromePasswords( + 'arc:Profile 2', + 'keep-existing', + createDeps({ readPasswords: readPasswordsSpy }) + ) + + expect(readPasswordsSpy).toHaveBeenCalledWith('/arc/Profile 2/Login Data', expect.any(Buffer)) + }) + + it('uses the chosen browser\u2019s own Keychain item', async () => { + // Reading Chrome's item to import Arc would prompt the user about the + // wrong browser — and would derive a key that cannot decrypt anything. + const readSafeStoragePassword = vi.fn(async () => 'password') + await importChromePasswords( + 'arc:Profile 2', + 'keep-existing', + createDeps({ readSafeStoragePassword }) + ) + + expect(readSafeStoragePassword).toHaveBeenCalledWith({ + service: 'Arc Safe Storage', + account: 'Arc', + }) + }) + + it('refuses an unknown profile rather than falling back to the default', async () => { + const readPasswordsSpy = vi.fn(async () => readPasswords()) + const result = await importChromePasswords( + '../../elsewhere', + 'keep-existing', + createDeps({ readPasswords: readPasswordsSpy }) + ) + + expect(result.error).toBe('chrome-not-found') + expect(readPasswordsSpy).not.toHaveBeenCalled() + }) + + it('will not run without secure storage, and never reaches the Keychain', async () => { + // There is no plaintext fallback for passwords: an unavailable vault ends + // the import instead of degrading it. + const readSafeStoragePassword = vi.fn() + const deps = createDeps({ + readSafeStoragePassword, + vault: { isAvailable: () => false, importCredentials: vi.fn() }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'vault-unavailable', + }) + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('zeroes the derived key after reading, including on failure', async () => { + let observed: Buffer | undefined + await importChromePasswords( + undefined, + 'keep-existing', + createDeps({ + readPasswords: async (_path, key) => { + observed = key + throw new ImportFailure('unsupported-schema', 'unknown logins table') + }, + }) + ) + + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) + + it('reports rows that all failed to decrypt as an import failure', async () => { + const deps = createDeps({ + readPasswords: async () => readPasswords({ credentials: [], skipped: 7, rowsSeen: 7 }), + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 7, + error: 'nothing-imported', + }) + }) + + it('treats a profile with no saved passwords as a success', async () => { + const deps = createDeps({ + readPasswords: async () => readPasswords({ credentials: [], skipped: 0, rowsSeen: 0 }), + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + }) + }) + + it.each([ + ['unsupported-platform', { platform: 'win32' as const }], + ['chrome-not-found', { listProfiles: async () => [] }], + ['keychain-unavailable', { readSafeStoragePassword: async () => null }], + ])('fails closed with %s', async (error, overrides) => { + await expect( + importChromePasswords(undefined, 'keep-existing', createDeps(overrides)) + ).resolves.toMatchObject({ error }) + }) + + it('does not leak an unexpected error to the caller', async () => { + const deps = createDeps({ + vault: { + isAvailable: () => true, + importCredentials: async () => { + throw new Error('/Users/someone/Library/.../Login Data exploded') + }, + }, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }) + }) +}) + +describe('importChromeData', () => { + const COOKIES_IMPORTED = { cookiesImported: 1, cookiesSkipped: 0 } + const PASSWORDS_IMPORTED = { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 } + + /** Runs a combined import while holding on to the key the halves were handed. */ + async function importObservingKey(overrides: Partial = {}) { + const base = createDeps(overrides) + let observed: Buffer | undefined + const result = await importChromeData(undefined, 'keep-existing', { + ...base, + readCookies: (path, key) => { + observed = key + return base.readCookies(path, key) + }, + readPasswords: (path, key) => { + observed = key + return base.readPasswords(path, key) + }, + }) + return { result, observed } + } + + it('brings over both halves in one action', async () => { + await expect(importChromeData('arc:Profile 2', 'keep-existing', createDeps())).resolves.toEqual( + { cookies: COOKIES_IMPORTED, passwords: PASSWORDS_IMPORTED } + ) + }) + + it('refuses both halves off macOS without consulting the disk', async () => { + const listProfiles = vi.fn() + const readSafeStoragePassword = vi.fn() + + await expect( + importChromeData( + undefined, + 'keep-existing', + createDeps({ platform: 'win32', listProfiles, readSafeStoragePassword }) + ) + ).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-platform' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unsupported-platform', + }, + }) + expect(listProfiles).not.toHaveBeenCalled() + expect(readSafeStoragePassword).not.toHaveBeenCalled() + }) + + it('prompts for the Keychain once, not once per half', async () => { + // Regression: running the two exported halves in sequence read the Safe + // Storage item twice, so a user who answered the first prompt with "Allow" + // rather than "Always Allow" got a second prompt mid-import — one arriving + // without a user gesture behind it, which silently fails the password half. + const listProfiles = vi.fn(async () => PROFILES) + const readSafeStoragePassword = vi.fn(async () => 'safe-storage-password') + + await importChromeData( + 'chrome:Default', + 'keep-existing', + createDeps({ listProfiles, readSafeStoragePassword }) + ) + + expect(readSafeStoragePassword).toHaveBeenCalledTimes(1) + expect(listProfiles).toHaveBeenCalledTimes(1) + }) + + it('hands both halves the one key that open derived', async () => { + let cookieKey: Buffer | undefined + let passwordKey: Buffer | undefined + const deps = createDeps({ + readCookies: async (_path, key) => { + cookieKey = key + return read() + }, + readPasswords: async (_path, key) => { + passwordKey = key + // The cookie half must not wipe the key out from under this one. + expect(key.some((byte) => byte !== 0)).toBe(true) + return readPasswords() + }, + }) + + await importChromeData(undefined, 'keep-existing', deps) + + expect(passwordKey).toBe(cookieKey) + }) + + it('reports the same reason for both halves when the profile will not open', async () => { + await expect( + importChromeData('../../elsewhere', 'keep-existing', createDeps()) + ).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'chrome-not-found' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'chrome-not-found', + }, + }) + }) + + it('still imports passwords when the cookie half throws', async () => { + const deps = createDeps({ + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown cookies table') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-schema' }, + passwords: PASSWORDS_IMPORTED, + }) + }) + + it('still reports the imported cookies when the password half throws', async () => { + const deps = createDeps({ + readPasswords: async () => { + throw new ImportFailure('unsupported-schema', 'unknown logins table') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: COOKIES_IMPORTED, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unsupported-schema', + }, + }) + }) + + it('imports cookies even though secure storage cannot take the passwords', async () => { + // There is no plaintext fallback for passwords, but cookies do not need + // the vault — so an unavailable one must not cost the user both halves. + const importCredentials = vi.fn() + const deps = createDeps({ vault: { isAvailable: () => false, importCredentials } }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: COOKIES_IMPORTED, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'vault-unavailable', + }, + }) + expect(importCredentials).not.toHaveBeenCalled() + }) + + it('filters history by the cookie hosts and the password origins together', async () => { + // Either half alone is a partial picture of what the user brought over. + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readCookies: async () => + read({ cookies: [{ ...cookie(), url: 'https://news.example.com/' }] }), + readPasswords: async () => + readPasswords({ + credentials: [{ origin: 'https://accounts.other.test', username: 'ada', password: 'p' }], + }), + readSites, + }) + + await importChromeData(undefined, 'keep-existing', deps) + + expect(readSites).toHaveBeenCalledTimes(1) + expect(readSites).toHaveBeenCalledWith( + '/chrome/Default/History', + new Set(['news.example.com', 'accounts.other.test']) + ) + }) + + const KEY_PATHS: [string, Partial][] = [ + ['both halves land', {}], + [ + 'the cookie half throws', + { + readCookies: async () => { + throw new ImportFailure('unsupported-schema', 'unknown cookies table') + }, + }, + ], + [ + 'the password half throws', + { + readPasswords: async () => { + throw new Error('/Users/someone/Library/.../Login Data exploded') + }, + }, + ], + [ + 'secure storage is unavailable', + { vault: { isAvailable: () => false, importCredentials: vi.fn() } }, + ], + [ + 'remembering the sites throws', + { + rememberSites: async () => { + throw new Error('directory is unwritable') + }, + }, + ], + ] + + it.each(KEY_PATHS)('zeroes the derived key when %s', async (_path, overrides) => { + const { observed } = await importObservingKey(overrides) + + expect(observed).toBeDefined() + expect(observed?.every((byte) => byte === 0)).toBe(true) + }) +}) + +describe('remembering the sites an import brought over', () => { + const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + + it('records the host history knows, not the cookie domain it was filtered by', async () => { + // The filter is `google.com` (a domain cookie with its dot stripped) but + // the site the user opened is `mail.google.com`. The favicon lookup and the + // omnibox both key off the recorded string, so it must be the visited host. + const rememberSites = vi.fn(async () => {}) + const readSites = vi.fn(async () => [ + { hostname: 'mail.google.com', name: 'Gmail', visits: 412 }, + ]) + const readFavicons = vi.fn( + async () => new Map([['https://mail.google.com', 'data:image/png;base64,AA']]) + ) + const deps = createDeps({ + readCookies: async () => read({ cookies: [{ ...cookie(), url: 'https://google.com/' }] }), + readSites, + readFavicons, + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['google.com'])) + expect(readFavicons).toHaveBeenCalledWith( + '/chrome/Default/Favicons', + new Set(['https://mail.google.com']) + ) + expect(rememberSites).toHaveBeenCalledWith([ + { + hostname: 'mail.google.com', + name: 'Gmail', + icon: 'data:image/png;base64,AA', + visits: 412, + importedAt: expect.stringMatching(ISO_TIMESTAMP), + }, + ]) + }) + + it('carries the visit count and the import time, which order the suggestions', async () => { + const rememberSites = vi.fn(async () => {}) + const deps = createDeps({ + readSites: async () => [ + { hostname: 'example.com', name: 'Example', visits: 90 }, + { hostname: 'docs.example.com', visits: 4 }, + ], + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + const [records] = rememberSites.mock.calls[0] + expect(records.map(({ hostname, visits }) => ({ hostname, visits }))).toEqual([ + { hostname: 'example.com', visits: 90 }, + { hostname: 'docs.example.com', visits: 4 }, + ]) + // One wall-clock stamp for the whole import, never the source browser's + // own last-visit time — that would be the visit log this store avoids. + expect(new Set(records.map(({ importedAt }) => importedAt)).size).toBe(1) + expect(records[0].importedAt).toMatch(ISO_TIMESTAMP) + }) + + it('leaves a finished import alone when reading history throws synchronously', async () => { + // Regression: a dependency that threw before returning its promise escaped + // the `.catch()` chain and rewrote a completed import as + // { cookiesImported: 0, error: 'unknown' } — losing cookies already written. + const readSites = vi.fn((): Promise => { + throw new Error('History is locked by the source browser') + }) + + await expect(importChromeCookies(undefined, createDeps({ readSites }))).resolves.toEqual({ + cookiesImported: 1, + cookiesSkipped: 0, + }) + expect(readSites).toHaveBeenCalled() + }) + + it('leaves a finished import alone when writing the directory throws synchronously', async () => { + const rememberSites = vi.fn((): Promise => { + throw new Error('site directory is unwritable') + }) + const deps = createDeps({ + readSites: async () => [{ hostname: 'example.com', visits: 3 }], + rememberSites, + }) + + await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({ + passwordsAdded: 1, + passwordsUpdated: 0, + passwordsSkipped: 0, + }) + expect(rememberSites).toHaveBeenCalled() + }) + + it('does not reject the combined import when remembering the sites fails', async () => { + // The combined entry point awaits this last, outside its own guard, so a + // throw here would reach the IPC caller as a rejected promise. + const deps = createDeps({ + readSites: async () => [{ hostname: 'example.com', visits: 3 }], + rememberSites: async () => { + throw new Error('directory is unwritable') + }, + }) + + await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({ + cookies: { cookiesImported: 1, cookiesSkipped: 0 }, + passwords: { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 }, + }) + }) + + it('never records an Android package name a saved credential carried', async () => { + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readPasswords: async () => + readPasswords({ + credentials: [ + { origin: 'android://hash@com.example.app/', username: 'ada', password: 'p' }, + { origin: 'https://example.com', username: 'ada', password: 'p' }, + ], + }), + readSites, + }) + + await importChromePasswords(undefined, 'keep-existing', deps) + + expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['example.com'])) + }) + + it('does not open history for a profile that has none', async () => { + const readSites = vi.fn(async () => []) + const deps = createDeps({ + listProfiles: async () => [{ ...PROFILES[0], historyPath: null }], + readSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).not.toHaveBeenCalled() + }) + + it('writes nothing when the import vouched for no hosts', async () => { + const rememberSites = vi.fn(async () => {}) + const readSites = vi.fn(async () => []) + const deps = createDeps({ + readCookies: async () => read({ cookies: [], rowsSeen: 0 }), + readSites, + rememberSites, + }) + + await importChromeCookies(undefined, deps) + + expect(readSites).not.toHaveBeenCalled() + expect(rememberSites).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts new file mode 100644 index 00000000000..38d3fbc9be3 --- /dev/null +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -0,0 +1,511 @@ +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, +} from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { normalizeOrigin } from '@/main/browser-credentials/origin' +import type { ImportCandidate, ImportOutcome } from '@/main/browser-credentials/vault' +import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies' +import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto' +import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords' +import type { ImportedSite } from '@/main/browser-import/chromium-site-names' +import { + type BrowserProfile, + type ImportableCookie, + ImportFailure, + totalSkipped, +} from '@/main/browser-import/types' +import type { SiteRecord } from '@/main/browser-sites/directory' + +const logger = createLogger('BrowserImport') + +/** + * Orchestrates Chrome imports. + * + * Every dependency is injected so the policy in this module — platform gating, + * profile resolution, key lifetime, failure categorisation — can be tested + * without a Keychain, a Chrome profile, an Electron session, or a real vault. + */ +export interface ImportServiceDeps { + platform: NodeJS.Platform + listProfiles: () => Promise + /** Reads one browser's Safe Storage key; the item comes from its source. */ + readSafeStoragePassword: (item: { service: string; account: string }) => Promise + readCookies: (cookiesPath: string, key: Buffer) => Promise + writeCookies: (cookies: ImportableCookie[]) => Promise<{ imported: number; failed: number }> + readPasswords: (loginDataPath: string, key: Buffer) => Promise + /** Site icons for the given origins, read from the source browser's own store. */ + readFavicons: (faviconsPath: string, origins: ReadonlySet) => Promise> + /** The most-used hosts in the source browser's history that `domains` covers. */ + readSites: (historyPath: string, domains: ReadonlySet) => Promise + /** Records the hosts an import brought over, with their names and icons. */ + rememberSites: (records: readonly SiteRecord[]) => Promise + vault: { + isAvailable: () => boolean + importCredentials: ( + candidates: ImportCandidate[], + policy: BrowserCredentialConflictPolicy + ) => Promise + } +} + +function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult { + return { cookiesImported: 0, cookiesSkipped: 0, error } +} + +function passwordFailure(error: BrowserImportResult['error']): BrowserPasswordImportResult { + return { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0, error } +} + +/** + * Resolves the profile to read and derives its decryption key. + * + * A renderer-supplied id is matched against what was actually discovered and + * is never joined into a path, so it cannot escape Chrome's directory, and an + * unknown id is refused rather than quietly falling back to the default + * profile — that would read the wrong account. + */ +interface OpenProfile { + profile: BrowserProfile + key: Buffer +} + +/** + * Wipes the derived key once every read that needs it is done. (The password + * string itself is immutable and cannot be wiped; it is never persisted or + * logged.) + */ +function zeroKey({ key }: OpenProfile): void { + key.fill(0) +} + +async function openBrowserProfile( + profileId: string | undefined, + deps: ImportServiceDeps +): Promise { + const profiles = await deps.listProfiles() + const profile = profileId ? profiles.find(({ id }) => id === profileId) : profiles[0] + if (!profile) { + throw new ImportFailure('chrome-not-found', 'No matching browser profile.') + } + // Each browser has its own Safe Storage item, so the prompt the user sees + // names the browser they actually chose. + const safeStoragePassword = await deps.readSafeStoragePassword(profile.source.keychain) + if (safeStoragePassword === null) { + throw new ImportFailure('keychain-unavailable', 'Keychain access was unavailable.') + } + return { profile, key: deriveEncryptionKey(safeStoragePassword) } +} + +/** + * Chrome profiles offered to the user, stripped to what the UI needs. Returns + * an empty list rather than throwing when Chrome is absent or unsupported. + */ +export async function listImportableProfiles( + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return [] + try { + return toDisplayProfiles(await deps.listProfiles()) + } catch { + return [] + } +} + +/** + * Turns discovered profiles into things a person can pick between. + * + * The browser is the identity that matters — "Arc", not "Microtrades" — so + * every label leads with it, and a profile name is only appended when the user + * actually gave the profile one. Two unnamed profiles in the same browser fall + * back to the directory, because a list with the same entry twice is worse + * than one with an ugly entry. + */ +export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProfile[] { + const unnamedPerBrowser = new Map() + for (const { source, label } of profiles) { + if (label === '') { + unnamedPerBrowser.set(source.id, (unnamedPerBrowser.get(source.id) ?? 0) + 1) + } + } + + return profiles.map(({ id, label, directory, source }) => { + let suffix = label + if (suffix === '' && (unnamedPerBrowser.get(source.id) ?? 0) > 1) { + suffix = directory + } + return { + id, + label: suffix === '' ? source.label : `${source.label} · ${suffix}`, + browserId: source.id, + browserLabel: source.label, + // Shown on its own in a profile picker, where the browser is already + // chosen — so an unnamed profile reads as "Default" rather than blank. + profileLabel: label === '' ? directory : label, + } + }) +} + +/** + * Copies one Chrome profile's cookies into the built-in browser. + * + * Fails closed at every step: an unsupported platform, an absent Chrome, a + * refused Keychain prompt, or an unrecognised database all stop the import + * with a category instead of falling back to a weaker path. The result carries + * counts only — no cookie material, domain, or path reaches the caller. + */ +export async function importChromeCookies( + profileId: string | undefined, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return cookieFailure('unsupported-platform') + + try { + const open = await openBrowserProfile(profileId, deps) + let outcome: CookieOutcome + try { + outcome = await runCookieImport(open, deps) + } finally { + zeroKey(open) + } + await rememberImportedSites(outcome.domains, open.profile, deps) + return outcome.result + } catch (error) { + return cookieFailure(categorize(error, 'cookie')) + } +} + +/** What an import half brought over, and the hosts it can vouch for. */ +interface CookieOutcome { + result: BrowserImportResult + domains: Set +} + +interface PasswordOutcome { + result: BrowserPasswordImportResult + domains: Set +} + +/** + * The cookie half against an already-open profile. + * + * Key lifetime belongs to the caller: the combined import shares one key across + * both halves, so this must not wipe it out from under the password half. + */ +async function runCookieImport( + { profile, key }: OpenProfile, + deps: ImportServiceDeps +): Promise { + if (profile.cookiesPath === null) { + return { result: cookieFailure('profile-unreadable'), domains: new Set() } + } + + const read = await deps.readCookies(profile.cookiesPath, key) + const skippedReading = totalSkipped(read.skipped) + // Rows present but nothing usable means the profile did not decrypt — + // typically a scheme this importer does not support. Report it as a + // failure rather than as a successful import of zero cookies. + if (read.cookies.length === 0) { + return { + result: + read.rowsSeen > 0 + ? { cookiesImported: 0, cookiesSkipped: skippedReading, error: 'nothing-imported' } + : { cookiesImported: 0, cookiesSkipped: 0 }, + domains: new Set(), + } + } + + const written = await deps.writeCookies(read.cookies) + const result: BrowserImportResult = { + cookiesImported: written.imported, + cookiesSkipped: skippedReading + written.failed, + } + // Counts only: cookie names, values, domains, and the profile path are + // deliberately absent from local diagnostics. + logger.info('Chrome cookie import finished', { + imported: result.cookiesImported, + skipped: result.cookiesSkipped, + }) + return { result, domains: cookieHostnames(read.cookies) } +} + +/** + * Copies one Chrome profile's saved passwords into the encrypted vault. + * + * Refuses to run when secure storage is unavailable: there is no plaintext + * fallback for passwords, so an unavailable vault ends the import rather than + * degrading it. Decrypted passwords pass straight from the reader into the + * vault and are never logged or counted per site. + */ +export async function importChromePasswords( + profileId: string | undefined, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') return passwordFailure('unsupported-platform') + if (!deps.vault.isAvailable()) return passwordFailure('vault-unavailable') + + try { + const open = await openBrowserProfile(profileId, deps) + let outcome: PasswordOutcome + try { + outcome = await runPasswordImport(open, policy, deps) + } finally { + zeroKey(open) + } + await rememberImportedSites(outcome.domains, open.profile, deps) + return outcome.result + } catch (error) { + return passwordFailure(categorize(error, 'password')) + } +} + +/** The password half against an already-open profile. @see runCookieImport */ +async function runPasswordImport( + { profile, key }: OpenProfile, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (profile.loginDataPath === null) { + return { result: passwordFailure('profile-unreadable'), domains: new Set() } + } + + const read: ReadPasswordsResult = await deps.readPasswords(profile.loginDataPath, key) + if (read.credentials.length === 0) { + return { + result: + read.rowsSeen > 0 + ? { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: read.skipped, + error: 'nothing-imported', + } + : { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0 }, + domains: new Set(), + } + } + + const outcome = await deps.vault.importCredentials( + await withFavicons(read.credentials, profile.faviconsPath, deps), + policy + ) + const result: BrowserPasswordImportResult = { + passwordsAdded: outcome.added, + passwordsUpdated: outcome.updated, + passwordsSkipped: outcome.skipped + read.skipped, + } + logger.info('Chrome password import finished', { + added: result.passwordsAdded, + updated: result.passwordsUpdated, + skipped: result.passwordsSkipped, + }) + return { result, domains: credentialHostnames(read.credentials) } +} + +/** + * Imports cookies and saved passwords in one action. + * + * Deliberately one call rather than two from the UI: the Keychain prompt can + * easily outlive the page's transient user activation, so a second gated call + * afterwards would be refused for a user who did nothing wrong. Each half + * still reports its own outcome, so one failing does not hide the other. + * + * The profile is opened ONCE for both halves. Calling the two exported halves + * in sequence would read the Safe Storage item twice, and a user who answered + * the first Keychain prompt with "Allow" rather than "Always Allow" gets a + * second prompt they did not ask for — which, arriving without a user gesture + * behind it, is exactly the failure this combined entry point exists to + * prevent. One open also means one history read, over the union of what both + * halves vouched for. (The favicon store is still read twice — once for the + * saved-password origins, once for the history hosts — because the two sets are + * only known at different points.) + */ +export async function importChromeData( + profileId: string | undefined, + policy: BrowserCredentialConflictPolicy, + deps: ImportServiceDeps +): Promise { + if (deps.platform !== 'darwin') { + return { + cookies: cookieFailure('unsupported-platform'), + passwords: passwordFailure('unsupported-platform'), + } + } + + let open: OpenProfile + try { + open = await openBrowserProfile(profileId, deps) + } catch (error) { + // Neither half ran, so both report the same reason rather than one of them + // claiming a success it never attempted. + return { + cookies: cookieFailure(categorize(error, 'cookie')), + passwords: passwordFailure(categorize(error, 'password')), + } + } + + let cookies: CookieOutcome = { result: cookieFailure('unknown'), domains: new Set() } + let passwords: PasswordOutcome = { result: passwordFailure('unknown'), domains: new Set() } + try { + try { + cookies = await runCookieImport(open, deps) + } catch (error) { + cookies = { result: cookieFailure(categorize(error, 'cookie')), domains: new Set() } + } + // One half failing must not take the other with it. + try { + passwords = deps.vault.isAvailable() + ? await runPasswordImport(open, policy, deps) + : { result: passwordFailure('vault-unavailable'), domains: new Set() } + } catch (error) { + passwords = { result: passwordFailure(categorize(error, 'password')), domains: new Set() } + } + } finally { + zeroKey(open) + } + + await rememberImportedSites(union(cookies.domains, passwords.domains), open.profile, deps) + return { cookies: cookies.result, passwords: passwords.result } +} + +function union(first: ReadonlySet, second: ReadonlySet): Set { + return new Set([...first, ...second]) +} + +/** + * Attaches each credential's site icon, where the source browser has one. + * + * Icons are cosmetic, so a profile without a favicon store — or a favicon + * store that will not read — returns the credentials unchanged rather than + * failing an import that otherwise succeeded. + */ +async function withFavicons( + credentials: ImportCandidate[], + faviconsPath: string | null, + deps: ImportServiceDeps +): Promise { + if (faviconsPath === null) return credentials + // Only the origins being imported are looked up, so a password import never + // drags the browser's whole history along with it. + const origins = new Set( + credentials.flatMap((candidate) => { + const origin = normalizeOrigin(candidate.origin) + return origin === null ? [] : [origin] + }) + ) + + const icons = await deps + .readFavicons(faviconsPath, origins) + .catch(() => new Map()) + if (icons.size === 0) return credentials + + return credentials.map((candidate) => { + const icon = icons.get(normalizeOrigin(candidate.origin) ?? '') + return icon ? { ...candidate, icon } : candidate + }) +} + +/** The hosts a set of cookies belongs to, with the domain-cookie dot removed. */ +function cookieHostnames(cookies: readonly ImportableCookie[]): Set { + const hostnames = new Set() + for (const cookie of cookies) { + const hostname = hostnameOf(cookie.url) + if (hostname) hostnames.add(hostname) + } + return hostnames +} + +function credentialHostnames(credentials: readonly ImportCandidate[]): Set { + const hostnames = new Set() + for (const candidate of credentials) { + const hostname = hostnameOf(candidate.origin) + if (hostname) hostnames.add(hostname) + } + return hostnames +} + +/** + * Chrome stores Android app credentials with an `android://…@com.example.app/` + * realm, so without a scheme guard a package name would pass as a host and + * reach the omnibox as a site nothing can navigate to. + */ +function hostnameOf(url: string): string | null { + try { + const { hostname, protocol } = new URL(url) + if (protocol !== 'https:' && protocol !== 'http:') return null + return hostname || null + } catch { + return null + } +} + +/** + * Records the sites this import brought over, so the omnibox can offer them — + * as "Gmail" rather than `mail.google.com` where the source browser knew that. + * + * `domains` are the hosts the import has evidence for: cookie hosts with the + * domain dot stripped, plus saved-password origins. They are the FILTER; the + * source browser's history is the SOURCE. Being sourced from pages someone + * opened is what keeps trackers out — a cookie jar would be mostly ad networks. + * The filter's job is narrower: it holds what gets remembered inside the data + * the user chose to bring over. See {@link readBrowserSites}. + * + * Each record is keyed by the host actually visited, so the favicon lookup and + * the omnibox agree on the string. + * + * Cannot throw. A name and an icon are a nicety layered on cookies and + * passwords that have already landed, so no failure in here — including a + * synchronous one from a dependency — may reach the caller and rewrite a + * finished import as a failed one. + */ +async function rememberImportedSites( + domains: ReadonlySet, + profile: BrowserProfile, + deps: ImportServiceDeps +): Promise { + if (domains.size === 0 || !profile.historyPath) return + + try { + const sites = await deps.readSites(profile.historyPath, domains) + if (sites.length === 0) return + + const origins = new Set(sites.map((site) => originOf(site.hostname))) + const icons = profile.faviconsPath + ? await deps + .readFavicons(profile.faviconsPath, origins) + .catch(() => new Map()) + : new Map() + + const importedAt = new Date().toISOString() + await deps.rememberSites( + sites.map((site) => ({ + hostname: site.hostname, + name: site.name, + icon: icons.get(originOf(site.hostname)), + visits: site.visits, + importedAt, + })) + ) + } catch { + // Category only, like every other failure path here: the detail that would + // make this message useful is the hostnames, which is exactly what must not + // reach a local log. Without the line, a directory that can never be + // written leaves the omnibox permanently empty with no signal anywhere. + logger.warn('Could not record the sites an import brought over') + } +} + +const originOf = (hostname: string) => `https://${hostname}` + +function categorize(error: unknown, kind: 'cookie' | 'password'): BrowserImportResult['error'] { + if (error instanceof ImportFailure) { + logger.warn(`Chrome ${kind} import failed`, { code: error.code }) + return error.code + } + logger.warn(`Chrome ${kind} import failed with an unexpected error`) + return 'unknown' +} diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts new file mode 100644 index 00000000000..92ec971868d --- /dev/null +++ b/apps/desktop/src/main/browser-import/index.ts @@ -0,0 +1,68 @@ +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, +} from '@sim/desktop-bridge' +import { importAgentCookies } from '@/main/browser-agent/session' +import { credentialsAvailable, importCredentials } from '@/main/browser-credentials' +import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' +import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto' +import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons' +import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords' +import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles' +import { readBrowserSites } from '@/main/browser-import/chromium-site-names' +import { + type ImportServiceDeps, + listImportableProfiles, + importChromeCookies as runCookieImport, + importChromeData as runDataImport, + importChromePasswords as runPasswordImport, +} from '@/main/browser-import/import-service' +import { rememberSites } from '@/main/browser-sites' + +/** + * Composition root for the Chrome importer: binds the real Keychain, profile, + * database, browser-profile, and vault implementations to the policy in + * `import-service`. The IPC layer talks to this module and nothing deeper. + */ +function deps(): ImportServiceDeps { + return { + platform: process.platform, + listProfiles: () => listAllBrowserProfiles(), + readSafeStoragePassword: (item) => readSafeStoragePassword(item), + readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key), + writeCookies: (cookies) => importAgentCookies(cookies), + readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key), + readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins), + readSites: (historyPath, domains) => readBrowserSites(historyPath, domains), + rememberSites: (records) => rememberSites(records), + vault: { + isAvailable: () => credentialsAvailable(), + importCredentials: (candidates, policy) => importCredentials(candidates, policy), + }, + } +} + +export function listChromeImportProfiles(): Promise { + return listImportableProfiles(deps()) +} + +export function importChromeCookies(profileId?: string): Promise { + return runCookieImport(profileId, deps()) +} + +export function importChromePasswords( + profileId?: string, + policy: BrowserCredentialConflictPolicy = 'keep-existing' +): Promise { + return runPasswordImport(profileId, policy, deps()) +} + +export function importChromeData( + profileId?: string, + policy: BrowserCredentialConflictPolicy = 'keep-existing' +): Promise { + return runDataImport(profileId, policy, deps()) +} diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts new file mode 100644 index 00000000000..69b6d6c2be9 --- /dev/null +++ b/apps/desktop/src/main/browser-import/sqlite-source.ts @@ -0,0 +1,76 @@ +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ImportFailure } from '@/main/browser-import/types' + +/** + * Reads a Chrome SQLite database without touching the original. + * + * Chrome keeps its databases open and may hold a write-ahead log, so every + * read works from a private copy: the source is only ever read, never opened + * for writing and never locked, and the copy lives in a fresh temporary + * directory removed on success, failure, and cancellation alike. Both the + * cookie and password readers go through here so that guarantee is written + * once rather than reimplemented per database. + */ + +/** SQLite keeps recent writes beside the main file; both are needed for a faithful copy. */ +const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm'] + +async function queryCopy(databasePath: string, query: string): Promise[]> { + // Imported lazily: `node:sqlite` is only needed on the import path, and this + // keeps module load working on runtimes that lack it. + const { DatabaseSync } = await import('node:sqlite') + + let database: InstanceType + try { + database = new DatabaseSync(databasePath, { readOnly: true }) + } catch { + // A write-ahead log that needs recovery cannot be opened read-only. + // Reopening the *copy* writable is safe — Chrome's own file is not this one. + try { + database = new DatabaseSync(databasePath) + } catch { + throw new ImportFailure('profile-unreadable', 'Could not open the copied database.') + } + } + + try { + const statement = database.prepare(query) + // Chrome's microsecond timestamps overflow a JS number's safe integer + // range, which this API rejects unless BigInt reads are enabled. + statement.setReadBigInts(true) + return statement.all() as Record[] + } catch { + throw new ImportFailure('unsupported-schema', 'The Chrome table is not in a recognised shape.') + } finally { + database.close() + } +} + +/** + * Copies `sourcePath` (plus any SQLite sidecars) to private temporary storage + * and runs `query` against the copy. The copy is always deleted. + */ +export async function queryBrowserDatabase( + sourcePath: string, + fileName: string, + query: string +): Promise[]> { + const staging = await mkdtemp(join(tmpdir(), 'sim-chrome-import-')) + try { + const workingCopy = join(staging, fileName) + try { + await copyFile(sourcePath, workingCopy) + } catch { + throw new ImportFailure('profile-unreadable', 'Could not read the Chrome database.') + } + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { + // Absent sidecars are normal: they only exist while a WAL is live. + await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {}) + } + return await queryCopy(workingCopy, query) + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/apps/desktop/src/main/browser-import/types.ts b/apps/desktop/src/main/browser-import/types.ts new file mode 100644 index 00000000000..e7bb5f2f96e --- /dev/null +++ b/apps/desktop/src/main/browser-import/types.ts @@ -0,0 +1,103 @@ +import type { BrowserImportError } from '@sim/desktop-bridge' +import type { BrowserSource } from '@/main/browser-import/browser-sources' + +/** + * Shapes internal to the Chrome importer. + * + * Nothing declared here may cross the preload bridge: cookie values, cookie + * names, and Chrome's host paths stay inside the Electron main process. Only + * the counts in `BrowserImportResult` are ever returned to a renderer. + */ + +/** One browser profile discovered on this device. */ +export interface BrowserProfile { + /** Namespaced `:` id, opaque to the renderer. */ + id: string + /** The profile directory, used to tell unnamed profiles apart. */ + directory: string + /** The name the user gave this profile, or empty when they never named it. */ + label: string + /** Which browser it belongs to, carrying that browser's Keychain item. */ + source: BrowserSource + /** That profile's cookie database, or null when it has none. Stays in main. */ + cookiesPath: string | null + /** That profile's saved-password database, or null when it has none. */ + loginDataPath: string | null + /** That profile's favicon store, or null when it has none. */ + faviconsPath: string | null + /** That profile's history, read only for the names sites go by. */ + historyPath: string | null +} + +/** One row of Chrome's `cookies` table, coerced to plain JS types. */ +export interface ChromiumCookieRow { + hostKey: string + name: string + path: string + expiresUtc: number + isSecure: boolean + isHttpOnly: boolean + hasExpires: boolean + isPersistent: boolean + sameSite: number +} + +/** + * A cookie ready for Electron's `cookies.set`. Field names and the `sameSite` + * union mirror `Electron.CookiesSetDetails` so the writer passes it straight + * through without a second translation step. + */ +export interface ImportableCookie { + url: string + name: string + value: string + /** Set only for domain cookies (a leading-dot `host_key`). */ + domain?: string + path: string + secure: boolean + httpOnly: boolean + /** Omitted for session cookies. */ + expirationDate?: number + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' +} + +/** + * Why a row was dropped. Aggregated into counts for local diagnostics — a skip + * reason is never paired with the cookie it came from, in a log or anywhere + * else. + */ +export type CookieSkipReason = 'decrypt-failed' | 'expired' | 'invalid-target' | 'empty' + +export type CookieSkipCounts = Record + +export function emptySkipCounts(): CookieSkipCounts { + return { 'decrypt-failed': 0, expired: 0, 'invalid-target': 0, empty: 0 } +} + +export function totalSkipped(counts: CookieSkipCounts): number { + return Object.values(counts).reduce((sum, count) => sum + count, 0) +} + +/** SQLite integers arrive as BigInt once BigInt reads are enabled. */ +export function toNumber(value: unknown): number { + if (typeof value === 'bigint') return Number(value) + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +export function toText(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * A failure carrying a category that is safe to hand to a renderer. The + * message stays in the main process for local logs; only `code` is exposed. + */ +export class ImportFailure extends Error { + constructor( + readonly code: BrowserImportError, + message: string + ) { + super(message) + this.name = 'ImportFailure' + } +} diff --git a/apps/desktop/src/main/browser-sites/directory.test.ts b/apps/desktop/src/main/browser-sites/directory.test.ts new file mode 100644 index 00000000000..af3c1919f4b --- /dev/null +++ b/apps/desktop/src/main/browser-sites/directory.test.ts @@ -0,0 +1,256 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SiteRecord } from '@/main/browser-sites/directory' + +vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) + +const { SiteDirectory } = await import('@/main/browser-sites/directory') + +/** + * The cap is module-private, so it is read back out of the source instead of + * copied here. A hardcoded `500` would keep passing on the one day the + * assertion matters — the day someone changes the cap. + */ +const MAX_SITES = Number( + /MAX_SITES = (\d+)/.exec(await readFile(new URL('directory.ts', import.meta.url), 'utf8'))?.[1] +) +if (!Number.isInteger(MAX_SITES)) throw new Error('could not read MAX_SITES out of directory.ts') + +/** Reversible stand-in for the OS keychain, so tests assert real round-trips. */ +const encryption = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`sealed:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').replace(/^sealed:/, ''), +} + +/** `count` distinct hosts numbered from `from`, each as used as `visits` says. */ +const sites = (count: number, visits: (index: number) => number, from = 0): SiteRecord[] => + Array.from({ length: count }, (_, offset) => ({ + hostname: `site-${String(from + offset).padStart(4, '0')}.example.com`, + visits: visits(from + offset), + })) + +let directory: string +let path: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'sim-site-directory-')) + path = join(directory, 'browser-sites.json') +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const open = () => new SiteDirectory(path, encryption) + +describe('SiteDirectory', () => { + it('remembers a site’s name and icon across restarts', async () => { + await open().remember([{ hostname: 'mail.google.com', name: 'Gmail', icon: 'data:png' }]) + + expect(await open().list()).toEqual([ + { hostname: 'mail.google.com', name: 'Gmail', icon: 'data:png' }, + ]) + }) + + it('refreshes a site that has been renamed', async () => { + const store = open() + await store.remember([{ hostname: 'x.com', name: 'Twitter' }]) + await store.remember([{ hostname: 'x.com', name: 'X' }]) + + expect(await store.list()).toEqual([{ hostname: 'x.com', name: 'X' }]) + }) + + it('keeps sites a later import says nothing about', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + await store.remember([{ hostname: 'linear.app', name: 'Linear' }]) + + expect((await store.list()).map((site) => site.hostname).sort()).toEqual([ + 'github.com', + 'linear.app', + ]) + }) + + it('keeps an existing icon when a later import only learns a name', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', icon: 'data:png' }]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await store.list()).toEqual([ + { hostname: 'github.com', name: 'GitHub', icon: 'data:png' }, + ]) + }) + + it('remembers how used a site was and when it was imported', async () => { + await open().remember([ + { + hostname: 'mail.google.com', + name: 'Gmail', + visits: 412, + importedAt: '2026-07-27T09:15:00.000Z', + }, + ]) + + expect(await open().list()).toEqual([ + { + hostname: 'mail.google.com', + name: 'Gmail', + visits: 412, + importedAt: '2026-07-27T09:15:00.000Z', + }, + ]) + }) + + it('keeps the busiest profile’s visit count when a host is imported twice', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + await store.remember([{ hostname: 'github.com', visits: 2 }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + }) + + it('raises a visit count when a later profile used the site more', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', visits: 2 }]) + await store.remember([{ hostname: 'github.com', visits: 300 }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', visits: 300 }]) + }) + + it('keeps a known visit count when a later import measures nothing', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', visits: 300 }]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub', visits: 300 }]) + }) + + it('stops at the cap when two imports together overflow it', async () => { + const store = open() + const perImport = Math.ceil(MAX_SITES * 0.6) + expect(perImport * 2).toBeGreaterThan(MAX_SITES) + + await store.remember(sites(perImport, () => 10)) + await store.remember(sites(perImport, () => 10, perImport)) + + expect(await store.list()).toHaveLength(MAX_SITES) + }) + + it('evicts the least-used sites and keeps the most-used ones', async () => { + const overflow = 20 + await open().remember(sites(MAX_SITES + overflow, (index) => index)) + + const kept = new Set((await open().list()).map((site) => site.hostname)) + expect(kept.size).toBe(MAX_SITES) + expect([...kept].sort()[0]).toBe(`site-${String(overflow).padStart(4, '0')}.example.com`) + expect(kept).toContain(`site-${String(MAX_SITES + overflow - 1).padStart(4, '0')}.example.com`) + expect(kept).not.toContain('site-0000.example.com') + expect(kept).not.toContain(`site-${String(overflow - 1).padStart(4, '0')}.example.com`) + }) + + it('evicts a site with no measured use before one with any', async () => { + // No usage evidence must not outrank measured usage: treating a missing + // count as infinitely used would evict a real site to keep this one. + await open().remember([{ hostname: 'unmeasured.example.com' }, ...sites(MAX_SITES, () => 1)]) + + const kept = (await open().list()).map((site) => site.hostname) + expect(kept).toHaveLength(MAX_SITES) + expect(kept).not.toContain('unmeasured.example.com') + expect(kept).toContain('site-0000.example.com') + }) + + it('evicts the same record no matter what order the records arrived in', async () => { + const tied: SiteRecord[] = [ + { hostname: 'tie-a.example.com', visits: 5, importedAt: '2026-01-01T00:00:00.000Z' }, + { hostname: 'tie-b.example.com', visits: 5, importedAt: '2026-02-01T00:00:00.000Z' }, + { hostname: 'tie-c.example.com', visits: 5, importedAt: '2026-02-01T00:00:00.000Z' }, + ] + const busier = sites(MAX_SITES - 1, () => 100) + + const survivors = await Promise.all( + [[...busier, ...tied], [...tied].reverse().concat(busier)].map(async (records, index) => { + const store = new SiteDirectory(join(directory, `order-${index}.json`), encryption) + await store.remember(records) + return (await store.list()) + .map((site) => site.hostname) + .filter((hostname) => hostname.startsWith('tie-')) + }) + ) + + // One slot, three equally used hosts: the newer import wins, and hostname + // settles what is left — `tie-b` and `tie-c` share an import time. + expect(survivors).toEqual([['tie-b.example.com'], ['tie-b.example.com']]) + }) + + it('never writes the site list in the clear', async () => { + await open().remember([{ hostname: 'mail.google.com', name: 'Gmail' }]) + + // Which sites someone uses is exactly what this file must not leak. + expect((await readFile(path)).toString('utf8')).not.toContain('mail.google.com') + }) + + it('stores nothing at all when the OS cannot encrypt', async () => { + const unavailable = new SiteDirectory(path, { + ...encryption, + isEncryptionAvailable: () => false, + }) + + await unavailable.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + expect(await unavailable.list()).toEqual([]) + await expect(readFile(path)).rejects.toThrow() + }) + + it('reads as empty rather than throwing on a corrupt file', async () => { + await writeFile(path, 'not json at all') + + expect(await open().list()).toEqual([]) + }) + + it('ignores a directory written by a future version', async () => { + await writeFile(path, JSON.stringify({ version: 99, payload: 'whatever' })) + + expect(await open().list()).toEqual([]) + }) + + it('drops a directory written before imported hosts became suggestions', async () => { + // Version 1 was seeded from imported cookie hosts — mostly ad and analytics + // origins — and those records only ever decorated a host the omnibox already + // had. Version 2 records are offered as suggestions in their own right, so + // carrying the old set forward would put exactly those origins in the + // dropdown. The payload below decrypts cleanly; it is discarded on meaning, + // not on damage. + const version1: SiteRecord[] = [{ hostname: 'doubleclick.net', name: 'DoubleClick' }] + await writeFile( + path, + JSON.stringify({ + version: 1, + payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), + }) + ) + + expect(await open().list()).toEqual([]) + }) + + it('skips an entry with no hostname to key it by', async () => { + await open().remember([{ hostname: '', name: 'Nowhere' }]) + + expect(await open().list()).toEqual([]) + }) + + it('forgets everything on clear', async () => { + const store = open() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + + await store.clear() + + expect(await store.list()).toEqual([]) + }) + + it('clears cleanly when there is nothing to clear', async () => { + await expect(open().clear()).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts new file mode 100644 index 00000000000..ba135bb2836 --- /dev/null +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -0,0 +1,197 @@ +import { readFile } from 'node:fs/promises' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' + +/** + * What the sites brought over from another browser are called, and what they + * look like. + * + * Sim's browser records no history, so it cannot learn on its own that + * `mail.google.com` is Gmail. This is the answer to that, captured once at + * import time from the browser that did know, for the hosts being imported + * anyway. It is a name and an icon per host — never a visit log, never a URL + * beyond the host itself. + * + * Encrypted with the same OS-backed key as the credential vault. The hostnames + * in here include ones only a saved password knows about, and those live in an + * encrypted vault today; writing them to a plaintext file beside it would + * quietly undo that. + */ + +/** + * Bumped when what a record MEANS changes, not merely when a field is added. + * + * Version 1 was seeded from the imported cookie hosts, which are mostly ad and + * analytics origins nobody navigated to. Those records only ever decorated a + * host the omnibox already had, so they were harmless; version 2 hosts are + * offered as suggestions in their own right, and carrying the old set forward + * would put exactly the origins this design rejects into the dropdown. `read` + * discards a foreign version, so the upgrade costs a re-import — the right + * trade for a cache of someone else's data that is now user-visible. + */ +const DIRECTORY_VERSION = 2 + +/** + * Hosts kept across all imports. Bounded because every record can carry an + * inline favicon, and the whole directory crosses IPC whenever the browser + * panel appears. Least-used is evicted first — see {@link evictExcess} for how + * ties settle. + */ +const MAX_SITES = 500 + +export interface SiteRecord { + hostname: string + /** What the source browser's page titles call this site. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from. Present only for + * hosts an import found in the source browser's history; absent for one known + * solely through a saved password. + * + * An aggregate, deliberately: no visit times, no URLs, no ordering of one + * visit against another. Enough to rank suggestions, not enough to + * reconstruct where someone went or when. + */ + visits?: number + /** + * When Sim imported this host — wall clock at import, never the source + * browser's own last-visit time, which would be the visit log this store + * exists to avoid keeping. + */ + importedAt?: string +} + +interface EncryptedDirectoryEnvelope { + version: number + payload: string +} + +function isSiteRecord(value: unknown): value is SiteRecord { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SiteRecord).hostname === 'string' && + (value as SiteRecord).hostname !== '' + ) +} + +function maxDefined(first: number | undefined, second: number | undefined): number | undefined { + if (first === undefined) return second + if (second === undefined) return first + return Math.max(first, second) +} + +/** + * Trims the directory to {@link MAX_SITES}, dropping the least-used first and + * breaking ties by most recent import, then alphabetically so the same inputs + * always evict the same records. + * + * A record with no visit count is a record with no evidence of use, so it goes + * first rather than last — that is the only reading under which eviction order + * cannot be gamed by a malformed entry. + */ +function evictExcess(records: SiteRecord[]): SiteRecord[] { + if (records.length <= MAX_SITES) return records + return [...records] + .sort( + (a, b) => + (b.visits ?? 0) - (a.visits ?? 0) || + (b.importedAt ?? '').localeCompare(a.importedAt ?? '') || + a.hostname.localeCompare(b.hostname) + ) + .slice(0, MAX_SITES) +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +export class SiteDirectory { + constructor( + private readonly filePath: string, + private readonly encryption: EncryptionProvider = safeStorage + ) {} + + /** + * Whether this device can store the directory at all. Without OS-backed + * encryption nothing is written, matching the vault: a site list is not + * worth leaving in plaintext for the next person on this machine. + */ + isAvailable(): boolean { + try { + return this.encryption.isEncryptionAvailable() + } catch { + return false + } + } + + private async read(): Promise { + if (!this.isAvailable()) return [] + try { + const raw = await readFile(this.filePath) + const envelope = JSON.parse(raw.toString('utf8')) as EncryptedDirectoryEnvelope + if (envelope.version !== DIRECTORY_VERSION) return [] + const decrypted = this.encryption.decryptString(Buffer.from(envelope.payload, 'base64')) + const records = JSON.parse(decrypted) as SiteRecord[] + // Per-record, not just per-array: everything downstream sorts and merges + // on `hostname`, so one entry without it is a TypeError in the middle of + // an import rather than a record that is quietly skipped. + return Array.isArray(records) ? records.filter(isSiteRecord) : [] + } catch { + // A missing, truncated, or foreign-keyed file reads as empty rather than + // taking the omnibox down with it. + return [] + } + } + + private async write(records: SiteRecord[]): Promise { + if (!this.isAvailable()) return false + const envelope: EncryptedDirectoryEnvelope = { + version: DIRECTORY_VERSION, + payload: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + } + await writeJsonFileAtomically(this.filePath, envelope) + return true + } + + async list(): Promise { + return this.read() + } + + /** + * Records what an import learned, keeping anything it did not. + * + * A re-import refreshes a site that has been renamed or re-iconed, but an + * entry the new import says nothing about is left alone rather than blanked: + * importing a second profile should add to what the browser knows, not + * strip the first profile's sites of their names. + */ + async remember(records: readonly SiteRecord[]): Promise { + if (records.length === 0 || !this.isAvailable()) return + const merged = new Map() + for (const existing of await this.read()) merged.set(existing.hostname, existing) + for (const incoming of records) { + if (!incoming.hostname) continue + const existing = merged.get(incoming.hostname) + merged.set(incoming.hostname, { + hostname: incoming.hostname, + name: incoming.name ?? existing?.name, + icon: incoming.icon ?? existing?.icon, + // The most-used of the profiles a host was seen in wins, so re-importing + // a profile that barely touches a site cannot demote it. + visits: maxDefined(incoming.visits, existing?.visits), + importedAt: incoming.importedAt ?? existing?.importedAt, + }) + } + await this.write(evictExcess([...merged.values()])) + } + + /** Forgets every site. Runs with the rest of the browser teardown. */ + async clear(): Promise { + await removeFileIfPresent(this.filePath) + } +} diff --git a/apps/desktop/src/main/browser-sites/index.ts b/apps/desktop/src/main/browser-sites/index.ts new file mode 100644 index 00000000000..65301f2718c --- /dev/null +++ b/apps/desktop/src/main/browser-sites/index.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { app } from 'electron' +import { SiteDirectory, type SiteRecord } from '@/main/browser-sites/directory' + +/** + * Composition root for the imported site directory: one store for the whole + * app, holding what each imported host is called and what it looks like. + */ + +let instance: SiteDirectory | null = null + +export function siteDirectory(): SiteDirectory { + if (!instance) { + instance = new SiteDirectory(join(app.getPath('userData'), 'browser-sites.json')) + } + return instance +} + +export function listSites(): Promise { + return siteDirectory().list() +} + +export function rememberSites(records: readonly SiteRecord[]): Promise { + return siteDirectory().remember(records) +} + +/** Runs with the rest of the browser teardown, so no site list outlives sign-out. */ +export function clearSites(): Promise { + return siteDirectory().clear() +} diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts new file mode 100644 index 00000000000..11133620b9e --- /dev/null +++ b/apps/desktop/src/main/config.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + APP_NAME_FOR_CHANNEL, + channelForOrigin, + createConfigStore, + DEFAULT_ORIGIN, + isSafeInternalPath, + partitionForOrigin, + validateOriginInput, +} from '@/main/config' + +function tempSettingsPath(): string { + return join(mkdtempSync(join(tmpdir(), 'sim-desktop-config-')), 'settings.json') +} + +describe('validateOriginInput', () => { + it('accepts https and normalizes to the origin', () => { + expect(validateOriginInput('https://sim.ai')).toEqual({ ok: true, origin: 'https://sim.ai' }) + expect(validateOriginInput(' https://sim.example.com/path?q=1 ')).toEqual({ + ok: true, + origin: 'https://sim.example.com', + }) + expect(validateOriginInput('https://sim.example.com:8443')).toEqual({ + ok: true, + origin: 'https://sim.example.com:8443', + }) + }) + + it('accepts http only for loopback hosts', () => { + expect(validateOriginInput('http://localhost:3000')).toEqual({ + ok: true, + origin: 'http://localhost:3000', + }) + expect(validateOriginInput('http://127.0.0.1:3000').ok).toBe(true) + expect(validateOriginInput('http://evil.example').ok).toBe(false) + }) + + it('rejects credentials, bad schemes, and garbage', () => { + expect(validateOriginInput('https://user:pass@sim.ai').ok).toBe(false) + expect(validateOriginInput('ftp://sim.ai').ok).toBe(false) + expect(validateOriginInput('sim.ai').ok).toBe(false) + expect(validateOriginInput('').ok).toBe(false) + }) +}) + +describe('partitionForOrigin', () => { + it('uses the canonical partition for the default origin', () => { + expect(partitionForOrigin(DEFAULT_ORIGIN)).toBe('persist:sim') + }) + + it('gives every other origin an isolated persistent partition', () => { + const partition = partitionForOrigin('https://self-hosted.example:8443') + expect(partition).toMatch(/^persist:sim-/) + expect(partition).not.toBe(partitionForOrigin('https://other.example')) + }) +}) + +describe('isSafeInternalPath', () => { + it('accepts absolute same-origin paths with query', () => { + expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true) + expect(isSafeInternalPath('/')).toBe(true) + }) + + it('rejects protocol-relative, backslash, absolute, and oversized values', () => { + expect(isSafeInternalPath('//evil.example')).toBe(false) + expect(isSafeInternalPath('/a\\evil')).toBe(false) + expect(isSafeInternalPath('https://evil.example/x')).toBe(false) + expect(isSafeInternalPath('workspace')).toBe(false) + expect(isSafeInternalPath('')).toBe(false) + expect(isSafeInternalPath(`/${'a'.repeat(2100)}`)).toBe(false) + expect(isSafeInternalPath(42)).toBe(false) + }) +}) + +describe('createConfigStore', () => { + it('round-trips settings through disk', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + store.set('zoomLevel', 1.5) + store.set('lastRoute', '/workspace/ws1') + // Writes coalesce; quitting flushes them. Without this the file on disk is + // still empty, which is the point of the debounce. + store.flush() + + const reloaded = createConfigStore(filePath, {}) + expect(reloaded.get('zoomLevel')).toBe(1.5) + expect(reloaded.get('lastRoute')).toBe('/workspace/ws1') + }) + + it('persists a validated origin and rejects invalid input', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + expect(store.setOrigin('https://self-hosted.example').ok).toBe(true) + expect(store.getOrigin()).toBe('https://self-hosted.example') + expect(store.setOrigin('http://evil.example').ok).toBe(false) + expect(store.getOrigin()).toBe('https://self-hosted.example') + + const reloaded = createConfigStore(filePath, {}) + expect(reloaded.getOrigin()).toBe('https://self-hosted.example') + }) + + it('recovers from a corrupted settings file', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, '{not json') + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) + + it('falls back to the default origin when the stored origin is invalid', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, JSON.stringify({ origin: 'http://evil.example' })) + const store = createConfigStore(filePath, {}) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) + + it('honors a valid SIM_DESKTOP_ORIGIN override without persisting it', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, { SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:4600' }) + expect(store.getOrigin()).toBe('http://127.0.0.1:4600') + store.set('zoomLevel', 1) + store.flush() + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe(DEFAULT_ORIGIN) + }) + + it('coalesces repeated writes and skips ones that change nothing', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + + // Reference equality can never hold for an array value, so this guard used + // to be dead for exactly the settings written most often — every browser + // navigation fell through to a synchronous whole-file write. + store.set('browserPinnedTabUrls', ['https://a.example/']) + store.flush() + const afterFirst = readFileSync(filePath, 'utf8') + + store.set('browserPinnedTabUrls', ['https://a.example/']) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(afterFirst) + + store.set('browserPinnedTabUrls', ['https://b.example/']) + store.flush() + expect(JSON.parse(readFileSync(filePath, 'utf8')).browserPinnedTabUrls).toEqual([ + 'https://b.example/', + ]) + }) + + it('writes a new origin immediately rather than debouncing it', () => { + // Changing the origin tears down and reloads, so a pending write could be + // lost on the way out — and losing it strands the app on the old server. + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + + store.setOrigin('https://sim.example.com') + + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://sim.example.com') + }) + + it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => { + const store = createConfigStore(tempSettingsPath(), { + SIM_DESKTOP_ORIGIN: 'http://evil.example', + }) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + }) +}) + +describe('channelForOrigin', () => { + it('maps each environment origin to its channel', () => { + expect(channelForOrigin('https://sim.ai')).toBe('prod') + expect(channelForOrigin('https://www.sim.ai')).toBe('prod') + expect(channelForOrigin('https://www.dev.sim.ai')).toBe('dev') + expect(channelForOrigin('https://dev.sim.ai')).toBe('dev') + expect(channelForOrigin('https://www.staging.sim.ai')).toBe('staging') + expect(channelForOrigin('http://localhost:3000')).toBe('local') + expect(channelForOrigin('http://127.0.0.1:3000')).toBe('local') + }) + + it('treats self-hosted and garbage origins as prod', () => { + expect(channelForOrigin('https://sim.mycompany.com')).toBe('prod') + expect(channelForOrigin('not a url')).toBe('prod') + }) + + it('gives every channel a distinct app identity, prod keeping the plain name', () => { + expect(APP_NAME_FOR_CHANNEL.prod).toBe('Sim') + const names = Object.values(APP_NAME_FOR_CHANNEL) + expect(new Set(names).size).toBe(names.length) + }) +}) diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 00000000000..7a0b0a5a8bd --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -0,0 +1,305 @@ +import { readFileSync } from 'node:fs' +import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' +import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' + +/** settings.json is meant to be readable when a user opens it. */ +const SETTINGS_INDENT = 2 + +const logger = createLogger('DesktopConfig') + +/** + * The server origin fresh installs point at. `scripts/build.ts` can bake an + * override in via SIM_DESKTOP_DEFAULT_ORIGIN (per-environment builds: dev, + * staging, localhost); official builds default to production. The esbuild + * define replaces the env read at bundle time, so a packaged app never + * consults the runtime environment for this. http is accepted only for + * loopback origins (the localhost dev-server channel). + */ +function isValidBakedOrigin(origin: string | undefined): origin is string { + if (!origin) return false + try { + const url = new URL(origin) + if (url.protocol === 'https:') return true + return url.protocol === 'http:' && isLoopbackHostname(url.hostname) + } catch { + return false + } +} + +export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT_ORIGIN) + ? process.env.SIM_DESKTOP_DEFAULT_ORIGIN + : 'https://sim.ai' + +/** + * The environment a build is keyed to, derived from its baked default origin. + * Channel drives the app's identity — its name and therefore its userData + * directory and single-instance lock — so one developer can keep a prod, + * staging, dev, and localhost install side by side, each with its own + * settings, sessions, and update feed. + */ +export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' + +export function channelForOrigin(origin: string): DesktopChannel { + try { + const host = new URL(origin).hostname.toLowerCase() + if (isLoopbackHostname(host)) return 'local' + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return 'dev' + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return 'staging' + return 'prod' + } catch { + return 'prod' + } +} + +/** + * Per-channel app names. Prod keeps the plain name every existing install + * already has (its userData must not move); the others are distinct apps. + */ +export const APP_NAME_FOR_CHANNEL: Record = { + prod: 'Sim', + staging: 'Sim Staging', + dev: 'Sim Dev', + local: 'Sim Local', +} + +export interface WindowBounds { + x?: number + y?: number + width: number + height: number +} + +export interface BrowserKnownSiteSetting { + hostname: string + lastVisitedAt: string + signInCompletedAt?: string +} + +export interface DesktopSettings { + origin: string + windowBounds?: WindowBounds + zoomLevel?: number + lastRoute?: string + themeBackground?: 'dark' | 'light' + blockThirdPartyAnalytics?: boolean + trayEnabled?: boolean + notificationsEnabled?: boolean + notificationSounds?: boolean + notificationsOnlyWhenUnfocused?: boolean + launchAtLogin?: boolean + autoDownloadUpdates?: boolean + browserEnabled?: boolean + terminalEnabled?: boolean + /** + * Where the agent terminal last was. A shell that always reopened in the + * home directory would drop the user back at square one every session, and + * `$HOME` is the worst possible working directory for tools that ask what + * they are allowed to touch. Restored on the next launch when it still + * exists. + */ + terminalCwd?: string + /** + * Top-level sites visited in the dedicated agent-browser profile. This is + * local inference metadata only; no cookies, credentials, or account data + * are persisted here. + */ + browserKnownSites?: BrowserKnownSiteSetting[] + /** + * URLs of user-pinned agent-browser tabs, in pinned-strip order. Pinned + * pages are restored locally when the browser resource is opened again. + */ + browserPinnedTabUrls?: string[] +} + +export type OriginValidation = { ok: true; origin: string } | { ok: false; error: string } + +/** + * Validates a user-supplied server origin. HTTPS is required except for + * localhost, which may use HTTP for local development and self-host testing. + * Returns the normalized origin (scheme + host + port, no path). + */ +export function validateOriginInput(raw: string): OriginValidation { + const trimmed = raw.trim() + if (!trimmed) { + return { ok: false, error: 'Server URL is required' } + } + let url: URL + try { + url = new URL(trimmed) + } catch { + return { ok: false, error: 'Enter a full URL, like https://sim.example.com' } + } + if (url.username || url.password) { + return { ok: false, error: 'Server URL must not contain credentials' } + } + if (url.protocol === 'https:') { + return { ok: true, origin: url.origin } + } + if (url.protocol === 'http:' && isLoopbackHostname(url.hostname)) { + return { ok: true, origin: url.origin } + } + return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' } +} + +/** + * Maps a server origin to its cookie/storage partition. Each origin gets an + * isolated persistent partition so sessions never leak across instances. + */ +export function partitionForOrigin(origin: string): string { + if (origin === DEFAULT_ORIGIN) { + return 'persist:sim' + } + return `persist:sim-${encodeURIComponent(origin)}` +} + +/** + * Validates that a value is a same-origin absolute path suitable for reload + * targets and returnTo handoffs (single leading slash, no scheme or host). + */ +export function isSafeInternalPath(path: unknown): path is string { + if (typeof path !== 'string' || path.length === 0 || path.length > 2048) { + return false + } + if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) { + return false + } + try { + const url = new URL(path, 'https://internal.invalid') + return url.origin === 'https://internal.invalid' + } catch { + return false + } +} + +const DEFAULT_SETTINGS: DesktopSettings = { + origin: DEFAULT_ORIGIN, + blockThirdPartyAnalytics: true, + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: true, + terminalEnabled: true, +} + +export interface ConfigStore { + readonly filePath: string + getOrigin(): string + setOrigin(origin: string): OriginValidation + get(key: K): DesktopSettings[K] + set(key: K, value: DesktopSettings[K]): void + /** Writes any debounced change immediately. Called on quit. */ + flush(): void +} + +/** + * How long settings changes coalesce before hitting disk. Long enough to + * absorb a burst (a resize drag, a run of navigations), short enough that a + * crash loses nothing a user would notice. + */ +const SAVE_DEBOUNCE_MS = 400 + +/** + * Whether a settings write is a no-op. Identity first, then structurally, + * because the array- and object-valued settings are rebuilt + * on every write and would never be reference-equal. + */ +function isSameSetting(current: unknown, next: unknown): boolean { + if (current === next) return true + return JSON.stringify(current) === JSON.stringify(next) +} + +/** + * Creates the desktop settings store backed by a single JSON file. Writes are + * atomic (temp file + rename) so a crash mid-write never corrupts settings. + * The SIM_DESKTOP_ORIGIN environment variable overrides the stored origin, + * which the e2e harness uses to point the app at a fixture server. + */ +export function createConfigStore( + filePath: string, + env: NodeJS.ProcessEnv = process.env +): ConfigStore { + let settings: DesktopSettings = { ...DEFAULT_SETTINGS } + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial + settings = { ...DEFAULT_SETTINGS, ...parsed } + const validated = validateOriginInput(settings.origin) + settings.origin = validated.ok ? validated.origin : DEFAULT_ORIGIN + } catch { + settings = { ...DEFAULT_SETTINGS } + } + + const envOverride = env.SIM_DESKTOP_ORIGIN ? validateOriginInput(env.SIM_DESKTOP_ORIGIN) : null + if (env.SIM_DESKTOP_ORIGIN && !envOverride?.ok) { + logger.warn('Ignoring invalid SIM_DESKTOP_ORIGIN override', { value: env.SIM_DESKTOP_ORIGIN }) + } + + let saveTimer: ReturnType | null = null + + /** Writes the whole file now and cancels any pending debounced write. */ + const writeNow = () => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = null + try { + writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + } catch (error) { + logger.error('Failed to persist desktop settings', { error }) + } + } + + /** + * Coalesces writes. `save` is a synchronous mkdir + write + rename of the + * whole settings file on the main thread, and the callers are not rare: the + * known-sites list is rewritten on browser navigation and sign-in, pinned + * tabs on every pin change, window bounds on every resize step. Debouncing + * here rather than per-caller is what makes that safe by default — two + * callers had already hand-rolled their own timers, and the ones that had + * not were paying a full fsync per event. + */ + const save = () => { + if (saveTimer) return + saveTimer = setTimeout(writeNow, SAVE_DEBOUNCE_MS) + saveTimer.unref?.() + } + + return { + filePath, + getOrigin() { + if (envOverride?.ok) { + return envOverride.origin + } + return settings.origin + }, + setOrigin(raw: string) { + const validated = validateOriginInput(raw) + if (validated.ok) { + settings.origin = validated.origin + // Not debounced: changing the origin tears the session down and + // reloads, so a pending write could be lost on the way out — and this + // is the one setting whose loss strands the app on the wrong server. + writeNow() + } + return validated + }, + get(key) { + return settings[key] + }, + set(key, value) { + // Structural, not `===`. Reference equality can never hold for the array + // values this store carries (known sites, pinned tabs, window bounds are + // all freshly built), so the guard was dead for exactly the keys written + // most often — every one of them fell through to a write. + if (isSameSetting(settings[key], value)) { + return + } + settings[key] = value + save() + }, + flush() { + if (!saveTimer) return + writeNow() + }, + } +} diff --git a/apps/desktop/src/main/context-menu.test.ts b/apps/desktop/src/main/context-menu.test.ts new file mode 100644 index 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/csp.test.ts b/apps/desktop/src/main/csp.test.ts new file mode 100644 index 00000000000..1319ca314fb --- /dev/null +++ b/apps/desktop/src/main/csp.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { attachCspFallback, DEFAULT_DESKTOP_CSP } from '@/main/csp' + +type HeadersReceivedHandler = ( + details: { + url: string + resourceType: string + responseHeaders?: Record + }, + callback: (response: { responseHeaders?: Record }) => void +) => void + +function fakeSession() { + let handler: HeadersReceivedHandler | undefined + const ses = { + webRequest: { + onHeadersReceived: vi.fn((h: HeadersReceivedHandler) => { + handler = h + }), + }, + } + return { ses, run: () => handler } +} + +const APP_ORIGIN = 'https://sim.ai' + +describe('attachCspFallback', () => { + let session: ReturnType + + beforeEach(() => { + session = fakeSession() + attachCspFallback( + session.ses as unknown as Parameters[0], + () => APP_ORIGIN + ) + }) + + it('injects the fallback CSP on an app-origin document lacking one', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/workspace`, resourceType: 'mainFrame', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({ + responseHeaders: { 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + }) + + it('never overrides a server-sent CSP', () => { + const cb = vi.fn() + session.run()?.( + { + url: `${APP_ORIGIN}/workspace`, + resourceType: 'mainFrame', + responseHeaders: { 'content-security-policy': ["default-src 'self'"] }, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves subresources untouched', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/app.js`, resourceType: 'script', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves non-app-origin documents untouched', () => { + const cb = vi.fn() + session.run()?.( + { + url: 'https://accounts.google.com/o/oauth2', + resourceType: 'mainFrame', + responseHeaders: {}, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) +}) diff --git a/apps/desktop/src/main/csp.ts b/apps/desktop/src/main/csp.ts new file mode 100644 index 00000000000..5d956f21e26 --- /dev/null +++ b/apps/desktop/src/main/csp.ts @@ -0,0 +1,47 @@ +import { createLogger } from '@sim/logger' +import type { Session } from 'electron' +import { isAppOrigin } from '@/main/navigation' + +const logger = createLogger('DesktopCsp') + +/** + * A minimal, non-drifting Content-Security-Policy applied ONLY to an app-origin + * top-level document whose response ships no CSP of its own. + * + * The hosted web app sends a full, env-aware policy on every response (see + * `apps/sim/lib/core/security/csp.ts`), which the shell can neither import + * (monorepo boundary) nor safely duplicate (it varies by env). This is a + * defense-in-depth backstop for the narrow case where that header is somehow + * absent — a deliberately small subset of the server's own base directives, so + * it can never be stricter than what the app already depends on and cannot + * break embeds or integrations. + */ +export const DEFAULT_DESKTOP_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + +function hasCspHeader(headers: Record): boolean { + return Object.keys(headers).some((key) => key.toLowerCase() === 'content-security-policy') +} + +/** + * Installs the CSP fallback on a session. Runs on `onHeadersReceived` (a + * distinct event from telemetry-policy's `onBeforeRequest`, so the two coexist) + * and leaves every response untouched except an app-origin main-frame document + * that carries no CSP, which gets {@link DEFAULT_DESKTOP_CSP}. + */ +export function attachCspFallback(ses: Session, appOrigin: () => string): void { + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = details.responseHeaders ?? {} + if ( + details.resourceType === 'mainFrame' && + isAppOrigin(details.url, appOrigin()) && + !hasCspHeader(headers) + ) { + logger.info('Injecting fallback CSP for app document without one') + callback({ + responseHeaders: { ...headers, 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + return + } + callback({}) + }) +} diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts new file mode 100644 index 00000000000..f94fb132192 --- /dev/null +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { app, BrowserWindow } from 'electron' +import { createConfigStore } from '@/main/config' +import { createDesktopSettingsService } from '@/main/desktop-settings' +import { Notification } from '@/test/electron-mock' + +function makeService() { + const config = createConfigStore( + join(mkdtempSync(join(tmpdir(), 'sim-desktop-settings-')), 'settings.json'), + {} + ) + const window = new BrowserWindow() + const openMainWindowAt = vi.fn() + const setAutoDownloadUpdates = vi.fn() + const setTrayEnabled = vi.fn() + const setBrowserEnabled = vi.fn() + const setTerminalEnabled = vi.fn() + const service = createDesktopSettingsService({ + config, + getMainWindow: () => window, + openMainWindowAt, + setAutoDownloadUpdates, + setTrayEnabled, + setBrowserEnabled, + setTerminalEnabled, + }) + return { + config, + window, + openMainWindowAt, + setAutoDownloadUpdates, + setTrayEnabled, + setBrowserEnabled, + setTerminalEnabled, + service, + } +} + +describe('desktop settings service', () => { + beforeEach(() => { + Notification.instances.length = 0 + Notification.isSupported.mockReturnValue(true) + vi.mocked(app.setLoginItemSettings).mockClear() + Object.defineProperty(app, 'isPackaged', { configurable: true, value: false }) + }) + + it('persists preferences and applies live updater changes', () => { + const { config, service, setAutoDownloadUpdates } = makeService() + expect(service.getPreferences()).toMatchObject({ + notificationsEnabled: true, + notificationsOnlyWhenUnfocused: true, + autoDownloadUpdates: true, + trayEnabled: true, + }) + + service.setPreference('autoDownloadUpdates', false) + expect(config.get('autoDownloadUpdates')).toBe(false) + expect(setAutoDownloadUpdates).toHaveBeenCalledWith(false) + }) + + it('persists tray visibility and applies it immediately', () => { + const { config, service, setTrayEnabled } = makeService() + service.setPreference('trayEnabled', false) + expect(config.get('trayEnabled')).toBe(false) + expect(setTrayEnabled).toHaveBeenCalledWith(false) + expect(service.getPreferences().trayEnabled).toBe(false) + }) + + it('tears down the browser and terminal when their surfaces are switched off', () => { + const { config, service, setBrowserEnabled, setTerminalEnabled } = makeService() + expect(service.getPreferences()).toMatchObject({ + browserEnabled: true, + terminalEnabled: true, + }) + + service.setPreference('browserEnabled', false) + service.setPreference('terminalEnabled', false) + expect(config.get('browserEnabled')).toBe(false) + expect(config.get('terminalEnabled')).toBe(false) + expect(setBrowserEnabled).toHaveBeenCalledWith(false) + expect(setTerminalEnabled).toHaveBeenCalledWith(false) + }) + + it('applies login-item changes only for packaged builds', () => { + const { service } = makeService() + service.setPreference('launchAtLogin', true) + expect(app.setLoginItemSettings).not.toHaveBeenCalled() + + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + service.setPreference('launchAtLogin', false) + expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false }) + }) + + it('shows notifications only when allowed and opens their route on click', () => { + const { window, openMainWindowAt, service } = makeService() + vi.mocked(window.isFocused).mockReturnValue(true) + expect(service.notify({ title: 'Done', body: 'Ready' })).toBe(false) + + vi.mocked(window.isFocused).mockReturnValue(false) + expect( + service.notify({ + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + ).toBe(true) + + const notification = Notification.instances[0] + expect(notification.options).toMatchObject({ silent: false }) + expect(notification.show).toHaveBeenCalled() + const click = notification.on.mock.calls.find(([event]) => event === 'click')?.[1] + expect(click).toBeTypeOf('function') + ;(click as () => void)() + expect(openMainWindowAt).toHaveBeenCalledWith('/workspace/ws1/chat/c1') + }) +}) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts new file mode 100644 index 00000000000..abc6327d29f --- /dev/null +++ b/apps/desktop/src/main/desktop-settings.ts @@ -0,0 +1,147 @@ +import type { + DesktopNotificationPayload, + DesktopPreferenceKey, + DesktopPreferences, +} from '@sim/desktop-bridge' +import type { BrowserWindow } from 'electron' +import { app, Notification } from 'electron' +import type { ConfigStore } from '@/main/config' +import { isSafeInternalPath } from '@/main/config' + +/** + * Every key the shell accepts over the settings IPC channel: the closed + * `setPreference` union plus preferences added after the first release, which + * ride their own optional bridge setters but share this channel. + */ +export type DesktopSettingKey = + | DesktopPreferenceKey + | 'trayEnabled' + | 'browserEnabled' + | 'terminalEnabled' + +const PREFERENCE_KEYS: ReadonlySet = new Set([ + 'notificationsEnabled', + 'notificationSounds', + 'notificationsOnlyWhenUnfocused', + 'launchAtLogin', + 'autoDownloadUpdates', + 'trayEnabled', + 'browserEnabled', + 'terminalEnabled', +]) + +export function isDesktopPreferenceKey(value: unknown): value is DesktopSettingKey { + return typeof value === 'string' && PREFERENCE_KEYS.has(value) +} + +export interface DesktopSettingsService { + getPreferences(): DesktopPreferences + setPreference(key: DesktopSettingKey, value: boolean): DesktopPreferences + notify(payload: DesktopNotificationPayload): boolean + applySystemPreferences(): void +} + +interface DesktopSettingsServiceDeps { + config: ConfigStore + getMainWindow: () => BrowserWindow | null + openMainWindowAt: (route?: string) => void + setAutoDownloadUpdates: (enabled: boolean) => void + /** Installs or tears down the menu-bar status item immediately. */ + setTrayEnabled: (enabled: boolean) => void + /** Ends the running agent-browser session when the surface is turned off. */ + setBrowserEnabled: (enabled: boolean) => void + /** Ends every open agent shell when the surface is turned off. */ + setTerminalEnabled: (enabled: boolean) => void +} + +function readPreferences(config: ConfigStore): DesktopPreferences { + return { + notificationsEnabled: config.get('notificationsEnabled') ?? true, + notificationSounds: config.get('notificationSounds') ?? true, + notificationsOnlyWhenUnfocused: config.get('notificationsOnlyWhenUnfocused') ?? true, + launchAtLogin: config.get('launchAtLogin') ?? false, + autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true, + trayEnabled: config.get('trayEnabled') ?? true, + browserEnabled: config.get('browserEnabled') ?? true, + terminalEnabled: config.get('terminalEnabled') ?? true, + } +} + +/** + * Owns device preferences and their native side effects. Renderer code can + * request a change, but only this main-process service touches login items, + * updater policy, window focus, or OS notifications. + */ +export function createDesktopSettingsService( + deps: DesktopSettingsServiceDeps +): DesktopSettingsService { + const applyLaunchAtLogin = (enabled: boolean) => { + // Registering an unpackaged Electron binary at login is surprising and + // points at the wrong executable. Persist the dev preference, then apply + // it when the packaged app starts. + if (app.isPackaged) { + app.setLoginItemSettings({ openAtLogin: enabled }) + } + } + + return { + getPreferences: () => readPreferences(deps.config), + setPreference(key, value) { + deps.config.set(key, value) + // Not debounced. Every branch below takes effect immediately, and + // `launchAtLogin` writes state the OS keeps after we exit — so a kill + // inside the debounce window would leave the login item registered + // while the switch that registered it reads off, and toggling it back + // on would be a no-op the store considers unchanged. + deps.config.flush() + switch (key) { + case 'launchAtLogin': + applyLaunchAtLogin(value) + break + case 'autoDownloadUpdates': + deps.setAutoDownloadUpdates(value) + break + case 'trayEnabled': + deps.setTrayEnabled(value) + break + case 'browserEnabled': + deps.setBrowserEnabled(value) + break + case 'terminalEnabled': + deps.setTerminalEnabled(value) + break + default: + break + } + return readPreferences(deps.config) + }, + notify(payload) { + const preferences = readPreferences(deps.config) + if (!preferences.notificationsEnabled || !Notification.isSupported()) { + return false + } + const window = deps.getMainWindow() + if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) { + return false + } + + const notification = new Notification({ + title: payload.title, + body: payload.body, + silent: !preferences.notificationSounds, + }) + notification.on('click', () => { + deps.openMainWindowAt( + payload.route && isSafeInternalPath(payload.route) ? payload.route : undefined + ) + }) + notification.show() + return true + }, + applySystemPreferences() { + const preferences = readPreferences(deps.config) + applyLaunchAtLogin(preferences.launchAtLogin) + deps.setAutoDownloadUpdates(preferences.autoDownloadUpdates) + }, + } +} diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts new file mode 100644 index 00000000000..ebb670946e8 Binary files /dev/null and b/apps/desktop/src/main/downloads.test.ts differ 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..3682c8eeca0 --- /dev/null +++ b/apps/desktop/src/main/handoff.test.ts @@ -0,0 +1,272 @@ +import { get as httpGet } from 'node:http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + buildRedeemScript, + type ConnectHandoffCallback, + createHandoffManager, + type HandoffCallback, + type HandoffCallbacks, + type HandoffManagerDeps, +} from '@/main/handoff' +import type { EventRecorder } from '@/main/observability' + +const VALID_STATE = 'a'.repeat(32) +const VALID_TOKEN = 'tok_1234567890abcdef' + +function makeEvents(): EventRecorder { + return { filePath: '/tmp/none', record: vi.fn() } +} + +function makeDeps(overrides: Partial = {}): HandoffManagerDeps { + return { + origin: () => 'https://sim.ai', + openExternal: vi.fn(async () => true), + events: makeEvents(), + currentUserId: vi.fn(async () => 'user-1'), + ...overrides, + } +} + +function makeCallbacks(overrides: Partial = {}): HandoffCallbacks { + return { onLogin: () => {}, onConnect: () => {}, ...overrides } +} + +describe('buildRedeemScript', () => { + it('embeds the token JSON-escaped, targets the verify endpoint, and returns the status', () => { + const script = buildRedeemScript('abc"def') + expect(script).toContain('/api/auth/one-time-token/verify') + expect(script).toContain("credentials: 'include'") + expect(script).toContain(JSON.stringify(JSON.stringify({ token: 'abc"def' }))) + expect(script).toContain('return response.status') + }) +}) + +describe('createHandoffManager', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('begin opens the landing page with state and the loopback port', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + const opened = await manager.begin() + expect(opened).toBe(true) + + const openExternal = vi.mocked(deps.openExternal) + expect(openExternal).toHaveBeenCalledTimes(1) + const landing = new URL(openExternal.mock.calls[0][0]) + expect(landing.origin).toBe('https://sim.ai') + expect(landing.pathname).toBe('/desktop/auth') + expect(landing.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{32}$/) + expect(Number(landing.searchParams.get('port'))).toBeGreaterThan(0) + manager.clear() + }) + + it('consume is single-use, state-bound, and TTL-bound', async () => { + let nowValue = 1_000_000 + const deps = makeDeps({ now: () => nowValue }) + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.begin() + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + + expect(manager.consume('z'.repeat(32), 'login')).toBe(false) + expect(manager.consume(state, 'login')).toBe(true) + expect(manager.consume(state, 'login')).toBe(false) + + await manager.begin() + const secondState = new URL(vi.mocked(deps.openExternal).mock.calls[1][0]).searchParams.get( + 'state' + ) as string + nowValue += 31 * 60 * 1000 + expect(manager.consume(secondState, 'login')).toBe(false) + manager.clear() + }) + + it('loopback accepts one valid callback, rejects bad input, then closes', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const port = landing.searchParams.get('port') as string + const state = landing.searchParams.get('state') as string + const base = `http://127.0.0.1:${port}` + + expect((await fetch(`${base}/other`)).status).toBe(404) + + const badToken = await fetch(`${base}/auth/callback?token=bad token&state=${state}`) + expect(badToken.status).toBe(400) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { + redirect: 'manual', + }) + // Hands the browser back to a real app page rather than serving HTML from + // the main process, so the closing screen matches the rest of Sim. + expect(ok.status).toBe(302) + expect(ok.headers.get('location')).toBe('https://sim.ai/desktop/done?kind=auth') + expect(received).toEqual([{ token: VALID_TOKEN, state }]) + + await expect( + fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { redirect: 'manual' }) + ).rejects.toThrow() + }) + + it('rejects a wrong-state callback without killing the sign-in', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const base = `http://127.0.0.1:${landing.searchParams.get('port')}` + const state = landing.searchParams.get('state') as string + + // This port is reachable by any local process, and by any page the user + // has open via a no-CORS GET. Tearing the one-shot server down before + // checking the state let any of them cancel the sign-in. + const wrongState = await fetch( + `${base}/auth/callback?token=${VALID_TOKEN}&state=${'z'.repeat(32)}` + ) + expect(wrongState.status).toBe(403) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`, { + redirect: 'manual', + }) + expect(ok.status).toBe(302) + expect(received).toEqual([{ token: VALID_TOKEN, state }]) + }) + + it('refuses a request that does not address the loopback by name', async () => { + const received: HandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onLogin: (callback) => received.push(callback) }) + ) + await manager.begin() + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const state = landing.searchParams.get('state') as string + + // The DNS-rebinding shape: an attacker hostname resolving to 127.0.0.1. + const status = await new Promise((resolvePromise, rejectPromise) => { + const request = httpGet( + { + host: '127.0.0.1', + port: Number(landing.searchParams.get('port')), + path: `/auth/callback?token=${VALID_TOKEN}&state=${state}`, + headers: { Host: 'attacker.example' }, + }, + (response) => { + response.resume() + resolvePromise(response.statusCode ?? 0) + } + ) + request.on('error', rejectPromise) + }) + + expect(status).toBe(403) + expect(received).toHaveLength(0) + manager.clear() + }) + + it('cleans up the pending handoff when the browser cannot be opened', async () => { + const deps = makeDeps({ openExternal: vi.fn(async () => false) }) + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.begin() + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + expect(manager.consume(state, 'login')).toBe(false) + }) + it('beginConnect opens /desktop/connect with provider, state, and port', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + expect(await manager.beginConnect('not a provider!')).toBe(false) + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.pathname).toBe('/desktop/connect') + expect(landing.searchParams.get('provider')).toBe('google-email') + expect(landing.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{32}$/) + expect(Number(landing.searchParams.get('port'))).toBeGreaterThan(0) + manager.clear() + }) + + it('connect loopback forwards state and optional error, rejecting bad slugs', async () => { + const received: ConnectHandoffCallback[] = [] + const deps = makeDeps() + const manager = createHandoffManager( + deps, + makeCallbacks({ onConnect: (callback) => received.push(callback) }) + ) + await manager.beginConnect('google-email') + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const base = `http://127.0.0.1:${landing.searchParams.get('port')}` + const state = landing.searchParams.get('state') as string + + const badError = await fetch(`${base}/connect/callback?state=${state}&error=${'x'.repeat(80)}`) + expect(badError.status).toBe(400) + expect(received).toHaveLength(0) + + const ok = await fetch(`${base}/connect/callback?state=${state}&error=oauth_failed`, { + redirect: 'manual', + }) + expect(ok.status).toBe(302) + expect(ok.headers.get('location')).toBe('https://sim.ai/desktop/done?kind=connect') + expect(received).toEqual([{ state, error: 'oauth_failed' }]) + expect(manager.consume(state, 'connect')).toBe(true) + }) + + it('consume enforces the handoff kind', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + await manager.beginConnect('google-email') + const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( + 'state' + ) as string + expect(manager.consume(state, 'login')).toBe(false) + expect(manager.consume(state, 'connect')).toBe(true) + }) +}) + +describe('connect handoff account pinning', () => { + it('pins the connect flow to the account the app is signed in as', async () => { + // The OAuth flow runs in the browser under the BROWSER's session, which is + // a different row from the app's — without this the credential would attach + // to whichever account the browser happens to be signed into. + const deps = makeDeps({ currentUserId: vi.fn(async () => 'desktop-user') }) + const manager = createHandoffManager(deps, makeCallbacks()) + + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.pathname).toBe('/desktop/connect') + expect(landing.searchParams.get('user')).toBe('desktop-user') + manager.clear() + }) + + it('omits the pin when the app account cannot be read', async () => { + // Offline or signed out: fall back to the page's own login redirect rather + // than blocking a connect on a failed probe. + const deps = makeDeps({ currentUserId: vi.fn(async () => null) }) + const manager = createHandoffManager(deps, makeCallbacks()) + + expect(await manager.beginConnect('google-email')).toBe(true) + + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.searchParams.has('user')).toBe(false) + manager.clear() + }) +}) diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts new file mode 100644 index 00000000000..add823f9a99 --- /dev/null +++ b/apps/desktop/src/main/handoff.ts @@ -0,0 +1,469 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { generateShortId } from '@sim/utils/id' +import type { BrowserWindow } from 'electron' +import { app, dialog } from 'electron' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopHandoff') + +const TOKEN_PATTERN = /^[A-Za-z0-9_.-]{8,512}$/ +const STATE_PATTERN = /^[A-Za-z0-9_-]{16,256}$/ +const STATE_LENGTH = 32 +const REDEEM_PATH = '/api/auth/one-time-token/verify' +const CALLBACK_PATH = '/auth/callback' +const CONNECT_CALLBACK_PATH = '/connect/callback' +/** OAuth providerIds are kebab-case service slugs (e.g. "google-email"). */ +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/ +/** OAuth error codes forwarded by the connect complete page. */ +const ERROR_SLUG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/ +// Measured from begin() (when the browser opens) so it comfortably covers a +// full interactive login — email/OTP round-trips or OAuth consent — not just +// the redirect back. Bounds how long the loopback listener and the CSRF state +// stay valid. +const HANDOFF_TTL_MS = 30 * 60 * 1000 + +/** + * Where the browser lands once the loopback has taken the callback. Redirecting + * to a real app page — rather than serving HTML from here — keeps the closing + * screen on Sim's design system instead of a page hand-rolled in the main + * process. Purely informational: the handoff has already completed by then. + */ +const DONE_PATH = '/desktop/done' + +export type HandoffKind = 'login' | 'connect' + +export interface HandoffCallback { + token: string + state: string +} + +export interface ConnectHandoffCallback { + state: string + error?: string +} + +export interface HandoffCallbacks { + onLogin: (callback: HandoffCallback) => void + onConnect: (callback: ConnectHandoffCallback) => void +} + +export interface HandoffManagerDeps { + origin: () => string + openExternal: (url: string) => Promise + events: EventRecorder + /** + * The account the app is signed in as, used to pin an OAuth connect to it. + * See {@link HandoffManager.beginConnect}. + */ + currentUserId: () => Promise + now?: () => number +} + +/** Optional scope a chip-initiated connect carries into /desktop/connect. */ +export interface ConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface HandoffManager { + begin(): Promise + beginConnect(providerId: string, scope?: ConnectScope): Promise + consume(state: string, kind: HandoffKind): boolean + clear(): void +} + +/** + * Owns the system-browser handoffs — login and OAuth connect. The only + * callback channel is a one-shot 127.0.0.1 loopback server (RFC 8252 §7.3) — + * no OS scheme registration, works identically in dev and packaged builds. + * Because the app is always running when the browser redirects back (it + * started the loopback), the pending state lives in memory: single-flight, + * single-use, constant-time compared, TTL-bounded. Starting a new handoff of + * either kind supersedes the previous pending one. + */ +export function createHandoffManager( + deps: HandoffManagerDeps, + callbacks: HandoffCallbacks +): HandoffManager { + const now = deps.now ?? Date.now + let loopbackServer: Server | null = null + let loopbackTimer: NodeJS.Timeout | undefined + let pending: { state: string; createdAt: number; kind: HandoffKind } | null = null + + const stopLoopback = () => { + clearTimeout(loopbackTimer) + loopbackTimer = undefined + if (loopbackServer) { + loopbackServer.close() + loopbackServer = null + } + } + + /** + * The loopback route table: each hand-back kind declares its path, the + * "return to the app" page, and a parser that validates the query params + * and returns the callback dispatch (or null → 400). Adding a handoff kind + * is one new row. + */ + interface LoopbackRoute { + kind: HandoffKind + parse: (url: URL) => { state: string; dispatch: () => void } | null + } + const routes: Record = { + [CALLBACK_PATH]: { + kind: 'login', + parse: (url) => { + const token = url.searchParams.get('token') ?? '' + const state = url.searchParams.get('state') ?? '' + if (!TOKEN_PATTERN.test(token) || !STATE_PATTERN.test(state)) { + return null + } + return { state, dispatch: () => callbacks.onLogin({ token, state }) } + }, + }, + [CONNECT_CALLBACK_PATH]: { + kind: 'connect', + parse: (url) => { + const state = url.searchParams.get('state') ?? '' + const error = url.searchParams.get('error') + if (!STATE_PATTERN.test(state) || (error !== null && !ERROR_SLUG_PATTERN.test(error))) { + return null + } + return { + state, + dispatch: () => callbacks.onConnect({ state, ...(error !== null ? { error } : {}) }), + } + }, + }, + } + + /** + * Non-consuming state check, so a caller that does not know the state cannot + * shut the loopback down. The authoritative single-use consume still happens + * in the callback. + */ + const matchesPending = (state: string): boolean => + pending !== null && + now() - pending.createdAt <= HANDOFF_TTL_MS && + safeCompare(pending.state, state) + + /** + * The listener is reachable by any local process, and by any web page the + * user has open via a no-CORS GET. Requiring a loopback Host blocks the + * DNS-rebinding shape, where an attacker's hostname resolves to 127.0.0.1. + */ + const isLoopbackHost = (host: string | undefined): boolean => { + const hostname = (host ?? '').replace(/:\d+$/, '') + return hostname === '127.0.0.1' || hostname === 'localhost' + } + + const startLoopback = async (): Promise => { + stopLoopback() + const server = createServer((request, response) => { + if (!isLoopbackHost(request.headers.host)) { + response.writeHead(403, { 'Content-Type': 'text/plain' }).end('Forbidden') + return + } + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + const route = request.method === 'GET' ? routes[url.pathname] : undefined + if (!route) { + response.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found') + return + } + const callback = route.parse(url) + if (!callback) { + response.writeHead(400, { 'Content-Type': 'text/plain' }).end('Invalid request') + return + } + // Check the state BEFORE tearing anything down. Previously any request + // with a well-formed state killed this one-shot server, so a local + // process — or any page the user had open — could cancel a sign-in it + // could not otherwise touch. + if (!matchesPending(callback.state)) { + response.writeHead(403, { 'Content-Type': 'text/plain' }).end('Forbidden') + return + } + const done = new URL(DONE_PATH, deps.origin()) + done.searchParams.set('kind', route.kind === 'login' ? 'auth' : 'connect') + response.writeHead(302, { Location: done.toString() }).end() + stopLoopback() + callback.dispatch() + }) + loopbackServer = server + try { + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise) + server.listen(0, '127.0.0.1', () => resolvePromise()) + }) + } catch (error) { + logger.error('Could not start the loopback server', { error }) + loopbackServer = null + return undefined + } + loopbackTimer = setTimeout(stopLoopback, HANDOFF_TTL_MS) + const address = server.address() + return typeof address === 'object' && address ? address.port : undefined + } + + const clear = () => { + stopLoopback() + pending = null + } + + const beginFlow = async ( + kind: HandoffKind, + landingPath: string, + params: Record + ): Promise => { + const state = generateShortId(STATE_LENGTH) + // startLoopback() already tore down any prior server; if this bind fails, + // clear the now-orphaned pending so a superseded flow can't linger as a + // dangling entry pointing at a server that no longer exists. + const port = await startLoopback() + if (!port) { + clear() + return false + } + pending = { state, createdAt: now(), kind } + const landing = new URL(landingPath, deps.origin()) + for (const [key, value] of Object.entries(params)) { + landing.searchParams.set(key, value) + } + landing.searchParams.set('state', state) + landing.searchParams.set('port', String(port)) + deps.events.record(kind === 'login' ? 'handoff_started' : 'connect_handoff_started') + const opened = await deps.openExternal(landing.toString()) + if (!opened) { + clear() + } + return opened + } + + return { + begin() { + return beginFlow('login', '/desktop/auth', {}) + }, + async beginConnect(providerId: string, scope: ConnectScope = {}) { + if (!PROVIDER_ID_PATTERN.test(providerId)) { + logger.warn('Rejected connect handoff for invalid providerId') + return false + } + // The whole OAuth flow runs in the browser, under whatever account the + // browser is signed into — which is no longer guaranteed to be this app's + // account. Pin the flow to the app's user so a mismatch is refused instead + // of quietly attaching the credential to the wrong account. Omitted when + // unknown (offline, signed out): the page then falls back to its normal + // login redirect rather than blocking a connect on a failed probe. + const userId = await deps.currentUserId() + return beginFlow('connect', '/desktop/connect', { + provider: providerId, + ...(userId ? { user: userId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), + }) + }, + consume(state: string, kind: HandoffKind) { + if (!pending || pending.kind !== kind) { + return false + } + if (now() - pending.createdAt > HANDOFF_TTL_MS) { + clear() + return false + } + if (!safeCompare(pending.state, state)) { + return false + } + clear() + return true + }, + clear, + } +} + +/** Outcome of a token redeem. `status` is the verify endpoint's HTTP status, + * or 0 for a network/exec error, or -1 when the window was unavailable. */ +export const REDEEM_OK_STATUS = 200 +export const REDEEM_NETWORK_ERROR = 0 +export const REDEEM_WINDOW_UNAVAILABLE = -1 + +/** + * Builds the renderer-side script that redeems a one-time token. Running it in + * the app-origin renderer makes the request genuinely same-origin, so + * better-auth's trustedOrigins/CSRF checks pass and the Set-Cookie lands in the + * app partition. Resolves to the HTTP status (or 0 on a network error) so a + * failure surfaces the real cause — 403 = untrusted origin, 400 = bad/expired + * token, 0 = unreachable. + */ +export function buildRedeemScript(token: string): string { + const body = JSON.stringify(JSON.stringify({ token })) + return `(async () => { + try { + const response = await fetch('${REDEEM_PATH}', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: ${body}, + }) + return response.status + } catch { + return ${REDEEM_NETWORK_ERROR} + } +})()` +} + +/** + * Redeems a one-time token from the app-partition renderer and returns the + * verify endpoint's HTTP status (200 on success). If the window is currently + * off-origin (offline page, in-window IdP flow) it first loads the login page + * so the redeem fetch is same-origin. + */ +export async function redeemToken( + win: BrowserWindow, + origin: string, + token: string +): Promise { + if (win.isDestroyed()) { + return REDEEM_WINDOW_UNAVAILABLE + } + const contents = win.webContents + if (!contents.getURL().startsWith(`${origin}/`)) { + try { + await win.loadURL(`${origin}/login`) + } catch { + return REDEEM_WINDOW_UNAVAILABLE + } + } + try { + const status = await contents.executeJavaScript(buildRedeemScript(token), true) + return typeof status === 'number' ? status : REDEEM_NETWORK_ERROR + } catch (error) { + logger.error('Token redeem failed', { error }) + return REDEEM_NETWORK_ERROR + } +} + +export interface AuthFlowDeps { + handoff: HandoffManager + origin: () => string + events: EventRecorder + ensureMainWindow: () => Promise +} + +export interface AuthFlow { + beginLoginHandoff(): Promise + handleCallback(callback: HandoffCallback): Promise +} + +/** + * Orchestrates the login handoff: opening the system browser, consuming the + * loopback callback, redeeming the token, and navigating to the workspace. A + * failed or expired callback never leaves a partial session — the window lands + * back on /login. + */ +export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { + const failInWindow = async (win: BrowserWindow, reason: string, status?: number) => { + deps.events.record( + 'handoff_redeem_fail', + status === undefined ? { reason } : { reason, status } + ) + void dialog.showMessageBox(win, { + type: 'error', + message: 'Sign-in failed', + detail: 'The sign-in could not be completed. Try signing in again.', + }) + try { + await win.loadURL(`${deps.origin()}/login`) + } catch {} + } + + return { + async beginLoginHandoff() { + const opened = await deps.handoff.begin() + if (!opened) { + const win = await deps.ensureMainWindow() + void dialog.showMessageBox(win, { + type: 'error', + message: 'Couldn’t start sign-in', + detail: 'Sim could not open your browser to sign in. Try again.', + }) + } + }, + async handleCallback(callback: HandoffCallback) { + const win = await deps.ensureMainWindow() + if (!deps.handoff.consume(callback.state, 'login')) { + await failInWindow(win, 'state') + return + } + const origin = deps.origin() + const status = await redeemToken(win, origin, callback.token) + if (status !== REDEEM_OK_STATUS) { + await failInWindow(win, 'redeem', status) + return + } + deps.events.record('handoff_redeem_ok') + try { + await win.loadURL(`${origin}/workspace`) + } catch {} + win.show() + win.focus() + app.focus({ steal: true }) + }, + } +} + +/** Outcome pushed to the renderer when an OAuth connect handoff finishes. */ +export interface ConnectHandoffResult { + ok: boolean + error?: string +} + +export interface ConnectFlowDeps { + handoff: HandoffManager + events: EventRecorder + focusMainWindow: () => void + notifyRenderer: (result: ConnectHandoffResult) => void +} + +export interface ConnectFlow { + beginConnectHandoff(providerId: string, scope?: ConnectScope): Promise + handleCallback(callback: ConnectHandoffCallback): void +} + +/** + * Orchestrates the OAuth connect handoff: the whole OAuth flow — initiation, + * consent, callback — runs in the system browser (better-auth binds state to + * the initiating user agent's cookies, so the flow cannot be split between + * app and browser). The browser's /desktop/connect/complete page bounces to + * the loopback; this flow then refocuses the app and notifies the renderer, + * which refreshes its credential caches and shows the standard connected + * toast. + */ +export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow { + return { + async beginConnectHandoff(providerId: string, scope?: ConnectScope) { + const opened = await deps.handoff.beginConnect(providerId, scope) + if (!opened) { + deps.events.record('connect_handoff_open_fail') + } + return opened + }, + handleCallback(callback: ConnectHandoffCallback) { + if (!deps.handoff.consume(callback.state, 'connect')) { + deps.events.record('connect_handoff_state_fail') + return + } + if (callback.error === undefined) { + deps.events.record('connect_handoff_ok') + deps.focusMainWindow() + deps.notifyRenderer({ ok: true }) + return + } + deps.events.record('connect_handoff_error', { error: callback.error }) + deps.focusMainWindow() + deps.notifyRenderer({ ok: false, error: callback.error }) + }, + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 00000000000..1b22fd23771 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,545 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { Session, WebContents } from 'electron' +import { app, BrowserWindow, crashReporter, net, session } from 'electron' +import { newChatRoute, settingsRoute } from '@/main/app-routes' +import { + clearBrowserProfile as clearAgentBrowserProfile, + initDriver as initBrowserAgentDriver, +} from '@/main/browser-agent/driver' +import { + canReportPanelBounds, + setPanelBounds as setBrowserAgentPanelBounds, + setPanelOccluded as setBrowserAgentPanelOccluded, +} from '@/main/browser-agent/panel' +import { + closeSession as closeAgentBrowserSession, + closeFocusedTab as closeFocusedBrowserTab, + reopenFocusedTab as reopenClosedBrowserTab, + setPanelFocused as setBrowserAgentPanelFocused, +} from '@/main/browser-agent/session' +import { + APP_NAME_FOR_CHANNEL, + channelForOrigin, + createConfigStore, + DEFAULT_ORIGIN, + isSafeInternalPath, + partitionForOrigin, +} from '@/main/config' +import { attachContextMenu } from '@/main/context-menu' +import { attachCspFallback } from '@/main/csp' +import { createDesktopSettingsService } from '@/main/desktop-settings' +import { attachDownloadHandling } from '@/main/downloads' +import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff' +import { registerIpcHandlers } from '@/main/ipc' +import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' +import { LocalFilesystemService } from '@/main/local-filesystem' +import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' +import { installApplicationMenu } from '@/main/menu' +import { openExternalSafe } from '@/main/navigation' +import { createEventLog } from '@/main/observability' +import { installGlobalGuards } from '@/main/security-guards' +import { + createSessionLifecycleCoordinator, + decideStartRoute, + handleConnectIntercept, + readSessionUserId, + resolveStartRoute, +} from '@/main/session-lifecycle' +import { attachTelemetryPolicy } from '@/main/telemetry-policy' +import { TerminalService } from '@/main/terminal' +import { installTray, type TrayHandle } from '@/main/tray' +import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' +import { createMainWindow, setupPermissionHandlers } from '@/main/window' +import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' + +const logger = createLogger('DesktopMain') + +const OFFLINE_PAGE = 'static/offline.html' +const DOCK_ICON_FOR_CHANNEL = { + prod: 'dock-icon.png', + staging: 'dock-icon-staging.png', + dev: 'dock-icon-dev.png', + local: 'dock-icon-local.png', +} as const + +function main(): void { + app.enableSandbox() + + const config = createConfigStore(join(app.getPath('userData'), 'settings.json')) + const events = createEventLog(join(app.getPath('userData'), 'logs')) + const localFilesystem = new LocalFilesystemService({ + grantStore: createEncryptedLocalFilesystemGrantStore( + join(app.getPath('userData'), 'local-filesystem-grants.json') + ), + }) + const terminal = new TerminalService({ + loadCwd: () => config.get('terminalCwd'), + saveCwd: (cwd) => config.set('terminalCwd', cwd), + }) + const preloadPath = join(__dirname, 'preload.cjs') + + const windows = new Set() + const loadHealthByWindow = new Map() + let lastActiveWindow: BrowserWindow | null = null + let ensureWindowCreation: Promise | null = null + let appSession: Session | null = null + let sessionLifecycle: ReturnType | null = null + let tray: TrayHandle | null = null + let updater: UpdaterHandle | null = null + const configuredPartitions = new Set() + + const appOrigin = () => config.getOrigin() + const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://') + const getWindows = () => [...windows].filter((win) => !win.isDestroyed()) + const getMainWindow = () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused && windows.has(focused) && !focused.isDestroyed()) { + return focused + } + if (lastActiveWindow && windows.has(lastActiveWindow) && !lastActiveWindow.isDestroyed()) { + return lastActiveWindow + } + return getWindows().at(-1) ?? null + } + const windowForContents = (contents: WebContents) => { + const win = BrowserWindow.fromWebContents(contents) + return win && windows.has(win) && !win.isDestroyed() ? win : null + } + /** The focused window, but only when it is one of ours. */ + const focusedAppWindow = () => { + const focused = BrowserWindow.getFocusedWindow() + return focused && windows.has(focused) && !focused.isDestroyed() ? focused : null + } + const broadcast = (channel: string, ...args: unknown[]) => { + for (const win of getWindows()) { + win.webContents.send(channel, ...args) + } + } + + /** Restore/show/focus one full Sim window and activate the app. */ + function showMainWindow(target?: BrowserWindow | null): void { + const win = target ?? getMainWindow() + if (win) { + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + } + app.focus({ steal: true }) + } + + const handoff = createHandoffManager( + { + origin: appOrigin, + openExternal: (url) => openExternalSafe(url, allowHttpLocalhost()), + events, + currentUserId: () => readSessionUserId(ensureAppSession(), appOrigin()), + }, + { + onLogin: (callback) => void authFlow.handleCallback(callback), + onConnect: (callback) => connectFlow.handleCallback(callback), + } + ) + + const authFlow = createAuthFlow({ + handoff, + origin: appOrigin, + events, + ensureMainWindow: async () => { + let win = getMainWindow() + if (!win) { + win = await ensureMainWindow() + } + if (!win) { + throw new Error('Main window unavailable') + } + return win + }, + }) + + const connectFlow = createConnectFlow({ + handoff, + events, + focusMainWindow: showMainWindow, + notifyRenderer: (result) => { + broadcast('desktop:oauth-connect-complete', result) + }, + }) + + installGlobalGuards({ + appOrigin, + isPackaged: app.isPackaged, + allowHttpLocalhost, + isPopupContents, + onLoginHandoff: () => void authFlow.beginLoginHandoff(), + onConnectIntercept: (contents) => void handleConnectIntercept(contents, allowHttpLocalhost()), + }) + + function configureSessionForOrigin(origin: string) { + const partition = partitionForOrigin(origin) + const ses = session.fromPartition(partition) + if (configuredPartitions.has(partition)) { + return ses + } + configuredPartitions.add(partition) + setupPermissionHandlers(ses, appOrigin) + attachCspFallback(ses, appOrigin) + attachDownloadHandling(ses, events) + attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) + ses.setSpellCheckerLanguages(['en-US']) + return ses + } + + function ensureAppSession(): Session { + if (appSession && sessionLifecycle) return appSession + const ses = configureSessionForOrigin(appOrigin()) + appSession = ses + sessionLifecycle = createSessionLifecycleCoordinator({ + appSession: ses, + origin: appOrigin, + events, + getWindows, + clearHandoffState: async () => { + handoff.clear() + tray?.clearRecentChats() + await localFilesystem.forgetAll() + }, + clearBrowserProfile: clearAgentBrowserProfile, + }) + return ses + } + + async function ensureMainWindow(): Promise { + const existing = getMainWindow() + if (existing) return existing + if (ensureWindowCreation) return ensureWindowCreation + + const pending = createAndLoadAppWindow({ restorePosition: true }) + ensureWindowCreation = pending + try { + return await pending + } finally { + if (ensureWindowCreation === pending) { + ensureWindowCreation = null + } + } + } + + function routeFromAppUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl) + if (url.origin !== appOrigin()) return null + const route = `${url.pathname}${url.search}${url.hash}` + return isSafeInternalPath(route) ? route : null + } catch { + return null + } + } + + async function createAndLoadAppWindow({ + route: requestedRouteOverride, + restorePosition = false, + }: { + route?: string + restorePosition?: boolean + } = {}): Promise { + const origin = appOrigin() + const ses = ensureAppSession() + const requestedRoute = decideStartRoute(requestedRouteOverride ?? config.get('lastRoute')) + const route = await resolveStartRoute(ses, origin, requestedRoute) + if (route !== requestedRoute) { + config.set('lastRoute', route) + } + const win = createMainWindow({ + config, + events, + appOrigin, + partition: partitionForOrigin(origin), + preloadPath, + isPackaged: app.isPackaged, + restorePosition, + onFullScreenChange: (isFullScreen) => { + if (!win.isDestroyed()) { + win.webContents.send('desktop:window-state:changed', { isFullScreen }) + } + }, + onClosed: () => { + setBrowserAgentPanelBounds(null, win) + windows.delete(win) + loadHealthByWindow.delete(win) + if (lastActiveWindow === win) { + lastActiveWindow = getWindows().at(-1) ?? null + } + }, + }) + windows.add(win) + lastActiveWindow = win + win.on('focus', () => { + lastActiveWindow = win + }) + // A fresh document (reload, origin change, crash recovery) has no browser + // panel mounted yet — hide the embedded agent-browser view immediately + // rather than letting it linger over the loading page. + win.webContents.on('did-start-loading', () => { + setBrowserAgentPanelBounds(null, win) + }) + attachWindowOpenPolicy(win.webContents, { + appOrigin, + openAppWindow: (url) => { + const route = routeFromAppUrl(url) + if (route) { + void createAndLoadAppWindow({ route }) + } + }, + allowHttpLocalhost: allowHttpLocalhost(), + }) + attachContextMenu(win.webContents, { + isDev: !app.isPackaged, + allowHttpLocalhost: allowHttpLocalhost(), + }) + const loadHealth = attachLoadHealth(win, { + offlinePagePath: OFFLINE_PAGE, + getStartUrl: () => `${appOrigin()}${route}`, + isOnline: () => net.isOnline(), + events, + }) + loadHealthByWindow.set(win, loadHealth) + sessionLifecycle?.attachWindow(win) + loadHealth.startWatchdog() + // Fire-and-forget: the window and all its handlers are wired synchronously + // above, so callers get a usable window immediately and the app menu and + // updater never wait on the remote page's load (load-health surfaces any + // failure). + void win.loadURL(`${origin}${route}`).catch(() => {}) + return win + } + + /** Opens the Sim app's settings page in the active window. */ + function openSettings(): void { + void openMainWindowAt(settingsRoute(config.get('lastRoute'))) + } + + /** + * Brings the active window to front (creating one if needed), optionally + * navigating it to an in-app route first — the seam used by the tray menu. + */ + async function openMainWindowAt(route?: string): Promise { + let win = getMainWindow() + if (!win) { + win = await createAndLoadAppWindow({ route, restorePosition: true }) + showMainWindow(win) + return + } + if (route) { + void win.loadURL(`${appOrigin()}${route}`).catch(() => {}) + } + showMainWindow(win) + } + + /** Installs or removes the menu-bar status item; safe to call repeatedly. */ + function setTrayEnabled(enabled: boolean): void { + if (!enabled) { + tray?.destroy() + tray = null + return + } + if (!tray) { + tray = installTray({ + partition: () => partitionForOrigin(appOrigin()), + appOrigin, + lastRoute: () => config.get('lastRoute'), + openMainWindow: (route) => void openMainWindowAt(route), + }) + } + } + + const desktopSettings = createDesktopSettingsService({ + config, + getMainWindow, + openMainWindowAt: (route) => void openMainWindowAt(route), + setAutoDownloadUpdates: (enabled) => updater?.setAutoDownload(enabled), + setTrayEnabled, + // Switching a surface off ends what it is already running; the pages and + // shells would otherwise keep going in the background. The profile and the + // pinned strip survive, so switching back on resumes rather than restarts. + setBrowserEnabled: (enabled) => { + if (!enabled) closeAgentBrowserSession() + }, + setTerminalEnabled: (enabled) => { + if (!enabled) terminal.dispose() + }, + }) + + /** + * Routes through the coordinator rather than tearing down directly: the + * coordinator holds the in-progress guard, clears the same handoff and grant + * state, and reloads every window to /login. Doing it here instead meant the + * teardown's own cookie removal tripped the coordinator's cookie watcher into + * a second concurrent teardown. + */ + function signOutFromMenu(): void { + ensureAppSession() + sessionLifecycle?.signOut() + } + + app.on('second-instance', () => { + void app.whenReady().then(() => createAndLoadAppWindow()) + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) + + app.on('before-quit', () => { + // Stops the tray's background chat refresh alongside the OS handles. + tray?.destroy() + tray = null + localFilesystem.close() + terminal.dispose() + // Settings writes coalesce, so a change made in the last moments before + // quit is still pending here. + config.flush() + }) + + app.on('activate', () => { + if (app.isReady() && !getMainWindow()) { + void ensureMainWindow() + } + }) + + void app.whenReady().then(async () => { + // Use the same high-resolution source in packaged and unpackaged apps so + // macOS renders every environment marker consistently in the Dock. + if (process.platform === 'darwin') { + const channel = channelForOrigin(config.getOrigin()) + app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) + } + events.record('app_launch', { + version: app.getVersion(), + electron: process.versions.electron ?? '', + }) + initBrowserAgentDriver( + { + onPageState: (state) => { + broadcast('browser-agent:page-state', state) + }, + onTabsState: (state) => { + broadcast('browser-agent:tabs-state', state) + }, + onSessionStatus: (alive) => { + broadcast('browser-agent:session-status', alive) + }, + onFillAvailability: (available) => { + broadcast('browser-credentials:fill-availability', { available }) + }, + }, + getMainWindow, + config + ) + await localFilesystem.initialize() + terminal.setSink({ + data: (terminalId, data) => broadcast('terminal:data', terminalId, data), + tabs: (state) => broadcast('terminal:tabs', state), + command: (event) => broadcast('terminal:command', event), + }) + registerIpcHandlers({ + appOrigin, + allowHttpLocalhost, + retryLoad: (sender) => { + const win = windowForContents(sender) + if (win) loadHealthByWindow.get(win)?.retry() + }, + localFilesystem, + terminal, + settings: desktopSettings, + getWindowState: (sender) => ({ + isFullScreen: windowForContents(sender)?.isFullScreen() ?? false, + }), + getWindowForContents: (sender) => windowForContents(sender) ?? null, + browserPanel: { + setBounds: (sender, bounds, anchor) => { + const win = windowForContents(sender) + if (!win) return + if (bounds !== null && !canReportPanelBounds(win, focusedAppWindow())) { + return + } + setBrowserAgentPanelBounds(bounds, win, anchor) + }, + setFocused: (sender, focused) => { + const win = windowForContents(sender) + if (win) setBrowserAgentPanelFocused(focused, win) + }, + setOccluded: (sender, occluded) => { + const win = windowForContents(sender) + if (win) setBrowserAgentPanelOccluded(occluded, win) + }, + }, + beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope), + updates: { + getState: () => updater?.getState() ?? { status: 'idle' }, + check: () => updater?.check(), + install: () => updater?.install(), + }, + }) + await ensureMainWindow() + installApplicationMenu({ + config, + getMainWindow, + allowHttpLocalhost, + openSettings, + newWindow: () => void createAndLoadAppWindow(), + newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), + closeFocusedBrowserTab: (win) => closeFocusedBrowserTab(win), + reopenClosedBrowserTab: (win) => reopenClosedBrowserTab(win), + closeFocusedTerminal: (win) => terminal.closeFocusedTerminal(win), + reopenClosedTerminal: (win) => terminal.reopenClosedTerminal(win), + toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'), + signOut: signOutFromMenu, + checkForUpdates: () => + checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), + }) + setTrayEnabled(config.get('trayEnabled') ?? true) + updater = initUpdater({ + getWindow: getMainWindow, + events, + appOrigin, + autoDownload: () => config.get('autoDownloadUpdates') ?? true, + onStateChange: (state) => { + broadcast('desktop:updates:state', state) + }, + }) + desktopSettings.applySystemPreferences() + }) +} + +// Identity and userData must be set before the single-process lock, which +// writes its lock file into userData. Sim supports many full BrowserWindows +// inside that one process; a second OS launch is forwarded to the running +// process so it can create another window without two processes mutating the +// same Chromium profile. Setting identity here (not inside main) keeps the +// SIM_DESKTOP_ORIGIN/USER_DATA test overrides isolated per process. +// The name follows the build's channel ("Sim", "Sim Dev", …) so one developer +// can run one install per environment side by side — separate settings, +// sessions, locks, and update feeds. +app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +if (process.env.SIM_DESKTOP_USER_DATA) { + app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) +} + +// Capture native minidumps for main/renderer/GPU crashes. Local-only: there is +// no crash-ingest backend, so nothing is uploaded — the dumps land under +// userData/Crashpad and the event log records where. Must start before the app +// is ready so Crashpad initializes first. Set after userData so dumps follow +// any test/instance override. +crashReporter.start({ uploadToServer: false, compress: true }) + +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + app.quit() +} else { + main() +} diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts new file mode 100644 index 00000000000..26b4ee032c8 --- /dev/null +++ b/apps/desktop/src/main/ipc.test.ts @@ -0,0 +1,805 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +// Stubbed so the gating tests never read the developer's real Chrome profile +// or reach their Keychain — the importer's own behaviour is covered by +// src/main/browser-import. +vi.mock('@/main/browser-import', () => ({ + isChromeImportSupported: vi.fn(() => true), + listChromeImportProfiles: vi.fn(async () => [{ id: 'Default', label: 'Person 1' }]), + importChromeCookies: vi.fn(async () => ({ cookiesImported: 3, cookiesSkipped: 1 })), + importChromePasswords: vi.fn(async () => ({ + passwordsAdded: 2, + passwordsUpdated: 0, + passwordsSkipped: 1, + })), +})) + +const { mockCoordinator } = vi.hoisted(() => ({ + mockCoordinator: { + noteFormState: vi.fn(), + noteNavigation: vi.fn(), + forget: vi.fn(), + refreshAvailability: vi.fn(), + showChooser: vi.fn(async () => true), + }, +})) + +vi.mock('@/main/browser-credentials', () => ({ + revealCredential: vi.fn(async () => 'hunter2'), + copyCredential: vi.fn(async () => true), + credentialsAvailable: vi.fn(() => true), + listCredentials: vi.fn(async () => [ + { + id: 'c1', + origin: 'https://example.com', + username: 'ada', + createdAt: '', + updatedAt: '', + source: 'chrome', + }, + ]), + forgetCredential: vi.fn(async () => []), + forgetAllCredentials: vi.fn(async () => []), + clearCredentials: vi.fn(async () => {}), + initFillCoordinator: vi.fn(() => mockCoordinator), + fillCoordinator: vi.fn(() => mockCoordinator), +})) + +// A browser tab is identified by WebContents, not by URL — the pages it hosts +// are arbitrary websites. +vi.mock('@/main/browser-agent/registry', () => ({ + registerAgentWebContents: vi.fn(), + isAgentWebContents: vi.fn( + (contents: { isBrowserTab?: boolean } | null) => contents?.isBrowserTab === true + ), +})) + +import { ipcMain, shell } from 'electron' +import { + copyCredential, + credentialsAvailable, + forgetAllCredentials, + forgetCredential, + listCredentials, + revealCredential, +} from '@/main/browser-credentials' +import { + importChromeCookies, + importChromePasswords, + listChromeImportProfiles, +} from '@/main/browser-import' +import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' +import { LocalFilesystemService } from '@/main/local-filesystem' +import { TerminalService } from '@/main/terminal' + +const APP = 'https://sim.ai' + +type Handler = ( + event: { + senderFrame: { url: string; executeJavaScript?: (source: string) => Promise } | null + sender?: { + session?: { fetch: (url: string, init?: RequestInit) => Promise } + /** Marks a sender the mocked registry recognises as a browser tab. */ + isBrowserTab?: boolean + } + }, + ...args: unknown[] +) => unknown + +function collectHandlers() { + const invoke = new Map() + const on = new Map() + for (const [channel, handler] of vi.mocked(ipcMain.handle).mock.calls) { + invoke.set(channel as string, handler as Handler) + } + for (const [channel, handler] of vi.mocked(ipcMain.on).mock.calls) { + on.set(channel as string, handler as Handler) + } + return { invoke, on } +} + +const rejectedSender = () => ({ + session: { + fetch: vi.fn(async () => { + throw new Error('not authorized') + }), + }, +}) +const fileSender = rejectedSender() +const appSender = rejectedSender() +const evilSender = rejectedSender() +const fileEvent = { + senderFrame: { url: 'file:///app/static/offline.html' }, + sender: fileSender, +} +const appEvent = { senderFrame: { url: `${APP}/workspace/ws1` }, sender: appSender } +const activeAppEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => true), + }, +} +const inactiveAppEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => false), + }, +} +const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } +/** The chooser anchors a native menu, so it needs a sender with a window. */ +const FAKE_WINDOW = { id: 'main-window' } +const activeChooserEvent = { + senderFrame: { + url: `${APP}/workspace/ws1`, + executeJavaScript: vi.fn(async () => true), + }, + sender: appSender, +} + +describe('registerIpcHandlers', () => { + let deps: IpcDeps + + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + vi.mocked(shell.openExternal).mockClear() + vi.mocked(listChromeImportProfiles).mockClear() + vi.mocked(importChromeCookies).mockClear() + vi.mocked(importChromePasswords).mockClear() + vi.mocked(credentialsAvailable).mockClear() + vi.mocked(listCredentials).mockClear() + vi.mocked(forgetCredential).mockClear() + vi.mocked(forgetAllCredentials).mockClear() + vi.mocked(revealCredential).mockClear() + vi.mocked(copyCredential).mockClear() + mockCoordinator.noteFormState.mockClear() + mockCoordinator.showChooser.mockClear() + deps = { + appOrigin: () => APP, + allowHttpLocalhost: () => false, + retryLoad: vi.fn(), + beginOAuthConnect: vi.fn(async () => true), + localFilesystem: new LocalFilesystemService({ + chooseDirectory: vi.fn(async () => null), + }), + terminal: new TerminalService(), + settings: { + getPreferences: vi.fn(() => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + })), + setPreference: vi.fn(), + notify: vi.fn(() => true), + applySystemPreferences: vi.fn(), + }, + getWindowState: vi.fn(() => ({ isFullScreen: true })), + getWindowForContents: vi.fn(() => FAKE_WINDOW as never), + browserPanel: { + setBounds: vi.fn(), + setFocused: vi.fn(), + setOccluded: vi.fn(), + }, + updates: { + getState: vi.fn(() => ({ status: 'ready' as const, version: '1.2.3' })), + check: vi.fn(), + install: vi.fn(), + }, + } + registerIpcHandlers(deps) + }) + + it('validates open-external URLs regardless of sender', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true) + expect(await invoke.get('desktop:open-external')?.(appEvent, 'javascript:alert(1)')).toBe(false) + expect(await invoke.get('desktop:open-external')?.(appEvent, 42)).toBe(false) + expect(shell.openExternal).toHaveBeenCalledTimes(1) + }) + + it('restricts the OAuth connect handoff to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:oauth-connect') + expect(await handler?.(evilEvent, 'slack')).toBe(false) + expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(deps.beginOAuthConnect).not.toHaveBeenCalled() + expect(await handler?.(appEvent, 42)).toBe(false) + expect(await handler?.(appEvent, 'slack')).toBe(true) + expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {}) + + // Chip-initiated connects carry workspace/credential scope; malformed + // scopes (wrong types, unsafe ids) are rejected before the handoff. + expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1' })).toBe( + true + ) + expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', { + workspaceId: 'ws1', + credentialId: 'cred_1', + }) + expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) + }) + + it('restricts the updates surface to the app origin', async () => { + const { invoke, on } = collectHandlers() + const getState = invoke.get('desktop:updates:get-state') + expect(await getState?.(evilEvent)).toEqual({ status: 'idle' }) + expect(await getState?.(appEvent)).toEqual({ status: 'ready', version: '1.2.3' }) + + on.get('desktop:updates:check')?.(evilEvent) + on.get('desktop:updates:install')?.(evilEvent) + expect(deps.updates.check).not.toHaveBeenCalled() + expect(deps.updates.install).not.toHaveBeenCalled() + + on.get('desktop:updates:check')?.(appEvent) + on.get('desktop:updates:install')?.(appEvent) + expect(deps.updates.check).toHaveBeenCalledTimes(1) + expect(deps.updates.install).toHaveBeenCalledTimes(1) + }) + + it('restricts local filesystem access to the app origin', async () => { + const { invoke } = collectHandlers() + expect( + await invoke.get('desktop:local-filesystem')?.(evilEvent, { operation: 'list_mounts' }) + ).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + expect( + await invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' }) + ).toEqual({ ok: true, data: { mounts: [] } }) + }) + + it('requires an active user gesture for granting or revoking folder access', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:local-filesystem') + + expect(await handler?.(inactiveAppEvent, { operation: 'mount_directory' })).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('explicit user click'), + }) + expect(await handler?.(activeAppEvent, { operation: 'mount_directory' })).toMatchObject({ + ok: true, + data: { cancelled: true, mount: null }, + }) + expect( + await handler?.(inactiveAppEvent, { operation: 'reveal_mount', uri: 'localfs://mount-1/' }) + ).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('explicit user click'), + }) + }) + + it('requires server authorization for every privileged filesystem tool request', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:local-filesystem') + const handle = vi.spyOn(deps.localFilesystem, 'handle') + + expect( + await handler?.(appEvent, { + operation: 'read', + uri: 'localfs://mount-1/README.md', + requestId: 'tool-1', + }) + ).toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + expect(handle).not.toHaveBeenCalled() + + const fetchAuthorization = vi.fn(async () => + Response.json({ + toolName: 'read', + args: { path: 'user-local/Project--mount-1/README.md' }, + }) + ) + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { session: { fetch: fetchAuthorization } }, + } + vi.spyOn(deps.localFilesystem, 'isAuthorizedClientToolRequest').mockReturnValueOnce(true) + handle.mockResolvedValueOnce({ ok: true, data: { forgotten: false } }) + + await expect( + handler?.(authorizedEvent, { + operation: 'read', + uri: 'localfs://mount-1/README.md', + requestId: 'tool-1', + }) + ).resolves.toEqual({ ok: true, data: { forgotten: false } }) + expect(fetchAuthorization).toHaveBeenCalledWith( + `${APP}/api/desktop/tool/authorize`, + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ toolCallId: 'tool-1' }), + }) + ) + }) + + it('restricts desktop settings to the app origin and validates mutations', async () => { + const { invoke } = collectHandlers() + const get = invoke.get('desktop:settings:get') + const set = invoke.get('desktop:settings:set') + const notify = invoke.get('desktop:settings:notify') + + expect(await get?.(evilEvent)).toBeNull() + expect(await get?.(appEvent)).toMatchObject({ notificationsEnabled: true }) + + await set?.(evilEvent, 'notificationsEnabled', false) + await set?.(appEvent, 'not-a-setting', false) + await set?.(appEvent, 'notificationsEnabled', 'no') + expect(deps.settings.setPreference).not.toHaveBeenCalled() + + await set?.(appEvent, 'notificationsEnabled', false) + expect(deps.settings.setPreference).toHaveBeenCalledWith('notificationsEnabled', false) + + expect(await notify?.(evilEvent, { title: 'Done', body: 'Ready' })).toBe(false) + expect(await notify?.(appEvent, { title: '', body: 'Ready' })).toBe(false) + expect( + await notify?.(appEvent, { title: 'Done', body: 'Ready', route: '//evil.example' }) + ).toBe(false) + expect(deps.settings.notify).not.toHaveBeenCalled() + + expect( + await notify?.(appEvent, { + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + ).toBe(true) + expect(deps.settings.notify).toHaveBeenCalledWith({ + title: 'Task complete', + body: 'Sim finished responding.', + route: '/workspace/ws1/chat/c1', + }) + }) + + it('reports native fullscreen state only to the app origin', async () => { + const { invoke } = collectHandlers() + const getWindowState = invoke.get('desktop:window-state:get') + + expect(await getWindowState?.(evilEvent)).toEqual({ isFullScreen: false }) + expect(await getWindowState?.(appEvent)).toEqual({ isFullScreen: true }) + expect(deps.getWindowState).toHaveBeenCalledWith(appSender) + }) + + it('restricts shell-control channels to bundled local pages', () => { + const { on } = collectHandlers() + + on.get('offline:retry')?.(appEvent) + expect(deps.retryLoad).not.toHaveBeenCalled() + on.get('offline:retry')?.(fileEvent) + expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) + }) + + it('registers every channel the preload bridge invokes or sends', () => { + // The two files share ~20 channel names as bare string literals with + // nothing tying them together, so a typo on either side is a silently dead + // feature that type-checks, lints, and ships. + const { invoke, on } = collectHandlers() + const registered = new Set([...invoke.keys(), ...on.keys()]) + const preloadSource = readFileSync( + fileURLToPath(new URL('../preload/index.ts', import.meta.url)), + 'utf8' + ) + const used = [ + ...new Set( + [...preloadSource.matchAll(/ipcRenderer\.(?:invoke|send)\(\s*'([^']+)'/g)].map( + (match) => match[1] + ) + ), + ] + + expect(used.length).toBeGreaterThan(0) + expect(used.filter((channel) => !registered.has(channel))).toEqual([]) + }) + + it('compares the sender by parsed origin, not by prefix', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:settings:get') + + // A lookalike host is a prefix of the app origin, so a `startsWith` gate + // is one missing trailing slash away from admitting it. + const lookalike = { senderFrame: { url: `${APP}.evil.example/workspace/ws1` } } + expect(await handler?.(lookalike)).toBeNull() + + // Origin equality also normalizes the default port, which a prefix + // comparison rejects even though it is the same origin. + const explicitPort = { senderFrame: { url: 'https://sim.ai:443/workspace/ws1' } } + expect(await handler?.(explicitPort)).toMatchObject({ notificationsEnabled: true }) + }) + + it('handles a missing senderFrame safely', async () => { + const { invoke } = collectHandlers() + expect(await invoke.get('desktop:oauth-connect')?.({ senderFrame: null }, 'slack')).toBe(false) + expect(deps.beginOAuthConnect).not.toHaveBeenCalled() + }) + + it('restricts browser-agent tool execution to the app origin and known tools', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:execute-tool') + + expect( + await handler?.(evilEvent, 'tool-1', 'browser_navigate', { url: 'https://x.dev' }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('not allowed'), + }) + expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ + ok: false, + }) + expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {})).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + + const fetchAuthorization = vi.fn(async () => + Response.json({ toolName: 'browser_snapshot', args: {} }) + ) + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { session: { fetch: fetchAuthorization } }, + } + // The server-persisted name must match the renderer's requested name. + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_navigate', { + url: 'https://evil.example', + }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + // An authorized call reaches the driver with the server-persisted args + // (which reports its own tool-level failure because no session exists). + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', { + ignored: 'renderer cannot choose params', + }) + ).toMatchObject({ + ok: false, + error: expect.stringContaining('No page is open yet'), + }) + expect(fetchAuthorization).toHaveBeenCalledWith( + `${APP}/api/desktop/tool/authorize`, + expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-1' }) }) + ) + }) + + it('ignores browser-agent panel actions from outside the app origin', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:panel-action') + // Malformed and foreign-origin actions are dropped without throwing. + expect(() => handler?.(evilEvent, { action: 'reload' })).not.toThrow() + expect(() => handler?.(appEvent, 'not-an-object')).not.toThrow() + expect(() => handler?.(appEvent, { action: 'reload' })).not.toThrow() + }) + + it('restricts browser-tab pinning to typed app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-tab-pinned') + + expect(() => handler?.(evilEvent, '1', true)).not.toThrow() + expect(() => handler?.(appEvent, 1, true)).not.toThrow() + expect(() => handler?.(appEvent, '1', 'yes')).not.toThrow() + expect(() => handler?.(appEvent, '1', true)).not.toThrow() + }) + + it('restricts browser-tab reordering to typed app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:reorder-tab') + + expect(() => handler?.(evilEvent, '1', 0)).not.toThrow() + expect(() => handler?.(appEvent, 1, 0)).not.toThrow() + expect(() => handler?.(appEvent, '1', '0')).not.toThrow() + expect(() => handler?.(appEvent, '1', Number.NaN)).not.toThrow() + expect(() => handler?.(appEvent, '1', 0)).not.toThrow() + }) + + it('restricts browser-panel occlusion updates to boolean app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-occluded') + + expect(() => handler?.(evilEvent, true)).not.toThrow() + expect(() => handler?.(appEvent, 'yes')).not.toThrow() + expect(() => handler?.(appEvent, true)).not.toThrow() + expect(deps.browserPanel.setOccluded).toHaveBeenCalledWith(appSender, true) + }) + + it('restricts browser-panel focus updates to boolean app-origin messages', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-focused') + + expect(() => handler?.(evilEvent, true)).not.toThrow() + expect(() => handler?.(appEvent, 'yes')).not.toThrow() + expect(() => handler?.(appEvent, true)).not.toThrow() + expect(deps.browserPanel.setFocused).toHaveBeenCalledWith(appSender, true) + }) + + it('routes validated browser-panel bounds with the originating app window sender', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-bounds') + const bounds = { x: 100, y: 50, width: 800, height: 600 } + + handler?.(evilEvent, bounds) + handler?.(appEvent, { ...bounds, width: Number.NaN }) + expect(deps.browserPanel.setBounds).not.toHaveBeenCalled() + + handler?.(appEvent, bounds) + handler?.(appEvent, null) + expect(deps.browserPanel.setBounds).toHaveBeenNthCalledWith(1, appSender, bounds, undefined) + expect(deps.browserPanel.setBounds).toHaveBeenNthCalledWith(2, appSender, null, undefined) + }) + + it('forwards a well-formed panel anchor and drops a malformed one', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-panel-bounds') + const bounds = { x: 100, y: 50, width: 800, height: 600 } + const anchor = { viewportWidth: 1600, viewportHeight: 900, widthRatio: 0.5 } + + handler?.(appEvent, bounds, anchor) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, anchor) + + // A bad anchor must not take the bounds down with it — the rect still + // applies, the shell just loses the resize optimization. + handler?.(appEvent, bounds, { ...anchor, widthRatio: Number.NaN }) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + handler?.(appEvent, bounds, { ...anchor, viewportWidth: 0 }) + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + handler?.(appEvent, bounds, 'nonsense') + expect(deps.browserPanel.setBounds).toHaveBeenLastCalledWith(appSender, bounds, undefined) + }) + + it('restricts browser theme updates to known app-origin preferences', () => { + const { on } = collectHandlers() + const handler = on.get('browser-agent:set-theme') + + expect(() => handler?.(evilEvent, 'dark')).not.toThrow() + expect(() => handler?.(appEvent, 'sepia')).not.toThrow() + expect(() => handler?.(appEvent, 'system')).not.toThrow() + }) + + it('restricts Chrome profile discovery to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-import:list-profiles') + + expect(await handler?.(evilEvent)).toEqual([]) + expect(await handler?.(fileEvent)).toEqual([]) + expect(listChromeImportProfiles).not.toHaveBeenCalled() + + expect(await handler?.(appEvent)).toEqual([{ id: 'Default', label: 'Person 1' }]) + }) + + it('requires a live user gesture before importing Chrome cookies', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-import:cookies') + + // Reading someone's Chrome cookies is a user decision. Without an active + // gesture the call is refused before it can reach the Keychain, so a + // scripted or compromised renderer cannot start an import on its own. + expect(await handler?.(inactiveAppEvent, 'Default')).toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + + expect(await handler?.(activeAppEvent, 'Default')).toEqual({ + cookiesImported: 3, + cookiesSkipped: 1, + }) + expect(importChromeCookies).toHaveBeenCalledWith('Default') + }) + + it('never imports Chrome cookies for a foreign origin', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:cookies')?.(evilEvent, 'Default')).toMatchObject({ + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('refuses Chrome import while the browser surface is switched off', async () => { + deps.settings.getPreferences = vi.fn(() => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: false, + })) + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:list-profiles')?.(appEvent)).toEqual([]) + expect(await invoke.get('browser-import:cookies')?.(activeAppEvent, 'Default')).toMatchObject({ + error: 'unknown', + }) + expect(listChromeImportProfiles).not.toHaveBeenCalled() + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('refuses a malformed profile id rather than importing the default profile', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-import:cookies')?.(activeAppEvent, 42)).toEqual({ + cookiesImported: 0, + cookiesSkipped: 0, + error: 'unknown', + }) + expect(importChromeCookies).not.toHaveBeenCalled() + }) + + it('imports the default profile when the page names none', async () => { + const { invoke } = collectHandlers() + + await invoke.get('browser-import:cookies')?.(activeAppEvent, undefined) + expect(importChromeCookies).toHaveBeenCalledWith(undefined) + }) + + it('exposes exactly one channel that can return a password', async () => { + // The structural guarantee behind the credential design. Reveal is the one + // deliberate exception, so the channel list is pinned here: a new way to + // get plaintext out of the main process has to break this test first. + const { invoke, on } = collectHandlers() + const credentialChannels = [...invoke.keys(), ...on.keys()].filter((channel) => + channel.startsWith('browser-credentials:') + ) + + expect(credentialChannels.sort()).toEqual([ + 'browser-credentials:available', + 'browser-credentials:copy', + 'browser-credentials:forget', + 'browser-credentials:forget-all', + 'browser-credentials:form-state', + 'browser-credentials:import', + 'browser-credentials:list', + 'browser-credentials:reveal', + 'browser-credentials:show-chooser', + ]) + + const listed = (await invoke.get('browser-credentials:list')?.(appEvent)) as Array< + Record + > + expect(listed.every((credential) => !('password' in credential))).toBe(true) + }) + + it('requires origin and a live gesture before revealing or copying a password', async () => { + const { invoke } = collectHandlers() + const revealHandler = invoke.get('browser-credentials:reveal') + const copyHandler = invoke.get('browser-credentials:copy') + + expect(await revealHandler?.(evilEvent, 'c1')).toBeNull() + expect(await revealHandler?.(inactiveAppEvent, 'c1')).toBeNull() + expect(await copyHandler?.(evilEvent, 'c1')).toBe(false) + expect(await copyHandler?.(inactiveAppEvent, 'c1')).toBe(false) + expect(revealCredential).not.toHaveBeenCalled() + expect(copyCredential).not.toHaveBeenCalled() + + expect(await revealHandler?.(activeAppEvent, 'c1')).toBe('hunter2') + expect(revealCredential).toHaveBeenCalledWith('c1') + }) + + it('requires a live user gesture before deleting every password', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:forget-all') + + expect(await handler?.(evilEvent)).toEqual([]) + expect(await handler?.(inactiveAppEvent)).toEqual([]) + expect(forgetAllCredentials).not.toHaveBeenCalled() + + await handler?.(activeAppEvent) + expect(forgetAllCredentials).toHaveBeenCalled() + }) + + it('refuses a reveal for anything that is not a credential id', async () => { + const { invoke } = collectHandlers() + + expect( + await invoke.get('browser-credentials:reveal')?.(activeAppEvent, { id: 'c1' }) + ).toBeNull() + expect(revealCredential).not.toHaveBeenCalled() + }) + + it('accepts login-form reports only from the built-in browseritself', async () => { + const { on } = collectHandlers() + const handler = on.get('browser-credentials:form-state') + const report = { origin: 'https://example.com', hasLoginForm: true } + const browserPageEvent = { + senderFrame: { url: 'https://example.com/login' }, + sender: { isBrowserTab: true }, + } + + // An arbitrary website, and even the Sim app itself, cannot claim a page + // has a login form — only the browser tab's own preload can. + handler?.(evilEvent, report) + handler?.(appEvent, report) + expect(mockCoordinator.noteFormState).not.toHaveBeenCalled() + + handler?.(browserPageEvent, report) + expect(mockCoordinator.noteFormState).toHaveBeenCalledWith(browserPageEvent.sender, report) + }) + + it('ignores a malformed login-form report', () => { + const { on } = collectHandlers() + const handler = on.get('browser-credentials:form-state') + const browserPageEvent = { + senderFrame: { url: 'https://x.test/' }, + sender: { isBrowserTab: true }, + } + + handler?.(browserPageEvent, 'nonsense') + handler?.(browserPageEvent, { origin: 42, hasLoginForm: true }) + handler?.(browserPageEvent, { origin: 'https://x.test', hasLoginForm: 'yes' }) + + expect(mockCoordinator.noteFormState).not.toHaveBeenCalled() + }) + + it('requires a live user gesture before opening the credential chooser', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:show-chooser') + const anchor = { x: 10, y: 20 } + + expect(await handler?.(evilEvent, anchor)).toBe(false) + expect(await handler?.(inactiveAppEvent, anchor)).toBe(false) + expect(mockCoordinator.showChooser).not.toHaveBeenCalled() + + expect(await handler?.(activeChooserEvent, anchor)).toBe(true) + expect(mockCoordinator.showChooser).toHaveBeenCalledWith(FAKE_WINDOW, anchor) + }) + + it('refuses a chooser anchor that is not a real point', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:show-chooser') + + expect(await handler?.(activeChooserEvent, { x: 'left', y: 2 })).toBe(false) + expect(await handler?.(activeChooserEvent, { x: Number.NaN, y: 2 })).toBe(false) + expect(await handler?.(activeChooserEvent, null)).toBe(false) + expect(mockCoordinator.showChooser).not.toHaveBeenCalled() + }) + + it('requires a live user gesture before importing or forgetting passwords', async () => { + const { invoke } = collectHandlers() + + expect( + await invoke.get('browser-credentials:import')?.(inactiveAppEvent, 'Default') + ).toMatchObject({ error: 'unknown' }) + await invoke.get('browser-credentials:forget')?.(inactiveAppEvent, 'c1') + expect(importChromePasswords).not.toHaveBeenCalled() + expect(forgetCredential).not.toHaveBeenCalled() + + await invoke.get('browser-credentials:import')?.(activeAppEvent, 'Default', 'replace') + await invoke.get('browser-credentials:forget')?.(activeAppEvent, 'c1') + expect(importChromePasswords).toHaveBeenCalledWith('Default', 'replace') + expect(forgetCredential).toHaveBeenCalledWith('c1') + }) + + it('defaults password conflicts to keeping what is already stored', async () => { + const { invoke } = collectHandlers() + + await invoke.get('browser-credentials:import')?.(activeAppEvent, undefined, 'nonsense') + expect(importChromePasswords).toHaveBeenCalledWith(undefined, 'keep-existing') + }) + + it('reports credential availability only to the app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-credentials:available') + + expect(await handler?.(evilEvent)).toBe(false) + expect(credentialsAvailable).not.toHaveBeenCalled() + expect(await handler?.(appEvent)).toBe(true) + }) + + it('never lists credentials to a foreign origin', async () => { + const { invoke } = collectHandlers() + + expect(await invoke.get('browser-credentials:list')?.(evilEvent)).toEqual([]) + expect(listCredentials).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 00000000000..a3532b5f3e4 --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -0,0 +1,1048 @@ +import { + type BrowserPanelAnchor, + type BrowserPanelBounds, + isBrowserDataKind, + isBrowserTheme, + isBrowserToolName, +} from '@sim/browser-protocol' +import type { + DesktopNotificationPayload, + DesktopUpdateState, + DesktopWindowState, +} from '@sim/desktop-bridge' +import { + isTerminalOperation, + isTerminalToolName, + type TerminalToolArgs, +} from '@sim/terminal-protocol' +import { isRecordLike } from '@sim/utils/object' +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import { ipcMain } from 'electron' +import { + clearBrowsingData, + executeTool, + getKnownSessions, + handlePanelAction, +} from '@/main/browser-agent/driver' +import { isAgentWebContents } from '@/main/browser-agent/registry' +import { + findInActiveTab, + getTabsState, + reorderTab, + setBrowserTheme, + setTabPinned, + stopFindInActiveTab, +} from '@/main/browser-agent/session' +import { + copyCredential, + credentialsAvailable, + fillCoordinator, + forgetAllCredentials, + forgetCredential, + listCredentials, + revealCredential, +} from '@/main/browser-credentials' +import { + importChromeCookies, + importChromeData, + importChromePasswords, + listChromeImportProfiles, +} from '@/main/browser-import' +import { listSites } from '@/main/browser-sites' +import { isSafeInternalPath } from '@/main/config' +import type { DesktopSettingsService } from '@/main/desktop-settings' +import { isDesktopPreferenceKey } from '@/main/desktop-settings' +import type { LocalFilesystemService } from '@/main/local-filesystem' +import { isAppOrigin, openExternalSafe } from '@/main/navigation' +import type { TerminalService } from '@/main/terminal' + +/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ +const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +export interface OAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +/** + * Validates the optional connect-handoff scope: absent is fine, but a present + * scope must be an object whose ids are opaque tokens (they are embedded into + * the /desktop/connect URL). Returns undefined for malformed payloads. + */ +export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefined { + if (raw === undefined || raw === null) { + return {} + } + if (typeof raw !== 'object') { + return undefined + } + const { workspaceId, credentialId } = raw as { workspaceId?: unknown; credentialId?: unknown } + if ( + workspaceId !== undefined && + (typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId)) + ) { + return undefined + } + if ( + credentialId !== undefined && + (typeof credentialId !== 'string' || !ID_PATTERN.test(credentialId)) + ) { + return undefined + } + return { + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(credentialId !== undefined ? { credentialId } : {}), + } +} + +/** + * A renderer-supplied dimension worth acting on. `typeof NaN === 'number'` and + * `NaN <= 0` is false, so a bare typeof check lets an unfinite value through + * every downstream positivity guard untouched. + */ +export function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +/** + * A renderer-supplied dimension as a usable whole number of cells. + * + * Flooring after the positivity check is not enough on its own: 0.5 passes + * `> 0` and floors to 0, and a zero-column pty is either a broken shell or a + * spawn failure. The floor of one cell is applied here so every caller gets it. + */ +export function toCellCount(value: unknown, fallback: number): number { + return isPositiveFinite(value) ? Math.max(1, Math.floor(value)) : fallback +} + +/** Validates a renderer-reported panel rect (finite numbers or explicit null). */ +export function parsePanelBounds( + raw: unknown +): { x: number; y: number; width: number; height: number } | null | undefined { + if (raw === null) { + return null + } + if (typeof raw !== 'object') { + return undefined + } + const rect = raw as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } + if ( + typeof rect.x === 'number' && + typeof rect.y === 'number' && + typeof rect.width === 'number' && + typeof rect.height === 'number' && + [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) + ) { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + return undefined +} + +/** + * Validates the optional panel anchor. Absent or malformed yields undefined, so + * the panel falls back to the measured rect alone — an anchor is an + * optimization, never a requirement. + */ +export function parsePanelAnchor(raw: unknown): BrowserPanelAnchor | undefined { + if (!isRecordLike(raw)) { + return undefined + } + const { viewportWidth, viewportHeight, widthRatio } = raw as { + viewportWidth?: unknown + viewportHeight?: unknown + widthRatio?: unknown + } + if ( + typeof viewportWidth !== 'number' || + typeof viewportHeight !== 'number' || + typeof widthRatio !== 'number' || + ![viewportWidth, viewportHeight, widthRatio].every(Number.isFinite) || + viewportWidth <= 0 || + viewportHeight <= 0 || + widthRatio < 0 || + widthRatio > 1 + ) { + return undefined + } + return { viewportWidth, viewportHeight, widthRatio } +} + +export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificationPayload | null { + if (typeof raw !== 'object' || raw === null) { + return null + } + const { title, body, route } = raw as { + title?: unknown + body?: unknown + route?: unknown + } + if ( + typeof title !== 'string' || + title.length < 1 || + title.length > 120 || + typeof body !== 'string' || + body.length < 1 || + body.length > 500 + ) { + return null + } + if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) { + return null + } + return { title, body, ...(route !== undefined ? { route } : {}) } +} + +export interface IpcDeps { + appOrigin: () => string + allowHttpLocalhost: () => boolean + retryLoad: (sender: WebContents) => void + localFilesystem: LocalFilesystemService + terminal: TerminalService + settings: DesktopSettingsService + getWindowState: (sender: WebContents) => DesktopWindowState + /** The window owning a renderer, for anchoring native menus. */ + getWindowForContents: (sender: WebContents) => BrowserWindow | null + browserPanel: { + setBounds: ( + sender: WebContents, + bounds: BrowserPanelBounds | null, + anchor?: BrowserPanelAnchor + ) => void + setFocused: (sender: WebContents, focused: boolean) => void + setOccluded: (sender: WebContents, occluded: boolean) => void + } + beginOAuthConnect: (providerId: string, scope: OAuthConnectScope) => Promise + updates: { + getState: () => DesktopUpdateState + check: () => void + install: () => void + } +} + +/** + * Who may call a channel: + * - `app-origin`: only the remote app origin (main window pages). + * - `local-page`: only bundled `file:` pages (offline) — shell control. + * - `browser-page`: only the built-in browser's own tabs, identified by + * WebContents rather than by URL. These carry reports from the browser + * preload about untrusted pages, so they are the one inbound surface whose + * sender is not the app — the payload is treated as a claim to verify, never + * as an instruction. + * - `any`: sender-independent channels that validate their input instead. + */ +type ChannelGate = 'app-origin' | 'local-page' | 'browser-page' | 'any' + +/** + * A desktop surface the user can switch off. Channels that drive one are + * refused while it is off, so the gate holds even if renderer-side checks are + * stale or bypassed. Channels that only read or reset the surface's settings + * stay open — otherwise turning it back on would be impossible. + */ +type ChannelFeature = 'browser' | 'terminal' + +interface ChannelSpecBase { + gate: ChannelGate + passSender?: boolean + requires?: ChannelFeature + /** + * Why this channel's `gate` or `requires` deviates from the rest of its + * name family. Required by `check:desktop-ipc` for any channel that does, + * so a new channel cannot quietly opt out of its family's surface toggle. + * + * A field rather than a comment because the deviation is a property of this + * object: reordering the table moves it with its channel, where a positional + * comment would silently transfer to whichever channel took its place. + */ + deviationReason?: string +} + +type ChannelSpec = + | (ChannelSpecBase & { + kind: 'invoke' + /** Requires an in-progress user gesture in the calling page. */ + needsUserActivation?: boolean + /** Returned to the caller when a gate rejects the call. */ + denied: unknown + handler: (...args: unknown[]) => unknown + }) + | (ChannelSpecBase & { + kind: 'send' + handler: (...args: unknown[]) => void + }) + +function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + try { + return new URL(event.senderFrame?.url ?? '').protocol === 'file:' + } catch { + return false + } +} + +/** + * Compared by parsed origin, not `startsWith`. This is the renderer-to-main + * boundary, and prefix matching admits lookalike hosts — see the warning on + * {@link isAppOrigin}. + */ +function isAppOriginSender(event: IpcMainEvent | IpcMainInvokeEvent, appOrigin: string): boolean { + return isAppOrigin(event.senderFrame?.url ?? '', appOrigin) +} + +function localFilesystemRequestNeedsUserActivation(request: unknown): boolean { + if (typeof request !== 'object' || request === null) return false + const operation = (request as { operation?: unknown }).operation + return ( + operation === 'mount_directory' || operation === 'forget_mount' || operation === 'reveal_mount' + ) +} + +function localFilesystemRequestNeedsToolAuthorization(request: unknown): boolean { + if (typeof request !== 'object' || request === null) return false + const operation = (request as { operation?: unknown }).operation + return ( + operation === 'list' || + operation === 'glob' || + operation === 'read' || + operation === 'grep' || + operation === 'stat' + ) +} + +async function rendererHasActiveUserGesture(event: IpcMainInvokeEvent): Promise { + const frame = event.senderFrame + if (!frame || typeof frame.executeJavaScript !== 'function') return false + try { + return (await frame.executeJavaScript('navigator.userActivation?.isActive === true')) === true + } catch { + return false + } +} + +interface DesktopToolAuthorization { + toolName: string + args: Record +} + +async function fetchDesktopToolAuthorization( + event: IpcMainInvokeEvent, + deps: IpcDeps, + toolCallId: unknown +): Promise { + if (typeof toolCallId !== 'string' || toolCallId.length < 1 || toolCallId.length > 256) { + return null + } + try { + const response = await event.sender.session.fetch( + `${deps.appOrigin()}/api/desktop/tool/authorize`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolCallId }), + } + ) + if (!response.ok) return null + const authorization = (await response.json()) as { + toolName?: unknown + args?: unknown + } + if ( + typeof authorization.toolName !== 'string' || + typeof authorization.args !== 'object' || + authorization.args === null || + Array.isArray(authorization.args) + ) { + return null + } + return { + toolName: authorization.toolName, + args: authorization.args as Record, + } + } catch { + return null + } +} + +async function authorizeLocalFilesystemTool( + event: IpcMainInvokeEvent, + deps: IpcDeps, + request: unknown +): Promise { + if (typeof request !== 'object' || request === null) return false + const authorization = await fetchDesktopToolAuthorization( + event, + deps, + (request as { requestId?: unknown }).requestId + ) + return authorization + ? deps.localFilesystem.isAuthorizedClientToolRequest(request, authorization) + : false +} + +/** + * Registers the whitelisted IPC surface, table-driven so the whole + * renderer→main security posture is auditable in one place: every channel + * declares its sender gate up front, and handlers only ever see gated, + * unvalidated args they must parse themselves. + */ +export function registerIpcHandlers(deps: IpcDeps): void { + const channels: Record = { + 'desktop:open-external': { + kind: 'invoke', + gate: 'any', + deviationReason: + 'the offline and error pages are local-page senders, not app-origin, and handing a support link to the system browser is the one action that must work when the app cannot reach its origin at all', + denied: false, + handler: (url) => + typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false, + }, + // OAuth connect handoff: the whole flow runs in the system browser (state + // is cookie-bound to the initiating user agent), returning via loopback. + 'desktop:oauth-connect': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: (providerId, scope) => { + if (typeof providerId !== 'string') { + return false + } + const parsedScope = parseOAuthConnectScope(scope) + if (parsedScope === undefined) { + return false + } + return deps.beginOAuthConnect(providerId, parsedScope) + }, + }, + 'desktop:local-filesystem': { + kind: 'invoke', + gate: 'app-origin', + denied: { + ok: false, + code: 'ACCESS_DENIED', + error: 'Local filesystem access is not allowed from this page.', + }, + handler: (request) => deps.localFilesystem.handle(request), + }, + 'desktop:settings:get': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: () => deps.settings.getPreferences(), + }, + 'desktop:settings:set': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: (key, value) => + isDesktopPreferenceKey(key) && typeof value === 'boolean' + ? deps.settings.setPreference(key, value) + : deps.settings.getPreferences(), + }, + 'desktop:settings:notify': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: (raw) => { + const payload = parseDesktopNotificationPayload(raw) + return payload ? deps.settings.notify(payload) : false + }, + }, + 'desktop:window-state:get': { + kind: 'invoke', + gate: 'app-origin', + passSender: true, + denied: { isFullScreen: false }, + handler: (sender) => deps.getWindowState(sender as WebContents), + }, + 'desktop:updates:get-state': { + kind: 'invoke', + gate: 'app-origin', + denied: { status: 'idle' }, + handler: () => deps.updates.getState(), + }, + 'desktop:updates:check': { + kind: 'send', + gate: 'app-origin', + handler: () => deps.updates.check(), + }, + 'desktop:updates:install': { + kind: 'send', + gate: 'app-origin', + handler: () => deps.updates.install(), + }, + 'browser-agent:execute-tool': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: { ok: false, error: 'Browser automation is not allowed from this page.' }, + handler: (tool, params) => { + if (typeof tool !== 'string' || !isBrowserToolName(tool)) { + return { ok: false, error: `Unknown browser tool: ${String(tool)}` } + } + const toolParams = isRecordLike(params) ? params : {} + return executeTool(tool, toolParams) + }, + }, + 'browser-agent:get-tabs-state': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: { tabs: [], activeTabId: null }, + handler: () => getTabsState(), + }, + // Reads and wipes the stored browsing trail, so both stay available while + // the browser is switched off — that is exactly when someone clears it. + 'browser-agent:get-known-sessions': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + "read/reset of the surface's own data; gating it on the surface would strand the browsing trail with no way to inspect or erase it", + denied: { sessions: [] }, + handler: () => getKnownSessions(), + }, + 'browser-agent:clear-browsing-data': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'erasing browsing data has to work with the browser off, which is the state a user clearing it is most likely to be in', + needsUserActivation: true, + denied: { sessions: [] }, + handler: async (rawKinds) => { + // Saved passwords are intentionally untouched here; erasing the vault + // is a separate, explicit action. + const kinds = Array.isArray(rawKinds) ? rawKinds.filter(isBrowserDataKind) : undefined + await clearBrowsingData(kinds && kinds.length > 0 ? kinds : undefined) + return getKnownSessions() + }, + }, + 'browser-agent:panel-action': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (action) => { + if ( + typeof action !== 'object' || + action === null || + typeof (action as { action?: unknown }).action !== 'string' + ) { + return + } + void handlePanelAction(action as Parameters[0]).catch(() => {}) + }, + }, + 'browser-agent:set-tab-pinned': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (tabId, pinned) => { + if (typeof tabId !== 'string' || typeof pinned !== 'boolean') return + try { + setTabPinned(tabId, pinned) + } catch {} + }, + }, + 'browser-agent:reorder-tab': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (tabId, targetIndex) => { + if ( + typeof tabId !== 'string' || + typeof targetIndex !== 'number' || + !Number.isFinite(targetIndex) + ) { + return + } + try { + reorderTab(tabId, targetIndex) + } catch {} + }, + }, + 'browser-agent:set-panel-bounds': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, raw, rawAnchor) => { + const bounds = parsePanelBounds(raw) + if (bounds !== undefined) { + deps.browserPanel.setBounds(sender as WebContents, bounds, parsePanelAnchor(rawAnchor)) + } + }, + }, + 'browser-agent:set-panel-focused': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, focused) => { + if (typeof focused === 'boolean') { + deps.browserPanel.setFocused(sender as WebContents, focused) + } + }, + }, + 'browser-agent:set-panel-occluded': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + passSender: true, + handler: (sender, occluded) => { + if (typeof occluded === 'boolean') { + deps.browserPanel.setOccluded(sender as WebContents, occluded) + } + }, + }, + 'browser-agent:set-theme': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (theme) => { + if (isBrowserTheme(theme)) { + setBrowserTheme(theme) + } + }, + }, + 'browser-agent:find': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (raw) => { + if (typeof raw !== 'object' || raw === null) return + const { query, findNext, forward } = raw as Record + if ( + typeof query !== 'string' || + typeof findNext !== 'boolean' || + typeof forward !== 'boolean' + ) { + return + } + findInActiveTab({ query, findNext, forward }) + }, + }, + 'browser-agent:stop-find': { + kind: 'send', + gate: 'app-origin', + requires: 'browser', + handler: (focusPage) => { + stopFindInActiveTab(focusPage === true) + }, + }, + // Local Chrome import. This is a user-only surface: no browser tool maps + // to either channel, so the agent has no path to it, and the import itself + // additionally demands a live user gesture — a compromised or scripted + // renderer cannot start one on its own. Only counts come back. + 'browser-import:list-profiles': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: [], + handler: () => listChromeImportProfiles(), + }, + 'browser-import:cookies': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + needsUserActivation: true, + denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + handler: (profileId) => { + // An explicit profile must be honoured or refused, never quietly + // swapped for the default — that would import the wrong account. + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' } + } + return importChromeCookies(typeof profileId === 'string' ? profileId : undefined) + }, + }, + // Cookies and passwords together, so the user only has to authorize once. + 'browser-import:all': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + needsUserActivation: true, + denied: { + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + }, + handler: (profileId, policy) => { + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { + cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, + passwords: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + } + } + return importChromeData( + typeof profileId === 'string' ? profileId : undefined, + policy === 'replace' ? 'replace' : 'keep-existing' + ) + }, + }, + // Reported by the browser preload for the page it is running in. The + // origin it names is a claim: the fill coordinator re-checks it against + // the live URL before any password is read. + 'browser-credentials:form-state': { + kind: 'send', + gate: 'browser-page', + deviationReason: + "the only sender in this family that is a browser PAGE rather than the Sim app, so browser-page is the correct gate and requires:'browser' follows — with the browser off no such page exists", + requires: 'browser', + passSender: true, + handler: (sender, report) => { + if (!isRecordLike(report)) return + const { origin, hasLoginForm } = report as { origin?: unknown; hasLoginForm?: unknown } + if (typeof origin !== 'string' || typeof hasLoginForm !== 'boolean') return + fillCoordinator()?.noteFormState(sender as WebContents, { origin, hasLoginForm }) + }, + }, + 'browser-credentials:available': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: () => credentialsAvailable(), + }, + 'browser-credentials:list': { + kind: 'invoke', + gate: 'app-origin', + denied: [], + handler: () => listCredentials(), + }, + // Hosts a previous import brought over, with the name and icon the source + // browser gave each one and an aggregate count of how much it was used + // there. No password material, and no browsing history in the sense that + // matters: no visit times, no URLs beyond the host, no ordering of one + // visit against another. + 'browser-import:sites': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser', + denied: [], + handler: () => listSites(), + }, + // The one channel in the whole surface that can return password + // plaintext. It is gated three ways: the Sim app origin, a live user + // gesture, and an OS prompt inside the handler on every single call. + 'browser-credentials:reveal': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: null, + handler: (id) => (typeof id === 'string' ? revealCredential(id) : null), + }, + 'browser-credentials:copy': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: false, + handler: (id) => (typeof id === 'string' ? copyCredential(id) : false), + }, + 'browser-credentials:forget': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: [], + handler: (id) => (typeof id === 'string' ? forgetCredential(id) : listCredentials()), + }, + 'browser-credentials:forget-all': { + kind: 'invoke', + gate: 'app-origin', + needsUserActivation: true, + denied: [], + handler: () => forgetAllCredentials(), + }, + 'browser-credentials:import': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + "unlike its siblings this WRITES new credentials by driving the embedded browser's import path, so it needs the surface the rest of the family deliberately does without", + requires: 'browser', + needsUserActivation: true, + denied: { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'unknown', + }, + handler: (profileId, policy) => { + if (profileId !== undefined && profileId !== null && typeof profileId !== 'string') { + return { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0, error: 'unknown' } + } + return importChromePasswords( + typeof profileId === 'string' ? profileId : undefined, + policy === 'replace' ? 'replace' : 'keep-existing' + ) + }, + }, + // Opens the native account chooser. The renderer only says "the user + // clicked the key icon, here"; it never learns which accounts exist, never + // names one, and never receives a password. The shell performs the fill. + 'browser-credentials:show-chooser': { + kind: 'invoke', + gate: 'app-origin', + deviationReason: + 'it fills into a live browser page, so unlike the read-only management channels beside it there is nothing to act on when the browser is off', + requires: 'browser', + needsUserActivation: true, + passSender: true, + denied: false, + handler: (sender, anchor) => { + const window = deps.getWindowForContents(sender as WebContents) + if (!window || !isRecordLike(anchor)) return false + const { x, y } = anchor as { x?: unknown; y?: unknown } + if ( + typeof x !== 'number' || + typeof y !== 'number' || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return false + } + return fillCoordinator()?.showChooser(window, { x, y }) ?? false + }, + }, + 'terminal:start': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { ok: false, code: 'ACCESS_DENIED', error: 'Not allowed from this page.' }, + handler: (raw) => { + const options = isRecordLike(raw) ? raw : {} + const cols = Number(options.cols) + const rows = Number(options.rows) + try { + return { + ok: true, + tabs: deps.terminal.start({ + cols: toCellCount(cols, 80), + rows: toCellCount(rows, 24), + }), + } + } catch (error) { + const failure = error as { code?: string; message?: string } + return { + ok: false, + code: failure.code ?? 'SPAWN_FAILED', + error: failure.message ?? 'Could not open a terminal.', + } + } + }, + }, + 'terminal:execute-tool': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { ok: false, error: 'Terminal access is not allowed from this page.' }, + handler: (toolCallId, tool, params) => { + if ( + typeof toolCallId !== 'string' || + typeof tool !== 'string' || + !isTerminalToolName(tool) + ) { + return { ok: false, error: `Unknown terminal tool: ${String(tool)}` } + } + const call = isRecordLike(params) ? params : {} + if (!isTerminalOperation(call.operation)) { + return { ok: false, error: `Unknown terminal operation: ${String(call.operation)}` } + } + const args = isRecordLike(call.args) ? (call.args as TerminalToolArgs) : {} + return deps.terminal.executeTool(toolCallId, call.operation, args) + }, + }, + 'terminal:handoff-done': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId) => { + if (typeof terminalId === 'string') deps.terminal.finishHandoff(terminalId) + }, + }, + 'terminal:focused': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + passSender: true, + handler: (sender, focused) => + deps.terminal.setPanelFocused(focused === true, sender as WebContents), + }, + 'terminal:scrollback': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: '', + handler: (terminalId) => + typeof terminalId === 'string' ? deps.terminal.getScrollback(terminalId) : '', + }, + 'terminal:get-tabs': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: () => deps.terminal.getTabs(), + }, + 'terminal:open': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (cwd) => deps.terminal.openTerminal(typeof cwd === 'string' ? cwd : undefined), + }, + 'terminal:switch': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (terminalId) => + typeof terminalId === 'string' + ? deps.terminal.switchTerminal(terminalId) + : deps.terminal.getTabs(), + }, + 'terminal:close': { + kind: 'invoke', + gate: 'app-origin', + requires: 'terminal', + denied: { tabs: [], activeTerminalId: null }, + handler: (terminalId) => + typeof terminalId === 'string' + ? deps.terminal.closeTerminal(terminalId) + : deps.terminal.getTabs(), + }, + 'terminal:write': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId, data) => { + if (typeof terminalId === 'string' && typeof data === 'string') { + deps.terminal.write(terminalId, data) + } + }, + }, + 'terminal:resize': { + kind: 'send', + gate: 'app-origin', + requires: 'terminal', + handler: (terminalId, cols, rows) => { + // `typeof NaN === 'number'`, and the downstream `cols <= 0` guard is + // false for NaN, so an unfinite value reached pty.resize() intact. + // Matches the clamping terminal:start already applies to these fields. + if (typeof terminalId !== 'string') return + if (!isPositiveFinite(cols) || !isPositiveFinite(rows)) return + deps.terminal.resize(terminalId, toCellCount(cols, 1), toCellCount(rows, 1)) + }, + }, + 'terminal:dispose': { + kind: 'send', + gate: 'app-origin', + deviationReason: + 'tearing the surface down must survive the surface being off, or a terminal left running when the feature was disabled could never be reaped', + handler: () => deps.terminal.dispose(), + }, + 'offline:retry': { + kind: 'send', + gate: 'local-page', + passSender: true, + handler: (sender) => deps.retryLoad(sender as WebContents), + }, + } + + const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => { + if (gate === 'any') return true + if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) + if (gate === 'browser-page') return isAgentWebContents(event.sender) + return isLocalPageSender(event) + } + + const featureAllowed = (feature: ChannelFeature | undefined): boolean => { + if (!feature) return true + const preferences = deps.settings.getPreferences() + // Absent means on: the surfaces predate the preference. + return feature === 'browser' + ? preferences.browserEnabled !== false + : preferences.terminalEnabled !== false + } + + for (const [channel, spec] of Object.entries(channels)) { + if (spec.kind === 'invoke') { + ipcMain.handle(channel, async (event, ...args) => { + if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return spec.denied + if (spec.needsUserActivation && !(await rendererHasActiveUserGesture(event))) { + return spec.denied + } + let handlerArgs = args + if (channel === 'browser-agent:execute-tool') { + const requestedTool = args[1] + const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) + if ( + !authorization || + typeof requestedTool !== 'string' || + authorization.toolName !== requestedTool || + !isBrowserToolName(authorization.toolName) + ) { + return { + ok: false, + error: 'This browser action is not an authorized pending Copilot tool call.', + } + } + handlerArgs = [authorization.toolName, authorization.args] + } + if (channel === 'terminal:execute-tool') { + const requestedTool = args[1] + const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) + if ( + !authorization || + typeof requestedTool !== 'string' || + authorization.toolName !== requestedTool || + !isTerminalToolName(authorization.toolName) + ) { + return { + ok: false, + error: 'This terminal action is not an authorized pending Copilot tool call.', + } + } + // The command executed is the one the server has on file for this + // tool call, never the one the renderer passed in. + handlerArgs = [args[0], authorization.toolName, authorization.args] + } + if ( + channel === 'desktop:local-filesystem' && + localFilesystemRequestNeedsUserActivation(args[0]) && + !(await rendererHasActiveUserGesture(event)) + ) { + return { + ok: false, + code: 'ACCESS_DENIED', + error: 'This local filesystem action requires an explicit user click.', + } + } + if ( + channel === 'desktop:local-filesystem' && + localFilesystemRequestNeedsToolAuthorization(args[0]) && + !(await authorizeLocalFilesystemTool(event, deps, args[0])) + ) { + return { + ok: false, + code: 'ACCESS_DENIED', + error: 'This local filesystem request is not an authorized pending Copilot tool call.', + } + } + if (spec.passSender) { + handlerArgs = [event.sender, ...handlerArgs] + } + return spec.handler(...handlerArgs) + }) + } else { + ipcMain.on(channel, (event, ...args) => { + if (senderAllowed(event, spec.gate) && featureAllowed(spec.requires)) { + spec.handler(...(spec.passSender ? [event.sender, ...args] : args)) + } + }) + } + } +} diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts new file mode 100644 index 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..ab8b9d4c141 --- /dev/null +++ b/apps/desktop/src/main/load-health.ts @@ -0,0 +1,154 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow } from 'electron' +import { type EventRecorder, scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopLoadHealth') + +const AUTO_RETRY_INTERVAL_MS = 5000 +const LOAD_WATCHDOG_MS = 30_000 + +export type LoadErrorKind = 'offline' | 'dns' | 'tls' | 'timeout' | 'unreachable' | 'ignored' + +/** + * Maps Chromium net error codes to recovery copy. -3 (ERR_ABORTED) is emitted + * constantly by OAuth redirect chains and in-app aborts and must be ignored. + */ +export function classifyLoadError(errorCode: number): LoadErrorKind { + // Only ERR_ABORTED (-3) and success (0) are ignored. ERR_FAILED (-2) and + // ERR_IO_PENDING (-1) are real failures that must surface the offline page. + if (errorCode === 0 || errorCode === -3) { + return 'ignored' + } + if (errorCode === -106) { + return 'offline' + } + if (errorCode === -105 || errorCode === -137) { + return 'dns' + } + if (errorCode === -7 || errorCode === -118) { + return 'timeout' + } + if (errorCode <= -200 && errorCode >= -213) { + return 'tls' + } + return 'unreachable' +} + +export interface LoadHealthDeps { + offlinePagePath: string + getStartUrl: () => string + isOnline: () => boolean + events: EventRecorder +} + +export interface LoadHealthHandle { + retry(): void + startWatchdog(): void +} + +/** + * Branded recovery for a fully remote renderer: on main-frame load failures + * the window swaps to the bundled offline page (a local file, never wrapping + * the origin), auto-retries when the network returns, and a first-paint + * watchdog catches servers that accept connections but never respond. + */ +export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): LoadHealthHandle { + let intendedUrl: string | null = null + let showingOffline = false + let retryTimer: NodeJS.Timeout | undefined + let watchdogTimer: NodeJS.Timeout | undefined + + const stopAutoRetry = () => { + clearInterval(retryTimer) + retryTimer = undefined + } + + const startWatchdog = () => { + clearTimeout(watchdogTimer) + watchdogTimer = setTimeout(() => { + if (!win.isDestroyed() && win.webContents.isLoading()) { + showOffline('timeout', 'The app took too long to load') + } + }, LOAD_WATCHDOG_MS) + } + + const retry = () => { + if (win.isDestroyed()) { + return + } + const target = intendedUrl ?? deps.getStartUrl() + logger.info('Retrying load', { url: scrubUrl(target) }) + // Re-arm before loading. The caller has already stopped auto-retry, so if + // this load hangs — the exact case the watchdog exists for — no load event + // ever fires and without this no timer is left to recover the window. + startWatchdog() + void win.loadURL(target) + } + + const startAutoRetry = () => { + if (retryTimer) { + return + } + retryTimer = setInterval(() => { + if (!showingOffline || win.isDestroyed()) { + stopAutoRetry() + return + } + if (deps.isOnline()) { + stopAutoRetry() + retry() + } + }, AUTO_RETRY_INTERVAL_MS) + } + + const showOffline = (kind: LoadErrorKind, detail: string) => { + if (win.isDestroyed()) { + return + } + showingOffline = true + deps.events.record('load_failure', { kind, detail }) + void win.loadFile(deps.offlinePagePath, { query: { kind, detail } }) + startAutoRetry() + } + + win.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return + } + clearTimeout(watchdogTimer) + const kind = classifyLoadError(errorCode) + if (kind === 'ignored') { + return + } + if (validatedURL?.startsWith('http')) { + intendedUrl = validatedURL + } + logger.warn('Main-frame load failed', { + kind, + errorCode, + errorDescription, + url: scrubUrl(validatedURL ?? ''), + }) + showOffline(kind, `${errorDescription} (${errorCode})`) + } + ) + + win.webContents.on('did-finish-load', () => { + clearTimeout(watchdogTimer) + const url = win.webContents.getURL() + if (url.startsWith('http')) { + showingOffline = false + intendedUrl = null + stopAutoRetry() + } + }) + + win.on('closed', () => { + stopAutoRetry() + clearTimeout(watchdogTimer) + }) + + return { retry, startWatchdog } +} diff --git a/apps/desktop/src/main/local-filesystem-grant-store.test.ts b/apps/desktop/src/main/local-filesystem-grant-store.test.ts new file mode 100644 index 00000000000..043a3577cb1 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem-grant-store.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' + +function testEncryption(available = true) { + return { + isEncryptionAvailable: vi.fn(() => available), + encryptString: vi.fn((value: string) => Buffer.from(`protected:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^protected:/, '')), + } +} + +describe('createEncryptedLocalFilesystemGrantStore', () => { + it('encrypts grants at rest and restores them', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const encryption = testEncryption() + const store = createEncryptedLocalFilesystemGrantStore(filePath, encryption) + const grants = [ + { + id: 'grant-1', + name: 'project', + rootPath: '/Users/example/private-project', + bookmark: 'security-scoped-bookmark', + }, + ] + + await expect(store.save(grants)).resolves.toBe(true) + + const raw = await readFile(filePath, 'utf8') + expect(raw).not.toContain(grants[0].rootPath) + expect(raw).not.toContain(grants[0].bookmark) + expect(encryption.encryptString).toHaveBeenCalledOnce() + await expect(store.load()).resolves.toEqual(grants) + + await store.clear() + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('does not write a plaintext fallback when OS encryption is unavailable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption(false)) + + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/apps/desktop/src/main/local-filesystem-grant-store.ts b/apps/desktop/src/main/local-filesystem-grant-store.ts new file mode 100644 index 00000000000..2a086e11251 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem-grant-store.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises' +import { safeStorage } from 'electron' +import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' + +const STORE_VERSION = 1 + +export interface PersistedLocalFilesystemGrant { + id: string + name: string + rootPath: string + bookmark?: string +} + +export interface LocalFilesystemGrantStore { + load(): Promise + save(grants: PersistedLocalFilesystemGrant[]): Promise + clear(): Promise +} + +interface EncryptionProvider { + isEncryptionAvailable(): boolean + encryptString(value: string): Buffer + decryptString(value: Buffer): string +} + +interface EncryptedGrantEnvelope { + version: typeof STORE_VERSION + ciphertext: string +} + +function isPersistedGrant(value: unknown): value is PersistedLocalFilesystemGrant { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const grant = value as Record + return ( + typeof grant.id === 'string' && + typeof grant.name === 'string' && + typeof grant.rootPath === 'string' && + (grant.bookmark === undefined || typeof grant.bookmark === 'string') + ) +} + +/** + * `safeStorage.isEncryptionAvailable()` throws rather than returning false on a + * Linux box with no keyring, and an unguarded call propagated out of grant + * persistence. Grants stay session-only when encryption is unavailable. + */ +function encryptionAvailable(encryption: EncryptionProvider): boolean { + try { + return encryption.isEncryptionAvailable() + } catch { + return false + } +} + +/** + * Stores host paths and optional macOS security-scoped bookmarks encrypted + * with Electron safeStorage (Keychain on macOS, DPAPI on Windows, and the + * desktop keyring on supported Linux environments). No plaintext fallback is + * used: when OS-backed encryption is unavailable, grants remain session-only. + */ +export function createEncryptedLocalFilesystemGrantStore( + filePath: string, + encryption: EncryptionProvider = safeStorage +): LocalFilesystemGrantStore { + return { + async load() { + if (!encryptionAvailable(encryption)) return [] + try { + const raw = JSON.parse(await readFile(filePath, 'utf8')) as Partial + if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') return [] + const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + const parsed = JSON.parse(decrypted) as unknown + return Array.isArray(parsed) ? parsed.filter(isPersistedGrant) : [] + } catch { + return [] + } + }, + + async save(grants) { + if (!encryptionAvailable(encryption)) return false + const encrypted = encryption.encryptString(JSON.stringify(grants)) + const envelope: EncryptedGrantEnvelope = { + version: STORE_VERSION, + ciphertext: encrypted.toString('base64'), + } + await writeJsonFileAtomically(filePath, envelope) + return true + }, + + async clear() { + await removeFileIfPresent(filePath) + }, + } +} diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts new file mode 100644 index 00000000000..e54418f11a7 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -0,0 +1,536 @@ +import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { LocalFilesystemMount, LocalFilesystemResponse } from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { shell } from 'electron' +import { LocalFilesystemService } from '@/main/local-filesystem' +import type { + LocalFilesystemGrantStore, + PersistedLocalFilesystemGrant, +} from '@/main/local-filesystem-grant-store' + +class MemoryGrantStore implements LocalFilesystemGrantStore { + grants: PersistedLocalFilesystemGrant[] = [] + + async load(): Promise { + return structuredClone(this.grants) + } + + async save(grants: PersistedLocalFilesystemGrant[]): Promise { + this.grants = structuredClone(grants) + return true + } + + async clear(): Promise { + this.grants = [] + } +} + +function dataOf(response: LocalFilesystemResponse) { + expect(response.ok).toBe(true) + if (!response.ok) throw new Error(response.error) + return response.data +} + +async function mount(service: LocalFilesystemService): Promise { + const data = dataOf(await service.handle({ operation: 'mount_directory' })) + if (!('mount' in data) || !data.mount) throw new Error('Expected a mounted directory') + return data.mount +} + +describe('LocalFilesystemService', () => { + let root: string + let service: LocalFilesystemService + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'sim-localfs-')) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'README.md'), 'hello world\nsecond line\n') + await writeFile(join(root, 'src', 'index.ts'), 'export const answer = 42\n') + service = new LocalFilesystemService({ + chooseDirectory: async () => root, + }) + }) + + it('returns opaque mount metadata without exposing the host path', async () => { + const granted = await mount(service) + expect(granted.uri).toMatch(/^localfs:\/\/[^/]+\/$/) + expect(granted).not.toHaveProperty('path') + + const listData = dataOf(await service.handle({ operation: 'list_mounts' })) + expect(listData).toEqual({ mounts: [granted] }) + }) + + it('refuses glob patterns that would be ruinously expensive to evaluate', async () => { + const granted = await mount(service) + // Micromatch backtracking is exponential in wildcard count. Measured + // against a single 46-character path, the 10-wildcard pattern below took + // 2.7s and a 12-wildcard one 43s — per scanned entry, in one synchronous + // call that no abort check can interrupt, on the main process. + const pathological = ['**/*a*a*a*a*a*b', '**/*a*a*a*a*a*a*a*b', '*'.repeat(40)] + + for (const pattern of pathological) { + const response = await service.handle({ operation: 'glob', uri: granted.uri, pattern }) + expect(response.ok).toBe(false) + } + }) + + it('reports an invalid grep regex instead of claiming there are no matches', async () => { + const granted = await mount(service) + + const response = await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: '([unclosed', + }) + + // Returning an empty match set would tell the model the string appears + // nowhere in the user's files, which it would then act on as fact. + expect(response.ok).toBe(false) + }) + + it('still accepts the glob patterns people actually write', async () => { + const granted = await mount(service) + + for (const pattern of ['**/*.ts', 'src/**/*.tsx', '**/node_modules/**', '**/*spec*']) { + const response = await service.handle({ operation: 'glob', uri: granted.uri, pattern }) + expect(response.ok).toBe(true) + } + }) + + it('lists, reads, globs, greps, and stats inside the selected directory', async () => { + const granted = await mount(service) + + const listData = dataOf(await service.handle({ operation: 'list', uri: granted.uri })) + expect('entries' in listData && listData.entries.map((entry) => entry.name)).toEqual([ + 'README.md', + 'src', + ]) + + const readData = dataOf( + await service.handle({ + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 2, + lineCount: 1, + }) + ) + expect(readData).toMatchObject({ content: 'second line', startLine: 2, endLine: 2 }) + + const globData = dataOf( + await service.handle({ operation: 'glob', uri: granted.uri, pattern: '**/*.ts' }) + ) + expect( + 'entries' in globData && globData.entries.map((entry) => entry.uri.replace(granted.uri, '')) + ).toEqual(['src/index.ts']) + + const grepData = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + query: 'ANSWER', + include: '**/*.ts', + }) + ) + expect(grepData).toMatchObject({ + matches: [{ line: 1, text: 'export const answer = 42' }], + }) + + const statData = dataOf( + await service.handle({ operation: 'stat', uri: `${granted.uri}src/index.ts` }) + ) + expect(statData).toMatchObject({ name: 'index.ts', kind: 'file' }) + }) + + it('supports the normal VFS grep regex and output modes', async () => { + const granted = await mount(service) + + const content = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'hello|answer\\s*=\\s*42', + outputMode: 'content', + caseSensitive: true, + maxResults: 10, + }) + ) + expect(content).toMatchObject({ + matches: [ + { uri: `${granted.uri}README.md`, line: 1, text: 'hello world' }, + { uri: `${granted.uri}src/index.ts`, line: 1, text: 'export const answer = 42' }, + ], + }) + + const files = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'second line', + outputMode: 'files_with_matches', + }) + ) + expect(files).toEqual({ files: [`${granted.uri}README.md`], truncated: false }) + + const counts = dataOf( + await service.handle({ + operation: 'grep', + uri: `${granted.uri}README.md`, + pattern: 'line', + outputMode: 'count', + }) + ) + expect(counts).toEqual({ + counts: [{ uri: `${granted.uri}README.md`, count: 1 }], + truncated: false, + }) + }) + + it('rejects grep regexes with catastrophic-backtracking risk', async () => { + const granted = await mount(service) + await expect( + service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: '(a+)+$', + }) + ).resolves.toMatchObject({ + ok: false, + code: 'INVALID_REQUEST', + error: expect.stringContaining('catastrophic backtracking'), + }) + }) + + it('cancels a native scan by request id', async () => { + const granted = await mount(service) + for (let index = 0; index < 200; index++) { + await writeFile(join(root, `file-${index}.txt`), `line ${index}\n`) + } + + const pending = service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'never-matches', + requestId: 'tool-abort', + }) + const cancelled = dataOf(await service.handle({ operation: 'cancel', requestId: 'tool-abort' })) + expect(cancelled).toEqual({ cancelled: true }) + await expect(pending).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + }) + + it('does not expose a raw-byte read operation', async () => { + const granted = await mount(service) + await expect( + service.handle({ operation: 'read_file_bytes', uri: `${granted.uri}README.md` }) + ).resolves.toMatchObject({ ok: false, code: 'INVALID_REQUEST' }) + }) + + it('authorizes a request whose omitted args resolved to the shared defaults', async () => { + // The failure mode this guards is silent: with an arg omitted there is no + // value on the wire to disagree about, only two defaulting tables — the + // renderer's, resolving what to send, and the authorizer's, resolving what + // to expect. If they drift, a legitimate tool call is DENIED rather than + // erroring. Both now read these from @sim/desktop-bridge; this pins the + // authorizer half to them. + const granted = await mount(service) + const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 1, + lineCount: DEFAULT_READ_LINES, + requestId: 'read-defaults', + }, + { toolName: 'read', args: { path: `${vfsRoot}/README.md` } } + ) + ).toBe(true) + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: true, + outputMode: 'content', + lineNumbers: true, + context: DEFAULT_GREP_CONTEXT, + maxResults: DEFAULT_GREP_RESULTS, + requestId: 'grep-defaults', + }, + { toolName: 'grep', args: { path: 'user-local', pattern: 'TODO' } } + ) + ).toBe(true) + }) + + it('binds privileged client reads and searches to server-persisted tool args', async () => { + const granted = await mount(service) + const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}README.md`, + startLine: 3, + lineCount: 25, + requestId: 'read-tool', + }, + { + toolName: 'read', + args: { path: `${vfsRoot}/README.md`, offset: 2, limit: 25 }, + } + ) + ).toBe(true) + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'read', + uri: `${granted.uri}src/index.ts`, + startLine: 3, + lineCount: 25, + requestId: 'read-tool', + }, + { + toolName: 'read', + args: { path: `${vfsRoot}/README.md`, offset: 2, limit: 25 }, + } + ) + ).toBe(false) + + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'glob', + uri: granted.uri, + pattern: `${vfsRoot}/**/*.ts`, + pathPrefix: vfsRoot, + requestId: 'glob-tool', + }, + { toolName: 'glob', args: { pattern: `${vfsRoot}/**/*.ts` } } + ) + ).toBe(true) + + const grepAuthorization = { + toolName: 'grep', + args: { + path: 'user-local', + pattern: 'TODO', + ignoreCase: true, + output_mode: 'files_with_matches', + maxResults: 20, + }, + } + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + }, + grepAuthorization + ) + ).toBe(true) + expect( + service.isAuthorizedClientToolRequest( + { + operation: 'grep', + uri: granted.uri, + pattern: 'PASSWORD', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + }, + grepAuthorization + ) + ).toBe(false) + + const authorizedGrepRequest = { + operation: 'grep', + uri: granted.uri, + pattern: 'TODO', + caseSensitive: false, + outputMode: 'files_with_matches', + lineNumbers: true, + context: 0, + maxResults: 20, + requestId: 'grep-tool', + } + + // A tool call whose args carry no pattern once made the comparison + // `undefined !== undefined`, so the guard passed and grep fell back to + // searching the renderer's own `query`. + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, pattern: undefined, query: 'PASSWORD' }, + { toolName: 'grep', args: { ...grepAuthorization.args, pattern: undefined } } + ) + ).toBe(false) + + // `query` and `include` are read by grep() but never sent by the authorized + // path, so smuggling either widens or silently narrows the search. + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, query: 'PASSWORD' }, + grepAuthorization + ) + ).toBe(false) + expect( + service.isAuthorizedClientToolRequest( + { ...authorizedGrepRequest, include: '**/nothing-here/**' }, + grepAuthorization + ) + ).toBe(false) + }) + + it('rejects unknown mounts and symlinks that escape the selected directory', async () => { + const granted = await mount(service) + const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) + await writeFile(join(outside, 'secret.txt'), 'secret') + await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) + + const missingMount = await service.handle({ + operation: 'read', + uri: 'localfs://not-granted/file.txt', + }) + expect(missingMount).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) + + const escaped = await service.handle({ + operation: 'read', + uri: `${granted.uri}secret-link.txt`, + }) + expect(escaped).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + }) + + it('rejects lexical traversal before URL normalization can reinterpret it', async () => { + const granted = await mount(service) + const traversal = await service.handle({ + operation: 'read', + uri: `${granted.uri}../README.md`, + }) + + expect(traversal).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + }) + + it('reads a child whose name merely starts with dots', async () => { + // The containment check compares path SEGMENTS. Testing the two leading + // characters instead denies real files: `..config` is an ordinary name, + // not a walk out of the root. + await writeFile(join(root, '..config'), 'kept\n') + const granted = await mount(service) + + const response = await service.handle({ + operation: 'read', + uri: `${granted.uri}..config`, + }) + + expect(response.ok).toBe(true) + }) + + it('clears all grants without touching files on disk', async () => { + const granted = await mount(service) + service.close() + + const response = await service.handle({ operation: 'stat', uri: granted.uri }) + expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) + }) + + it('restores an encrypted grant with the same opaque URI after restart', async () => { + const grantStore = new MemoryGrantStore() + const firstStopAccessing = vi.fn() + const firstService = new LocalFilesystemService({ + chooseDirectory: async () => ({ path: root, bookmark: 'bookmark' }), + grantStore, + startAccessingBookmark: () => firstStopAccessing, + }) + + const granted = await mount(firstService) + const canonicalRoot = await realpath(root) + expect(granted).toMatchObject({ remembered: true }) + expect(grantStore.grants).toMatchObject([ + { id: granted.id, rootPath: canonicalRoot, bookmark: 'bookmark' }, + ]) + + firstService.close() + expect(firstStopAccessing).toHaveBeenCalledOnce() + + const restoredStopAccessing = vi.fn() + const restoredService = new LocalFilesystemService({ + grantStore, + startAccessingBookmark: () => restoredStopAccessing, + }) + await restoredService.initialize() + + const listData = dataOf(await restoredService.handle({ operation: 'list_mounts' })) + expect(listData).toEqual({ mounts: [granted] }) + const statData = dataOf( + await restoredService.handle({ + operation: 'stat', + uri: `${granted.uri}README.md`, + }) + ) + expect(statData).toMatchObject({ name: 'README.md', kind: 'file' }) + + const forgotten = dataOf( + await restoredService.handle({ operation: 'forget_mount', uri: granted.uri }) + ) + expect(forgotten).toEqual({ forgotten: true }) + expect(restoredStopAccessing).toHaveBeenCalledOnce() + expect(grantStore.grants).toEqual([]) + + const nextLaunch = new LocalFilesystemService({ grantStore }) + await nextLaunch.initialize() + expect(dataOf(await nextLaunch.handle({ operation: 'list_mounts' }))).toEqual({ mounts: [] }) + }) + + it('keeps a grant session-only when secure persistence is unavailable', async () => { + const grantStore: LocalFilesystemGrantStore = { + load: async () => [], + save: async () => false, + clear: async () => {}, + } + const sessionService = new LocalFilesystemService({ + chooseDirectory: async () => root, + grantStore, + }) + + expect(await mount(sessionService)).toMatchObject({ remembered: false }) + }) + + it('shows a granted folder in the file manager, and only a granted one', async () => { + const granted = await mount(service) + + expect(dataOf(await service.handle({ operation: 'reveal_mount', uri: granted.uri }))).toEqual({ + revealed: true, + }) + expect(shell.showItemInFolder).toHaveBeenCalledWith(await realpath(root)) + + const unknown = await service.handle({ + operation: 'reveal_mount', + uri: 'localfs://not-a-mount/', + }) + expect(unknown.ok).toBe(false) + expect(shell.showItemInFolder).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts new file mode 100644 index 00000000000..0b115133a00 --- /dev/null +++ b/apps/desktop/src/main/local-filesystem.ts @@ -0,0 +1,1140 @@ +import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { basename, isAbsolute, relative, resolve, sep } from 'node:path' +import type { + LocalFilesystemData, + LocalFilesystemEntry, + LocalFilesystemEntryKind, + LocalFilesystemGrepMatch, + LocalFilesystemMount, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, + MAX_GREP_CONTEXT, + MAX_GREP_RESULTS, + MAX_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { app, dialog, shell } from 'electron' +import micromatch from 'micromatch' +import safeRegex from 'safe-regex2' +import type { + LocalFilesystemGrantStore, + PersistedLocalFilesystemGrant, +} from '@/main/local-filesystem-grant-store' + +const MAX_URI_LENGTH = 4096 +const MAX_LIST_ENTRIES = 500 +const MAX_SCAN_ENTRIES = 10_000 +const MAX_SCAN_DEPTH = 50 +const MAX_GLOB_RESULTS = 500 +const MAX_GLOB_LENGTH = 128 +/** + * Measured cost of one match against a single 46-character path: six wildcards + * 1.8ms, eight 96ms, ten 2.7s, twelve 43s. Six keeps the worst case around 2ms + * per scanned entry while leaving headroom over real patterns, which top out + * around four (`**\/node_modules\/**`, `**\/*spec*`). + */ +const MAX_GLOB_WILDCARDS = 6 +/** + * Backstop only. Deliberately far above the ~0.1ms real patterns cost, because + * elapsed time varies with JIT warmth and machine load — a tight budget here + * rejects legitimate patterns on a busy machine and accepts bad ones on an idle + * one. {@link MAX_GLOB_WILDCARDS} is the deterministic bound. + */ +const GLOB_PROBE_BUDGET_MS = 100 +/** Repeated-literal path that provokes backtracking in a pathological glob. */ +const GLOB_PROBE_PATH = `${'a'.repeat(32)}/${'a'.repeat(32)}.txt` +const MAX_TEXT_FILE_BYTES = 5 * 1024 * 1024 +const MAX_GREP_SCAN_BYTES = 100 * 1024 * 1024 +const MAX_GREP_LINE_LENGTH = 500 +const REQUEST_ID_PATTERN = /^[^\x00-\x1f\x7f]{1,256}$/ + +type LocalFilesystemErrorCode = Extract['code'] + +class LocalFilesystemError extends Error { + constructor( + public readonly code: LocalFilesystemErrorCode, + message: string + ) { + super(message) + this.name = 'LocalFilesystemError' + } +} + +interface GrantedMount extends LocalFilesystemMount { + rootPath: string + bookmark?: string + stopAccessing?: () => void +} + +interface ResolvedLocalPath { + mount: GrantedMount + relativePath: string + lexicalPath: string + realPath: string +} + +interface LocalFilesystemServiceOptions { + chooseDirectory?: () => Promise + grantStore?: LocalFilesystemGrantStore + startAccessingBookmark?: (bookmark: string) => (() => void) | undefined +} + +interface SelectedDirectory { + path: string + bookmark?: string +} + +export interface LocalFilesystemToolAuthorization { + toolName: string + args: Record +} + +/** + * Whether a resolved path is the granted root or sits beneath it. + * + * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel + * that form needs to reject `/granted-evil` against `/granted` becomes `//` when + * the root IS a separator — and the picker lets the user grant a volume root — + * so every path under a granted `/` was denied while the bare root passed. + * `relative()` has no such edge case: it returns `''` for the root itself and a + * leading `..` segment for anything outside, on both separators. + */ +function isWithinRoot(rootPath: string, candidatePath: string): boolean { + const rel = relative(rootPath, candidatePath) + if (rel === '') return true + // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary + // child name, and matching it as an escape would deny a real dotfile. + if (rel === '..' || rel.startsWith(`..${sep}`)) return false + // A different Windows volume relativizes to an absolute path rather than a + // `..` walk, so it has to be rejected separately. + return !isAbsolute(rel) +} + +function entryKind(entry: { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean +}): LocalFilesystemEntryKind { + if (entry.isFile()) return 'file' + if (entry.isDirectory()) return 'directory' + if (entry.isSymbolicLink()) return 'symlink' + return 'other' +} + +function encodeUriPath(relativePath: string): string { + return relativePath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') +} + +function localUri(mountId: string, relativePath = ''): string { + const encodedPath = encodeUriPath(relativePath) + return `localfs://${mountId}/${encodedPath}` +} + +function normalizeVfsDisplaySegment(segment: string): string { + return segment + .normalize('NFC') + .trim() + .replace(/[\x00-\x1f\x7f]/g, '') + .replace(/\s+/g, ' ') +} + +function mountVfsRoot(mount: GrantedMount): string { + return `user-local/${encodeURIComponent(normalizeVfsDisplaySegment(mount.name))}--${mount.id}` +} + +function parsePositiveInteger(value: unknown, name: string, fallback: number, max: number): number { + if (value === undefined) return fallback + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > max) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `${name} must be an integer between 1 and ${max}.` + ) + } + return value as number +} + +/** + * Compiles a glob, refusing patterns that would be ruinously expensive to + * evaluate. + * + * Micromatch produces a backtracking regex whose cost grows exponentially with + * the number of wildcards separated by literals. Measured against a single + * 46-character path with the options below, `**\/*a*a*a*a*a*a*a*b` takes 2.7s + * and two more wildcards take 43s. The matcher runs once per scanned entry, up + * to {@link MAX_SCAN_ENTRIES}, inside one synchronous call — so the abort + * checks around it never get a turn and an unbounded pattern freezes the whole + * main process, taking every window, the menu bar and the tray with it. + * `safeRegex` does not catch this: it reports the generated source as safe. + * + * The wildcard cap is the real bound and is deterministic. The probe is a + * backstop for a shape the cap does not anticipate, with a budget loose enough + * that timing variance cannot make it fire on a legitimate pattern. + */ +function compileGlob(pattern: string): (path: string) => boolean { + if ( + !pattern || + pattern.length > MAX_GLOB_LENGTH || + pattern.includes('\0') || + pattern.includes('\\') + ) { + throw new LocalFilesystemError('INVALID_REQUEST', 'Glob pattern is invalid.') + } + if (isAbsolute(pattern) || pattern.split('/').some((segment) => segment === '..')) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Glob patterns must stay within the selected directory.' + ) + } + const wildcards = (pattern.match(/[*?]/g) ?? []).length + if (wildcards > MAX_GLOB_WILDCARDS) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `Glob patterns may use at most ${MAX_GLOB_WILDCARDS} wildcards.` + ) + } + + const matcher = micromatch.matcher(pattern, { + bash: false, + dot: false, + windows: false, + nobrace: true, + noext: true, + }) + + const startedAt = performance.now() + matcher(GLOB_PROBE_PATH) + if (performance.now() - startedAt > GLOB_PROBE_BUDGET_MS) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Glob pattern is too expensive to evaluate. Use fewer wildcards.' + ) + } + return matcher +} + +function isBinary(buffer: Uint8Array): boolean { + const sampleLength = Math.min(buffer.length, 8192) + for (let index = 0; index < sampleLength; index++) { + if (buffer[index] === 0) return true + } + return false +} + +function safeError(error: unknown): LocalFilesystemError { + if (error instanceof LocalFilesystemError) return error + if (error instanceof DOMException && error.name === 'AbortError') { + return new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } + if (error instanceof Error && error.name === 'AbortError') { + return new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : '' + if (code === 'ENOENT') { + return new LocalFilesystemError('NOT_FOUND', 'The local file or directory was not found.') + } + if (code === 'EACCES' || code === 'EPERM') { + return new LocalFilesystemError('ACCESS_DENIED', 'The operating system denied access.') + } + return new LocalFilesystemError('IO_ERROR', 'The local filesystem operation failed.') +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') + } +} + +export class LocalFilesystemService { + private readonly mounts = new Map() + private readonly activeRequests = new Map() + private readonly chooseDirectory: () => Promise + private readonly grantStore?: LocalFilesystemGrantStore + private readonly startAccessingBookmark: (bookmark: string) => (() => void) | undefined + private initializePromise?: Promise + + constructor(options: LocalFilesystemServiceOptions = {}) { + this.grantStore = options.grantStore + this.startAccessingBookmark = + options.startAccessingBookmark ?? + ((bookmark) => { + try { + return app.startAccessingSecurityScopedResource(bookmark) as () => void + } catch { + return undefined + } + }) + this.chooseDirectory = + options.chooseDirectory ?? + (async () => { + const result = await dialog.showOpenDialog({ + title: 'Allow Sim to read a folder', + buttonLabel: 'Allow', + properties: ['openDirectory'], + ...(process.platform === 'darwin' ? { securityScopedBookmarks: true } : {}), + }) + if (result.canceled || !result.filePaths[0]) return null + return { + path: result.filePaths[0], + ...(result.bookmarks?.[0] ? { bookmark: result.bookmarks[0] } : {}), + } + }) + } + + /** + * Restore encrypted grants after Electron is ready. Invalid, moved, or + * OS-revoked directories are skipped without exposing their host paths. + */ + initialize(): Promise { + return (this.initializePromise ??= this.restoreRememberedMounts()) + } + + /** Release active OS handles while keeping encrypted grants for next launch. */ + close(): void { + for (const controller of this.activeRequests.values()) { + controller.abort() + } + this.activeRequests.clear() + for (const mount of this.mounts.values()) { + mount.stopAccessing?.() + } + this.mounts.clear() + } + + /** Revoke every remembered grant, used on sign-out and origin changes. */ + async forgetAll(): Promise { + this.close() + await this.grantStore?.clear() + } + + async handle(request: unknown): Promise { + try { + if (!isRecordLike(request) || typeof request.operation !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'Local filesystem request is invalid.') + } + + if (request.operation === 'cancel') { + const requestId = this.requiredRequestId(request) + const controller = this.activeRequests.get(requestId) + controller?.abort() + return { ok: true, data: { cancelled: controller !== undefined } } + } + + const requestId = + request.requestId === undefined ? undefined : this.requiredRequestId(request) + if (requestId && this.activeRequests.has(requestId)) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'A local filesystem operation with that request id is already running.' + ) + } + const controller = requestId ? new AbortController() : undefined + if (requestId && controller) { + this.activeRequests.set(requestId, controller) + } + + let data: LocalFilesystemData + try { + switch (request.operation) { + case 'mount_directory': + data = await this.mountDirectory() + break + case 'list_mounts': + data = this.listMounts() + break + case 'forget_mount': + data = await this.forgetMount(this.requiredUri(request)) + break + case 'reveal_mount': + data = this.revealMount(this.requiredUri(request)) + break + case 'list': + data = await this.listDirectory(this.requiredUri(request)) + break + case 'glob': + data = await this.glob( + this.requiredUri(request), + this.requiredString(request, 'pattern'), + request.pathPrefix, + controller?.signal + ) + break + case 'read': + data = await this.readText( + this.requiredUri(request), + request.startLine, + request.lineCount, + controller?.signal + ) + break + case 'grep': + data = await this.grep(this.requiredUri(request), request, controller?.signal) + break + case 'stat': + data = await this.statPath(this.requiredUri(request)) + break + default: + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'Local filesystem operation is not supported.' + ) + } + } finally { + if (requestId) { + this.activeRequests.delete(requestId) + } + } + return { ok: true, data } + } catch (error) { + const safe = safeError(error) + return { ok: false, code: safe.code, error: safe.message } + } + } + + /** + * Bind a privileged read/search request to the canonical args persisted for + * one authenticated pending client tool call. Renderer code chooses neither + * a different operation nor a different granted path. + */ + isAuthorizedClientToolRequest( + request: unknown, + authorization: LocalFilesystemToolAuthorization + ): boolean { + if (!isRecordLike(request) || typeof request.operation !== 'string') return false + if ( + typeof request.requestId !== 'string' || + request.requestId.length === 0 || + !REQUEST_ID_PATTERN.test(request.requestId) + ) { + return false + } + const args = authorization.args + + const expectedUriForPath = (path: unknown): string | null => { + if (typeof path !== 'string') return null + for (const mount of this.mounts.values()) { + const root = mountVfsRoot(mount) + if (path === root) return mount.uri + if (path.startsWith(`${root}/`)) { + return `${mount.uri}${path.slice(root.length + 1)}` + } + } + return null + } + + switch (authorization.toolName) { + case 'read': { + if (request.operation !== 'read') return false + const expectedUri = expectedUriForPath(args.path) + const offset = + typeof args.offset === 'number' && Number.isFinite(args.offset) + ? Math.max(0, Math.trunc(args.offset)) + : 0 + const limit = + typeof args.limit === 'number' && Number.isFinite(args.limit) + ? Math.min(MAX_READ_LINES, Math.max(1, Math.trunc(args.limit))) + : DEFAULT_READ_LINES + return ( + expectedUri !== null && + request.uri === expectedUri && + request.startLine === offset + 1 && + request.lineCount === limit + ) + } + case 'grep': { + // `typeof` first, mirroring the glob case below: without it, a tool + // call whose args carry no pattern makes this `undefined !== undefined` + // and the guard passes. + if ( + request.operation !== 'grep' || + typeof args.pattern !== 'string' || + request.pattern !== args.pattern + ) { + return false + } + // `grep()` falls back to `query` when `pattern` is absent, and narrows + // the file set by `include`. The authorized path sends neither, so a + // request carrying them is the renderer searching for something the + // model did not ask for, or hiding results it believes are complete. + if (request.query !== undefined || request.include !== undefined) return false + const rawPath = typeof args.path === 'string' ? args.path.replace(/\/+$/, '') : '' + const uriAllowed = + rawPath === 'user-local' + ? [...this.mounts.values()].some((mount) => request.uri === mount.uri) + : request.uri === expectedUriForPath(rawPath) + const outputMode = + args.output_mode === 'files_with_matches' || args.output_mode === 'count' + ? args.output_mode + : 'content' + const maxResults = + typeof args.maxResults === 'number' && Number.isFinite(args.maxResults) + ? Math.min(MAX_GREP_RESULTS, Math.max(1, Math.trunc(args.maxResults))) + : DEFAULT_GREP_RESULTS + const context = + typeof args.context === 'number' && Number.isFinite(args.context) + ? Math.min(MAX_GREP_CONTEXT, Math.max(0, Math.trunc(args.context))) + : DEFAULT_GREP_CONTEXT + return ( + uriAllowed && + request.caseSensitive === (args.ignoreCase !== true) && + request.maxResults === maxResults && + request.outputMode === outputMode && + request.lineNumbers === (args.lineNumbers !== false) && + request.context === context + ) + } + case 'glob': { + if ( + request.operation !== 'glob' || + typeof args.pattern !== 'string' || + request.pattern !== args.pattern + ) { + return false + } + for (const mount of this.mounts.values()) { + if (request.uri !== mount.uri) continue + return request.pathPrefix === mountVfsRoot(mount) + } + return false + } + default: + return false + } + } + + private requiredRequestId(request: Record): string { + const value = request.requestId + if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) { + throw new LocalFilesystemError('INVALID_REQUEST', 'requestId is invalid.') + } + return value + } + + private requiredUri(request: Record): string { + return this.requiredString(request, 'uri', MAX_URI_LENGTH) + } + + private requiredString(request: Record, key: string, maxLength = 1000): string { + const value = request[key] + if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) { + throw new LocalFilesystemError('INVALID_REQUEST', `${key} is required.`) + } + return value + } + + private async mountDirectory(): Promise { + const selection = await this.chooseDirectory() + if (!selection) return { mount: null, cancelled: true } + + const selected = typeof selection === 'string' ? { path: selection } : selection + const stopAccessing = selected.bookmark + ? this.startAccessingBookmark(selected.bookmark) + : undefined + + try { + const rootPath = await realpath(selected.path) + const rootStat = await stat(rootPath) + if (!rootStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') + } + + const existing = [...this.mounts.values()].find((mount) => mount.rootPath === rootPath) + const id = existing?.id ?? generateId() + const bookmark = selected.bookmark ?? existing?.bookmark + const nextStopAccessing = selected.bookmark + ? stopAccessing + : (existing?.stopAccessing ?? + (bookmark ? this.startAccessingBookmark(bookmark) : undefined)) + if (selected.bookmark) { + existing?.stopAccessing?.() + } + const mount: GrantedMount = { + id, + name: basename(rootPath) || 'Local files', + uri: localUri(id), + rootPath, + remembered: existing?.remembered ?? false, + ...(bookmark ? { bookmark } : {}), + ...(nextStopAccessing ? { stopAccessing: nextStopAccessing } : {}), + } + this.mounts.set(id, mount) + mount.remembered = await this.persistMounts() + return { mount: this.publicMount(mount), cancelled: false } + } catch (error) { + stopAccessing?.() + throw error + } + } + + private listMounts(): LocalFilesystemData { + return { mounts: [...this.mounts.values()].map((mount) => this.publicMount(mount)) } + } + + private publicMount(mount: GrantedMount): LocalFilesystemMount { + return { + id: mount.id, + name: mount.name, + uri: mount.uri, + remembered: mount.remembered, + } + } + + private async restoreRememberedMounts(): Promise { + if (!this.grantStore) return + const grants = await this.grantStore.load() + let skipped = false + + for (const grant of grants) { + if (!/^[a-zA-Z0-9-]{1,128}$/.test(grant.id) || this.mounts.has(grant.id)) { + skipped = true + continue + } + const stopAccessing = grant.bookmark ? this.startAccessingBookmark(grant.bookmark) : undefined + try { + const rootPath = await realpath(grant.rootPath) + const rootStat = await stat(rootPath) + if (!rootStat.isDirectory()) { + stopAccessing?.() + skipped = true + continue + } + this.mounts.set(grant.id, { + id: grant.id, + name: basename(rootPath) || grant.name || 'Local files', + uri: localUri(grant.id), + rootPath, + remembered: true, + ...(grant.bookmark ? { bookmark: grant.bookmark } : {}), + ...(stopAccessing ? { stopAccessing } : {}), + }) + } catch { + stopAccessing?.() + skipped = true + } + } + + if (skipped) { + await this.persistMounts() + } + } + + private persistedMounts(): PersistedLocalFilesystemGrant[] { + return [...this.mounts.values()].map((mount) => ({ + id: mount.id, + name: mount.name, + rootPath: mount.rootPath, + ...(mount.bookmark ? { bookmark: mount.bookmark } : {}), + })) + } + + private async persistMounts(): Promise { + if (!this.grantStore) return false + try { + if (this.mounts.size === 0) { + await this.grantStore.clear() + return true + } + const remembered = await this.grantStore.save(this.persistedMounts()) + if (remembered) { + for (const mount of this.mounts.values()) { + mount.remembered = true + } + } + return remembered + } catch { + return false + } + } + + private async forgetMount(uri: string): Promise { + const { mount } = this.parseUri(uri) + mount.stopAccessing?.() + this.mounts.delete(mount.id) + + const persisted = await this.persistMounts() + if (!persisted && this.grantStore) { + // Fail closed: if an updated encrypted grant set cannot be written, + // remove the store so a revoked mount cannot return after restart. + await this.grantStore.clear() + for (const remaining of this.mounts.values()) { + remaining.remembered = false + } + } + return { forgotten: true } + } + + /** + * Opens the grant's root in the OS file manager. Only a URI that resolves to + * a live grant can be revealed, so this exposes nothing the renderer could not + * already read. + */ + private revealMount(uri: string): LocalFilesystemData { + const { mount } = this.parseUri(uri) + shell.showItemInFolder(mount.rootPath) + return { revealed: true } + } + + private parseUri(uri: string): { mount: GrantedMount; relativePath: string } { + if (!uri.startsWith('localfs://')) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + const rawPathSegments = uri.slice('localfs://'.length).split('/').slice(1) + for (const rawSegment of rawPathSegments) { + let decodedSegment: string + try { + decodedSegment = decodeURIComponent(rawSegment) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if (decodedSegment === '.' || decodedSegment === '..') { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + } + + let parsed: URL + try { + parsed = new URL(uri) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if ( + parsed.protocol !== 'localfs:' || + !parsed.hostname || + parsed.username || + parsed.password || + parsed.port || + parsed.search || + parsed.hash + ) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + + const mount = this.mounts.get(parsed.hostname) + if (!mount) { + throw new LocalFilesystemError( + 'MOUNT_NOT_FOUND', + 'That local folder is no longer available. Select it again.' + ) + } + + const encodedSegments = parsed.pathname.split('/').filter(Boolean) + const segments = encodedSegments.map((segment) => { + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + if ( + !decoded || + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('\0') + ) { + throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') + } + return decoded + }) + return { mount, relativePath: segments.join('/') } + } + + private async resolveUri(uri: string): Promise { + const { mount, relativePath } = this.parseUri(uri) + const lexicalPath = resolve(mount.rootPath, ...relativePath.split('/').filter(Boolean)) + if (!isWithinRoot(mount.rootPath, lexicalPath)) { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + const realPath = await realpath(lexicalPath) + if (!isWithinRoot(mount.rootPath, realPath)) { + throw new LocalFilesystemError( + 'ACCESS_DENIED', + 'The requested path is outside the selected folder.' + ) + } + return { mount, relativePath, lexicalPath, realPath } + } + + private async listDirectory(uri: string): Promise { + const resolvedPath = await this.resolveUri(uri) + const directoryStat = await stat(resolvedPath.realPath) + if (!directoryStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') + } + + const directoryEntries = await readdir(resolvedPath.realPath, { withFileTypes: true }) + directoryEntries.sort((a, b) => a.name.localeCompare(b.name)) + const truncated = directoryEntries.length > MAX_LIST_ENTRIES + // `allSettled`, so one entry disappearing mid-read does not fail the whole + // listing. Build output, downloads and caches churn constantly, and a + // single ENOENT should drop that row rather than the directory. + const settled = await Promise.allSettled( + directoryEntries.slice(0, MAX_LIST_ENTRIES).map(async (directoryEntry) => { + const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] + .filter(Boolean) + .join('/') + const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) + const item: LocalFilesystemEntry = { + name: directoryEntry.name, + uri: localUri(resolvedPath.mount.id, childRelativePath), + kind: entryKind(directoryEntry), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + return item + }) + ) + const entries = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ) + return { entries, truncated } + } + + private async glob( + uri: string, + pattern: string, + rawPathPrefix?: unknown, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + if (rawPathPrefix !== undefined && typeof rawPathPrefix !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'pathPrefix must be a string.') + } + const pathPrefix = typeof rawPathPrefix === 'string' ? rawPathPrefix.replace(/\/+$/, '') : '' + const matcher = compileGlob(pattern) + const resolvedPath = await this.resolveUri(uri) + const baseStat = await stat(resolvedPath.realPath) + if (!baseStat.isDirectory()) { + throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') + } + + const entries: LocalFilesystemEntry[] = [] + let scanned = 0 + let truncated = false + const stack = [{ path: resolvedPath.realPath, relativeFromBase: '', depth: 0 }] + + while (stack.length > 0 && !truncated) { + throwIfAborted(signal) + const current = stack.pop() + if (!current) break + const children = await readdir(current.path, { withFileTypes: true }) + children.sort((a, b) => b.name.localeCompare(a.name)) + + for (const child of children) { + throwIfAborted(signal) + scanned++ + if (scanned > MAX_SCAN_ENTRIES) { + truncated = true + break + } + const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') + const childPath = resolve(current.path, child.name) + const mountRelativePath = [resolvedPath.relativePath, relativeFromBase] + .filter(Boolean) + .join('/') + + const candidatePath = pathPrefix ? `${pathPrefix}/${relativeFromBase}` : relativeFromBase + if (matcher(candidatePath)) { + const metadata = await lstat(childPath) + entries.push({ + name: child.name, + uri: localUri(resolvedPath.mount.id, mountRelativePath), + kind: entryKind(child), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + }) + if (entries.length >= MAX_GLOB_RESULTS) { + truncated = true + break + } + } + + if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { + stack.push({ + path: childPath, + relativeFromBase, + depth: current.depth + 1, + }) + } + } + } + + entries.sort((a, b) => a.uri.localeCompare(b.uri)) + return { entries, truncated } + } + + private async readText( + uri: string, + rawStartLine: unknown, + rawLineCount: unknown, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + const startLine = parsePositiveInteger(rawStartLine, 'startLine', 1, Number.MAX_SAFE_INTEGER) + const lineCount = parsePositiveInteger( + rawLineCount, + 'lineCount', + DEFAULT_READ_LINES, + MAX_READ_LINES + ) + const resolvedPath = await this.resolveUri(uri) + const fileStat = await stat(resolvedPath.realPath) + if (!fileStat.isFile()) { + throw new LocalFilesystemError('NOT_A_FILE', 'The localfs URI is not a file.') + } + if (fileStat.size > MAX_TEXT_FILE_BYTES) { + throw new LocalFilesystemError( + 'FILE_TOO_LARGE', + 'The file is too large to read through user-local/.' + ) + } + + const buffer = await readFile(resolvedPath.realPath, { signal }) + throwIfAborted(signal) + if (isBinary(buffer)) { + throw new LocalFilesystemError( + 'BINARY_FILE', + 'The file is binary and cannot be read through user-local/.' + ) + } + const content = new TextDecoder().decode(buffer) + const lines = content.length === 0 ? [] : content.split(/\r?\n/) + const selectedLines = lines.slice(startLine - 1, startLine - 1 + lineCount) + const endLine = selectedLines.length === 0 ? 0 : startLine + selectedLines.length - 1 + return { + uri, + content: selectedLines.join('\n'), + startLine, + endLine, + totalLines: lines.length, + } + } + + private async grep( + uri: string, + request: Record, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal) + const rawPattern = request.pattern + const rawQuery = request.query + if (rawPattern !== undefined && typeof rawPattern !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'pattern must be a string.') + } + if (rawQuery !== undefined && typeof rawQuery !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'query must be a string.') + } + const expression = rawPattern ?? rawQuery + if (typeof expression !== 'string' || expression.length < 1 || expression.length > 1000) { + throw new LocalFilesystemError('INVALID_REQUEST', 'grep pattern is invalid.') + } + if (request.include !== undefined && typeof request.include !== 'string') { + throw new LocalFilesystemError('INVALID_REQUEST', 'include must be a glob string.') + } + if (request.caseSensitive !== undefined && typeof request.caseSensitive !== 'boolean') { + throw new LocalFilesystemError('INVALID_REQUEST', 'caseSensitive must be a boolean.') + } + const outputMode = request.outputMode ?? 'content' + if (!['content', 'files_with_matches', 'count'].includes(String(outputMode))) { + throw new LocalFilesystemError('INVALID_REQUEST', 'outputMode is invalid.') + } + if (request.lineNumbers !== undefined && typeof request.lineNumbers !== 'boolean') { + throw new LocalFilesystemError('INVALID_REQUEST', 'lineNumbers must be a boolean.') + } + const rawContext = request.context ?? 0 + if ( + !Number.isInteger(rawContext) || + (rawContext as number) < 0 || + (rawContext as number) > MAX_GREP_CONTEXT + ) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + `context must be an integer from 0 to ${MAX_GREP_CONTEXT}.` + ) + } + const contextLines = rawContext as number + const maxResults = parsePositiveInteger( + request.maxResults, + 'maxResults', + DEFAULT_GREP_RESULTS, + MAX_GREP_RESULTS + ) + + const include = typeof request.include === 'string' ? request.include : '**/*' + const matcher = compileGlob(include) + const ignoreCase = request.caseSensitive !== true + if (rawPattern !== undefined && !safeRegex(expression)) { + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'grep pattern was rejected because it may cause catastrophic backtracking.' + ) + } + let regex: RegExp + try { + regex = + rawPattern !== undefined + ? new RegExp(expression, ignoreCase ? 'i' : '') + : new RegExp(expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '') + } catch { + // An empty result set would tell the model the string appears nowhere in + // the user's files — a factual claim it will act on, when in truth the + // search never ran. + throw new LocalFilesystemError( + 'INVALID_REQUEST', + 'grep pattern is not a valid regular expression.' + ) + } + const resolvedPath = await this.resolveUri(uri) + const baseStat = await stat(resolvedPath.realPath) + if (!baseStat.isDirectory() && !baseStat.isFile()) { + throw new LocalFilesystemError('NOT_A_FILE', 'The localfs URI is not searchable.') + } + + const matches: LocalFilesystemGrepMatch[] = [] + const files: string[] = [] + const counts: Array<{ uri: string; count: number }> = [] + let scanned = 0 + let scannedBytes = 0 + let truncated = false + const stack = baseStat.isDirectory() + ? [{ path: resolvedPath.realPath, relativeFromBase: '', depth: 0 }] + : [] + + const inspectFile = async (childPath: string, relativeFromBase: string): Promise => { + throwIfAborted(signal) + if (baseStat.isDirectory() && !matcher(relativeFromBase)) return + const fileStat = await stat(childPath) + if (fileStat.size > MAX_TEXT_FILE_BYTES) return + if (scannedBytes + fileStat.size > MAX_GREP_SCAN_BYTES) { + truncated = true + return + } + scannedBytes += fileStat.size + const buffer = await readFile(childPath, { signal }) + if (isBinary(buffer)) return + const content = new TextDecoder().decode(buffer) + const mountRelativePath = baseStat.isFile() + ? resolvedPath.relativePath + : [resolvedPath.relativePath, relativeFromBase].filter(Boolean).join('/') + const resultUri = localUri(resolvedPath.mount.id, mountRelativePath) + + if (outputMode === 'files_with_matches') { + regex.lastIndex = 0 + if (regex.test(content)) files.push(resultUri) + return + } + + const lines = content.split(/\r?\n/) + let count = 0 + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + throwIfAborted(signal) + regex.lastIndex = 0 + if (!regex.test(lines[lineIndex])) continue + count++ + if (outputMode !== 'content') continue + + const contextStart = Math.max(0, lineIndex - contextLines) + const contextEnd = Math.min(lines.length - 1, lineIndex + contextLines) + for (let contextIndex = contextStart; contextIndex <= contextEnd; contextIndex++) { + const line = lines[contextIndex] + matches.push({ + uri: resultUri, + line: request.lineNumbers === false ? 0 : contextIndex + 1, + text: + line.length > MAX_GREP_LINE_LENGTH ? `${line.slice(0, MAX_GREP_LINE_LENGTH)}…` : line, + }) + if (matches.length >= maxResults) { + truncated = true + return + } + } + } + if (outputMode === 'count' && count > 0) { + counts.push({ uri: resultUri, count }) + } + } + + if (baseStat.isFile()) { + await inspectFile(resolvedPath.realPath, basename(resolvedPath.realPath)) + } + + while (stack.length > 0 && !truncated) { + throwIfAborted(signal) + const current = stack.pop() + if (!current) break + const children = await readdir(current.path, { withFileTypes: true }) + for (const child of children) { + throwIfAborted(signal) + scanned++ + if (scanned > MAX_SCAN_ENTRIES) { + truncated = true + break + } + const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') + const childPath = resolve(current.path, child.name) + if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { + stack.push({ + path: childPath, + relativeFromBase, + depth: current.depth + 1, + }) + continue + } + if (!child.isFile()) continue + await inspectFile(childPath, relativeFromBase) + if (truncated) break + const resultCount = + outputMode === 'files_with_matches' + ? files.length + : outputMode === 'count' + ? counts.length + : matches.length + if (resultCount >= maxResults) { + truncated = true + break + } + } + } + + if (outputMode === 'files_with_matches') { + files.sort() + return { files: files.slice(0, maxResults), truncated } + } + if (outputMode === 'count') { + counts.sort((a, b) => a.uri.localeCompare(b.uri)) + return { counts: counts.slice(0, maxResults), truncated } + } + matches.sort((a, b) => a.uri.localeCompare(b.uri) || a.line - b.line) + return { matches, truncated } + } + + private async statPath(uri: string): Promise { + const resolvedPath = await this.resolveUri(uri) + const metadata = await lstat(resolvedPath.lexicalPath) + return { + name: basename(resolvedPath.lexicalPath) || resolvedPath.mount.name, + uri, + kind: entryKind(metadata), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + } +} diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts new file mode 100644 index 00000000000..1fe85ab43d7 --- /dev/null +++ b/apps/desktop/src/main/menu.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow, type MenuItemConstructorOptions } from 'electron' +import type { ConfigStore } from '@/main/config' +import { buildMenuTemplate, type MenuDeps } from '@/main/menu' + +function makeDeps(): MenuDeps { + return { + config: { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => 'https://sim.ai'), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore, + getMainWindow: vi.fn(() => null), + allowHttpLocalhost: vi.fn(() => false), + openSettings: vi.fn(), + newWindow: vi.fn(), + newChat: vi.fn(), + closeFocusedTerminal: vi.fn(() => false), + reopenClosedTerminal: vi.fn(() => false), + closeFocusedBrowserTab: vi.fn(() => false), + reopenClosedBrowserTab: vi.fn(() => false), + toggleSidebar: vi.fn(), + signOut: vi.fn(), + checkForUpdates: vi.fn(), + } +} + +function submenu( + template: MenuItemConstructorOptions[], + label: string +): MenuItemConstructorOptions[] { + return (template.find((item) => item.label === label || item.role === label.toLowerCase()) + ?.submenu ?? []) as MenuItemConstructorOptions[] +} + +describe('buildMenuTemplate', () => { + it('uses the requested native menu structure', () => { + const template = buildMenuTemplate(makeDeps()) + expect(template.map((item) => item.label ?? item.role)).toEqual([ + 'Sim', + 'File', + 'editMenu', + 'View', + 'windowMenu', + 'help', + ]) + + expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'about', + 'Settings…', + 'Check for Updates…', + 'Sign Out', + 'separator', + 'services', + 'separator', + 'hide', + 'hideOthers', + 'unhide', + 'separator', + 'quit', + ]) + expect(submenu(template, 'File').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'New Window', + 'New Chat', + 'separator', + 'Reopen Closed Tab', + 'Close Window', + ]) + expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'Toggle Sidebar', + 'separator', + 'Reload', + 'separator', + 'Actual Size', + 'Zoom In', + 'Zoom Out', + 'separator', + 'togglefullscreen', + ]) + }) + + it('keeps Help limited to documentation and system status', () => { + const help = submenu(buildMenuTemplate(makeDeps()), 'Help') + expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'System Status']) + }) + + it('never exposes developer tools in the application menu', () => { + const view = submenu(buildMenuTemplate(makeDeps()), 'View') + expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) + }) + + it('routes the close accelerator through the focused browser tab before closing a window', () => { + const closeFocusedBrowserTab = vi.fn((_win: BrowserWindow | null) => true) + const deps = Object.assign(makeDeps(), { closeFocusedBrowserTab }) + const closeItem = submenu(buildMenuTemplate(deps), 'File').find( + (item) => item.accelerator === 'CmdOrCtrl+W' + ) + const focusedWindow = new BrowserWindow() + + expect(closeItem).toMatchObject({ label: 'Close Window', accelerator: 'CmdOrCtrl+W' }) + expect(closeItem?.role).toBeUndefined() + + const click = closeItem?.click as unknown as ( + menuItem: unknown, + browserWindow: BrowserWindow + ) => void + click({}, focusedWindow) + + expect(closeFocusedBrowserTab).toHaveBeenCalledWith(focusedWindow) + expect(focusedWindow.close).not.toHaveBeenCalled() + + closeFocusedBrowserTab.mockReturnValue(false) + click({}, focusedWindow) + expect(focusedWindow.close).toHaveBeenCalledOnce() + }) + + it('routes the reopen accelerator through the focused browser session', () => { + const reopenClosedBrowserTab = vi.fn((_win: BrowserWindow | null) => true) + const template = buildMenuTemplate( + Object.assign(makeDeps(), { + reopenClosedBrowserTab, + }) + ) + const reopenItem = submenu(template, 'File').find( + (item) => item.accelerator === 'CmdOrCtrl+Shift+T' + ) + + expect(reopenItem).toMatchObject({ + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + }) + const focusedWindow = new BrowserWindow() + ;(reopenItem?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( + {}, + focusedWindow + ) + expect(reopenClosedBrowserTab).toHaveBeenCalledWith(focusedWindow) + }) + + it('offers the standard new-window command', () => { + const deps = makeDeps() + const item = submenu(buildMenuTemplate(deps), 'File').find( + (entry) => entry.accelerator === 'CmdOrCtrl+Shift+N' + ) + + expect(item).toMatchObject({ label: 'New Window' }) + ;(item?.click as unknown as () => void)() + expect(deps.newWindow).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts new file mode 100644 index 00000000000..715a5e44bee --- /dev/null +++ b/apps/desktop/src/main/menu.ts @@ -0,0 +1,156 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { app, BrowserWindow, Menu } from 'electron' +import type { ConfigStore } from '@/main/config' +import { openExternalSafe } from '@/main/navigation' + +const DOCS_URL = 'https://docs.sim.ai' +const STATUS_URL = 'https://status.sim.ai' +const ZOOM_STEP = 0.5 + +export interface MenuDeps { + config: ConfigStore + getMainWindow: () => BrowserWindow | null + allowHttpLocalhost: () => boolean + openSettings: () => void + newWindow: () => void + newChat: () => void + closeFocusedBrowserTab: (win: BrowserWindow | null) => boolean + reopenClosedBrowserTab: (win: BrowserWindow | null) => boolean + /** + * Terminal counterparts. Menu accelerators are global, so Cmd-W and + * Cmd-Shift-T reach here whatever the user is looking at; each panel gets + * asked whether the keystroke was meant for it before the window acts. + */ + closeFocusedTerminal: (win: BrowserWindow | null) => boolean + reopenClosedTerminal: (win: BrowserWindow | null) => boolean + toggleSidebar: () => void + signOut: () => void + checkForUpdates: () => void +} + +/** + * Builds the role-based macOS menu. Edit roles are load-bearing — without + * them copy/paste/undo silently fail in web inputs. Zoom items are custom so + * the zoom level persists across launches. + */ +export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { + const withWindow = (fn: (win: BrowserWindow) => void) => () => { + const win = deps.getMainWindow() + if (win && !win.isDestroyed()) { + fn(win) + } + } + + const setZoom = (resolve: (current: number) => number) => + withWindow((win) => { + const level = resolve(win.webContents.getZoomLevel()) + win.webContents.setZoomLevel(level) + deps.config.set('zoomLevel', level) + }) + + const viewSubmenu: MenuItemConstructorOptions[] = [ + { + label: 'Toggle Sidebar', + accelerator: 'CmdOrCtrl+B', + click: deps.toggleSidebar, + }, + { type: 'separator' }, + { + label: 'Reload', + accelerator: 'CmdOrCtrl+R', + click: withWindow((win) => win.webContents.reload()), + }, + { type: 'separator' }, + { label: 'Actual Size', accelerator: 'CmdOrCtrl+0', click: setZoom(() => 0) }, + { + label: 'Zoom In', + accelerator: 'CmdOrCtrl+Plus', + click: setZoom((current) => current + ZOOM_STEP), + }, + { + label: 'Zoom Out', + accelerator: 'CmdOrCtrl+-', + click: setZoom((current) => current - ZOOM_STEP), + }, + { type: 'separator' }, + ] + viewSubmenu.push({ role: 'togglefullscreen' }) + + return [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, + { label: 'Check for Updates…', click: deps.checkForUpdates }, + { label: 'Sign Out', click: deps.signOut }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { + label: 'New Window', + accelerator: 'CmdOrCtrl+Shift+N', + click: deps.newWindow, + }, + { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, + { type: 'separator' }, + { + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + click: (_item, focusedWindow) => { + const win = + focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + if (deps.reopenClosedTerminal(win)) return + deps.reopenClosedBrowserTab(win) + }, + }, + { + label: 'Close Window', + accelerator: 'CmdOrCtrl+W', + click: (_item, focusedWindow) => { + const win = + focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + // Both panels are asked window-scoped, so a claim made in one window + // cannot answer an accelerator fired in another. + if (deps.closeFocusedTerminal(win)) return + if (deps.closeFocusedBrowserTab(win)) return + if (win && !win.isDestroyed()) win.close() + }, + }, + ], + }, + { role: 'editMenu' }, + { label: 'View', submenu: viewSubmenu }, + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Sim Documentation', + click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), + }, + { + label: 'System Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ], + }, + ] +} + +/** + * Installs the application menu. + */ +export function installApplicationMenu(deps: MenuDeps): void { + Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenuTemplate(deps))) +} diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts new file mode 100644 index 00000000000..6edfe06592e --- /dev/null +++ b/apps/desktop/src/main/navigation.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { shell } from 'electron' +import { + classifyBlankChildNavigation, + classifyNavigation, + classifyWindowOpen, + isAuthSurfacePath, + isSafeExternalUrl, + matchesHostList, + openExternalSafe, +} from '@/main/navigation' + +const APP = 'https://sim.ai' + +describe('classifyNavigation', () => { + it('keeps same-origin navigation in-app', () => { + expect( + classifyNavigation(`${APP}/workspace/ws1/w/wf1`, { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1`, + }) + ).toBe('in-app') + }) + + it('allows about:blank', () => { + expect(classifyNavigation('about:blank', { appOrigin: APP })).toBe('in-app') + }) + + it('routes Google from the login surface to the system-browser handoff', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?x=1', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-system-login') + }) + + it('routes Microsoft from the signup surface to the system-browser handoff', () => { + expect( + classifyNavigation('https://login.microsoftonline.com/common/oauth2/v2.0/authorize', { + appOrigin: APP, + currentUrl: `${APP}/signup`, + }) + ).toBe('idp-system-login') + }) + + it('routes Google from a workspace page to the connect-in-browser intercept', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?scope=drive', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1/integrations/google-drive`, + }) + ).toBe('idp-system-connect') + }) + + it('matches system IdP subdomains', () => { + expect( + classifyNavigation('https://device.login.microsoftonline.com/', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1`, + }) + ).toBe('idp-system-connect') + }) + + it('keeps verified-lenient IdPs in-window from the login surface', () => { + expect( + classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-in-window') + }) + + it('sends unknown hosts from an auth surface to the system browser (SSO safe default)', () => { + expect( + classifyNavigation('https://company.okta.com/sso/saml', { + appOrigin: APP, + currentUrl: `${APP}/login`, + }) + ).toBe('idp-system-login') + }) + + it('keeps unknown hosts from workspace pages in-window (integration OAuth is a same-window redirect)', () => { + expect( + classifyNavigation('https://api.notion.com/v1/oauth/authorize?x=1', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1/integrations/notion`, + }) + ).toBe('idp-in-window') + }) + + it('allows continuation navigation while already on an IdP host', () => { + expect( + classifyNavigation('https://github.com/sessions/two-factor', { + appOrigin: APP, + currentUrl: 'https://github.com/login', + }) + ).toBe('idp-in-window') + }) + + it('allows any https navigation inside popups', () => { + expect( + classifyNavigation('https://third-party-mcp.example/authorize', { + appOrigin: APP, + currentUrl: 'https://other.example/start', + isPopup: true, + }) + ).toBe('in-app') + }) + + it('denies non-web schemes everywhere', () => { + for (const url of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'data:text/html,x', + 'sim://auth', + ]) { + expect(classifyNavigation(url, { appOrigin: APP, currentUrl: `${APP}/login` })).toBe('deny') + expect(classifyNavigation(url, { appOrigin: APP, isPopup: true })).toBe('deny') + } + }) +}) + +describe('classifyWindowOpen', () => { + it('classifies blank children (Stripe blank-then-assign)', () => { + expect(classifyWindowOpen('', '', APP)).toBe('popup-blank') + expect(classifyWindowOpen('about:blank', '', APP)).toBe('popup-blank') + }) + + it('classifies the MCP OAuth popup by frame name for any https URL', () => { + expect(classifyWindowOpen(`${APP}/api/mcp/oauth/start`, 'mcp-oauth-srv1', APP)).toBe( + 'popup-mcp' + ) + expect(classifyWindowOpen('https://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( + 'popup-mcp' + ) + }) + + it('does not let a cross-origin http URL ride the mcp-oauth frame name in-app', () => { + expect(classifyWindowOpen('http://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( + 'external' + ) + }) + + it('collapses internal new-tab opens into the main window', () => { + expect(classifyWindowOpen(`${APP}/workspace/ws1/w/wf1`, '', APP)).toBe('popup-internal') + }) + + it('routes external opens to the system browser', () => { + expect(classifyWindowOpen('https://docs.sim.ai/blocks', '', APP)).toBe('external') + }) + + it('denies non-web schemes', () => { + expect(classifyWindowOpen('javascript:alert(1)', '', APP)).toBe('deny') + expect(classifyWindowOpen('file:///tmp/x', 'mcp-oauth-x', APP)).toBe('deny') + }) +}) + +describe('classifyBlankChildNavigation', () => { + it('ignores staying blank', () => { + expect(classifyBlankChildNavigation('about:blank', APP)).toBe('ignore') + }) + + it('routes same-origin assignment into the main window', () => { + expect(classifyBlankChildNavigation(`${APP}/chat/deployed`, APP)).toBe('internal') + }) + + it('routes external assignment (Stripe portal) to the system browser', () => { + expect(classifyBlankChildNavigation('https://billing.stripe.com/p/session/x', APP)).toBe( + 'external' + ) + }) + + it('denies non-web schemes', () => { + expect(classifyBlankChildNavigation('javascript:alert(1)', APP)).toBe('deny') + }) +}) + +describe('isSafeExternalUrl', () => { + it('allows https', () => { + expect(isSafeExternalUrl('https://docs.sim.ai')).toBe(true) + }) + + it('rejects credentials in the URL', () => { + expect(isSafeExternalUrl('https://user@evil.example')).toBe(false) + expect(isSafeExternalUrl('https://user:pass@evil.example')).toBe(false) + }) + + it('allows http only for loopback hosts and only when enabled', () => { + expect(isSafeExternalUrl('http://localhost:3000', true)).toBe(true) + expect(isSafeExternalUrl('http://127.0.0.1:3000', true)).toBe(true) + expect(isSafeExternalUrl('http://localhost:3000', false)).toBe(false) + expect(isSafeExternalUrl('http://evil.example', true)).toBe(false) + }) + + it('rejects non-web schemes', () => { + for (const url of [ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'data:text/html,x', + 'steam://run/1', + 'blob:https://sim.ai/x', + ]) { + expect(isSafeExternalUrl(url, true)).toBe(false) + } + }) + + it('rejects garbage', () => { + expect(isSafeExternalUrl('not a url')).toBe(false) + expect(isSafeExternalUrl('')).toBe(false) + }) +}) + +describe('openExternalSafe', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + it('opens validated URLs', async () => { + await expect(openExternalSafe('https://docs.sim.ai')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.sim.ai') + }) + + it('never passes unsafe URLs to the shell', async () => { + await expect(openExternalSafe('javascript:alert(1)')).resolves.toBe(false) + await expect(openExternalSafe('file:///etc/passwd', true)).resolves.toBe(false) + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) + +describe('helpers', () => { + it('matchesHostList covers exact hosts and subdomains', () => { + expect(matchesHostList('accounts.google.com', ['accounts.google.com'])).toBe(true) + expect(matchesHostList('sub.accounts.google.com', ['accounts.google.com'])).toBe(true) + expect(matchesHostList('evilaccounts.google.com.attacker.io', ['accounts.google.com'])).toBe( + false + ) + expect(matchesHostList('notaccounts.google.com', ['accounts.google.com'])).toBe(false) + }) + + it('isAuthSurfacePath matches auth routes and their children only', () => { + expect(isAuthSurfacePath('/login')).toBe(true) + expect(isAuthSurfacePath('/sso/acme')).toBe(true) + expect(isAuthSurfacePath('/desktop/auth')).toBe(true) + expect(isAuthSurfacePath('/workspace/ws1')).toBe(false) + expect(isAuthSurfacePath('/loginish')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts new file mode 100644 index 00000000000..a89e37f50b9 --- /dev/null +++ b/apps/desktop/src/main/navigation.ts @@ -0,0 +1,232 @@ +import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' +import { shell } from 'electron' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopNavigation') + +export type MainNavigationAction = + | 'in-app' + | 'idp-in-window' + | 'idp-system-login' + | 'idp-system-connect' + | 'external' + | 'deny' + +export type WindowOpenAction = 'popup-mcp' | 'popup-blank' | 'popup-internal' | 'external' | 'deny' + +export type BlankChildAction = 'internal' | 'external' | 'ignore' | 'deny' + +export interface NavigationContext { + appOrigin: string + currentUrl?: string + isPopup?: boolean +} + +/** + * IdP hosts that hard-block OAuth inside embedded user agents. Navigation to + * these hosts is cancelled and rerouted: from an auth surface the app starts + * the system-browser login handoff; from anywhere else it offers to finish the + * integration connect in the browser (tokens land server-side either way). + */ +export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [ + 'accounts.google.com', + 'accounts.youtube.com', + 'login.microsoftonline.com', + 'login.live.com', + 'login.windows.net', + 'sts.windows.net', +] + +/** + * IdP hosts verified lenient toward embedded user agents (the U5 provider + * matrix). Only consulted for navigations leaving an auth surface — everywhere + * else unknown hosts already stay in-window because same-window departures + * from workspace pages are OAuth connect flows in this app. + */ +export const IN_WINDOW_IDP_HOSTS: readonly string[] = ['github.com'] + +const AUTH_SURFACE_PREFIXES: readonly string[] = [ + '/login', + '/signup', + '/sso', + '/reset-password', + '/verify', + '/desktop/auth', +] + +const MCP_POPUP_NAME_PREFIX = 'mcp-oauth-' + +export function parseHttpUrl(raw: string): URL | null { + try { + const url = new URL(raw) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + return url + } catch { + return null + } +} + +/** + * Matches a hostname against a list of registrable domains, including their + * subdomains ('login.live.com' matches 'live.com'-style entries and itself). + */ +export function matchesHostList(hostname: string, hosts: readonly string[]): boolean { + return hosts.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) +} + +/** + * True when a URL is on the app origin, compared by parsed origin equality. + * Never use `url.startsWith(origin)` for this — that prefix-matches lookalike + * hosts (`https://sim.ai.evil.com` starts with `https://sim.ai`). + */ +export function isAppOrigin(rawUrl: string, appOrigin: string): boolean { + const url = parseHttpUrl(rawUrl) + return url !== null && url.origin === appOrigin +} + +/** + * Auth surfaces are routes where a non-origin departure means an identity + * flow (social login, SSO) rather than an integration connect. + */ +export function isAuthSurfacePath(pathname: string): boolean { + return AUTH_SURFACE_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ) +} + +/** + * Classifies a top-level navigation (will-navigate / will-redirect). + * + * Same-window departures to non-origin hosts are OAuth flows in this app — + * regular external links always go through window.open — so unknown hosts stay + * in-window (lenient IdP assumption) except for the known embedded-blocking + * hosts, which are rerouted through the system browser. Departures from auth + * surfaces default to the system browser because SSO IdPs need real-browser + * device claims. + */ +export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction { + if (rawUrl === 'about:blank') { + return 'in-app' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === ctx.appOrigin) { + return 'in-app' + } + if (ctx.isPopup) { + return 'in-app' + } + const current = ctx.currentUrl ? parseHttpUrl(ctx.currentUrl) : null + const fromAuthSurface = current + ? current.origin === ctx.appOrigin && isAuthSurfacePath(current.pathname) + : false + if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { + return fromAuthSurface ? 'idp-system-login' : 'idp-system-connect' + } + if (matchesHostList(url.hostname, IN_WINDOW_IDP_HOSTS)) { + return 'idp-in-window' + } + if (current && current.origin !== ctx.appOrigin) { + return 'idp-in-window' + } + return fromAuthSurface ? 'idp-system-login' : 'idp-in-window' +} + +/** + * Classifies a window.open request (setWindowOpenHandler). Internal requests + * become full Sim application windows; the MCP OAuth popup and the + * blank-then-assign pattern (Stripe portal, deployed-chat tabs) are allowed as + * guarded children in the same partition. + */ +export function classifyWindowOpen( + rawUrl: string, + frameName: string, + appOrigin: string +): WindowOpenAction { + if (rawUrl === '' || rawUrl === 'about:blank') { + return 'popup-blank' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === appOrigin) { + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX)) { + return 'popup-mcp' + } + return 'popup-internal' + } + // The MCP OAuth popup opens the provider's cross-origin authorization URL + // (always https). The frame name is renderer-controlled, so gate the in-app + // popup on https too — an http(s-less) page can never ride the mcp-oauth name + // into an in-app window; it goes to the system browser like any external URL. + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX) && url.protocol === 'https:') { + return 'popup-mcp' + } + return 'external' +} + +/** + * Classifies the first real navigation of an about:blank child window created + * by the blank-then-assign pattern. + */ +export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string): BlankChildAction { + if (rawUrl === '' || rawUrl === 'about:blank') { + return 'ignore' + } + const url = parseHttpUrl(rawUrl) + if (!url) { + return 'deny' + } + if (url.origin === appOrigin) { + return 'internal' + } + return 'external' +} + +/** + * Validates a URL for handing to the system browser: https always, http only + * for loopback hosts when explicitly allowed, never credentials in the URL, + * never non-web schemes. + */ +export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): boolean { + let url: URL + try { + url = new URL(raw) + } catch { + return false + } + if (url.username || url.password) { + return false + } + if (url.protocol === 'https:') { + return true + } + if (url.protocol === 'http:') { + return allowHttpLocalhost && isLoopbackHostname(url.hostname) + } + return false +} + +/** + * Opens a URL in the system browser after validation. Every openExternal in + * the app goes through here — menu items, IPC, and navigation policy. + */ +export async function openExternalSafe(raw: string, allowHttpLocalhost = false): Promise { + if (!isSafeExternalUrl(raw, allowHttpLocalhost)) { + logger.warn('Blocked unsafe external URL', { url: scrubUrl(raw) }) + return false + } + try { + await shell.openExternal(raw) + return true + } catch (error) { + logger.error('Failed to open external URL', { error }) + return false + } +} diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts new file mode 100644 index 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..6290c271d31 --- /dev/null +++ b/apps/desktop/src/main/observability.ts @@ -0,0 +1,83 @@ +import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { createLogger } from '@sim/logger' + +const logger = createLogger('DesktopEvents') + +const DEFAULT_MAX_BYTES = 1_000_000 + +export type DesktopEventName = + | 'app_launch' + | 'update_check' + | 'update_feed' + | 'update_downloaded' + | 'update_error' + | 'update_blocked_version' + | 'update_manual_mode' + | 'update_manual_download' + | 'handoff_redeem_ok' + | 'handoff_redeem_fail' + | 'load_failure' + | 'renderer_gone' + | 'renderer_unresponsive' + | 'sign_out' + | 'origin_changed' + | 'handoff_started' + | 'connect_handoff_started' + | 'connect_handoff_open_fail' + | 'connect_handoff_state_fail' + | 'connect_handoff_ok' + | 'connect_handoff_error' + +export interface EventRecorder { + readonly filePath: string + record(name: DesktopEventName, data?: Record): void +} + +/** + * Reduces a URL to origin + path for logging. Query strings and fragments are + * dropped so tokens, states, and signed parameters never reach the event log. + */ +export function scrubUrl(raw: string): string { + try { + const url = new URL(raw) + return `${url.origin}${url.pathname}` + } catch { + return '' + } +} + +/** + * Structured JSONL event log for the main process, answering "is this release + * crashing?" and "did auto-update fail?" from a user machine. Rotates once at + * maxBytes (current file becomes .1). Callers must pass pre-scrubbed data — + * use scrubUrl for anything URL-shaped and never log tokens or cookies. + */ +export function createEventLog(dir: string, maxBytes: number = DEFAULT_MAX_BYTES): EventRecorder { + const filePath = join(dir, 'desktop-events.log') + try { + mkdirSync(dir, { recursive: true }) + } catch {} + + const rotateIfNeeded = () => { + try { + if (statSync(filePath).size > maxBytes) { + renameSync(filePath, `${filePath}.1`) + } + } catch {} + } + + return { + filePath, + record(name, data) { + logger.info(`desktop event: ${name}`, data) + try { + rotateIfNeeded() + const entry = { at: new Date().toISOString(), name, ...(data ? { data } : {}) } + appendFileSync(filePath, `${JSON.stringify(entry)}\n`) + } catch (error) { + logger.warn('Failed to append desktop event', { error }) + } + }, + } +} diff --git a/apps/desktop/src/main/security-guards.test.ts b/apps/desktop/src/main/security-guards.test.ts new file mode 100644 index 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..ac6e8b2882b --- /dev/null +++ b/apps/desktop/src/main/security-guards.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import type { WebContents } from 'electron' +import { app } from 'electron' +import { isAgentWebContents } from '@/main/browser-agent/registry' +import { classifyNavigation, openExternalSafe } from '@/main/navigation' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopSecurityGuards') + +export interface GuardDeps { + appOrigin: () => string + isPackaged: boolean + allowHttpLocalhost: () => boolean + isPopupContents: (contents: WebContents) => boolean + onLoginHandoff: () => void + onConnectIntercept: (contents: WebContents) => void +} + +/** + * Applies the top-level navigation policy to both will-navigate and + * will-redirect — OAuth chains can be redirect-injected, so redirects get the + * same classifier as direct navigations. + */ +export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): void { + const handle = (event: { preventDefault(): void }, url: string) => { + // The agent browser's tabs are general-purpose browsing surfaces: any + // http(s) navigation is their job (they run isolated on their own + // partition with no preload). Everything else stays denied. + if (isAgentWebContents(contents)) { + if (!/^https?:/i.test(url)) { + event.preventDefault() + logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) }) + } + return + } + const action = classifyNavigation(url, { + appOrigin: deps.appOrigin(), + currentUrl: contents.getURL(), + isPopup: deps.isPopupContents(contents), + }) + switch (action) { + case 'in-app': + case 'idp-in-window': + return + case 'external': + event.preventDefault() + void openExternalSafe(url, deps.allowHttpLocalhost()) + return + case 'idp-system-login': + event.preventDefault() + deps.onLoginHandoff() + return + case 'idp-system-connect': + event.preventDefault() + deps.onConnectIntercept(contents) + return + default: + event.preventDefault() + logger.warn('Denied navigation', { url: scrubUrl(url) }) + } + } + contents.on('will-navigate', handle) + contents.on('will-redirect', handle) +} + +/** + * Global defense-in-depth: every WebContents ever created — main window, MCP + * popups, blank children, settings, agent-browser tabs — gets webview + * blocking, packaged DevTools lockdown, a default-deny window.open handler + * (specific policies overwrite it), and the navigation classifier. + * Agent-browser tabs swap the classifier for a free-http(s) policy (their job + * is browsing arbitrary sites, isolated on their own partition). TLS errors + * are always fatal when packaged; self-host private CAs must be + * system-trusted rather than bypassed in-app. + */ +export function installGlobalGuards(deps: GuardDeps): void { + app.on('web-contents-created', (_event, contents) => { + contents.on('will-attach-webview', (event) => { + event.preventDefault() + logger.warn('Blocked webview attach') + }) + if (deps.isPackaged) { + contents.on('devtools-opened', () => { + contents.closeDevTools() + }) + } + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + attachNavigationGuards(contents, deps) + }) + + app.on('certificate-error', (event, _webContents, url, error, _certificate, callback) => { + event.preventDefault() + callback(false) + logger.warn('Rejected TLS certificate', { url: scrubUrl(url), error }) + }) +} diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts new file mode 100644 index 00000000000..64f98f1f099 --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow, type Session } from 'electron' +import { + createSessionLifecycleCoordinator, + decideStartRoute, + isLogoutNavigation, + isSessionCookieName, + probeSession, + resolveStartRoute, + revokeAppSession, + tearDownSession, +} from '@/main/session-lifecycle' + +const APP = 'https://sim.ai' + +describe('isSessionCookieName', () => { + it('matches the better-auth session cookie on secure and non-secure hosts', () => { + expect(isSessionCookieName('better-auth.session_token')).toBe(true) + expect(isSessionCookieName('__Secure-better-auth.session_token')).toBe(true) + }) + + it('ignores non-session cookies', () => { + expect(isSessionCookieName('better-auth.session_data')).toBe(false) + expect(isSessionCookieName('__Host-csrf')).toBe(false) + expect(isSessionCookieName('theme')).toBe(false) + }) +}) + +function sessionWithResponse(status: number, body: unknown): Session { + return { + fetch: vi.fn(async () => new Response(JSON.stringify(body), { status })), + } as unknown as Session +} + +describe('isLogoutNavigation', () => { + it('detects the web sign-out navigation', () => { + expect(isLogoutNavigation(`${APP}/login?fromLogout=true`, APP)).toBe(true) + }) + + it('ignores plain login loads, other origins, and garbage', () => { + expect(isLogoutNavigation(`${APP}/login`, APP)).toBe(false) + expect(isLogoutNavigation(`${APP}/login?fromLogout=false`, APP)).toBe(false) + expect(isLogoutNavigation('https://evil.example/login?fromLogout=true', APP)).toBe(false) + expect(isLogoutNavigation('not a url', APP)).toBe(false) + }) +}) + +describe('decideStartRoute', () => { + it('restores the last route when plausible', () => { + expect(decideStartRoute('/workspace/ws1?tab=logs')).toBe('/workspace/ws1?tab=logs') + expect(decideStartRoute('/workspace/ws1')).toBe('/workspace/ws1') + }) + + it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => { + expect(decideStartRoute(undefined)).toBe('/workspace') + expect(decideStartRoute('//evil.example')).toBe('/workspace') + expect(decideStartRoute('/login')).toBe('/workspace') + }) +}) + +describe('resolveStartRoute', () => { + it('restores an accessible saved workspace route', async () => { + const session = sessionWithResponse(200, { workspace: { id: 'ws1' } }) + + await expect(resolveStartRoute(session, APP, '/workspace/ws1/home')).resolves.toBe( + '/workspace/ws1/home' + ) + expect(vi.mocked(session.fetch)).toHaveBeenCalledWith( + `${APP}/api/workspaces/ws1/host-context`, + expect.objectContaining({ cache: 'no-store' }) + ) + }) + + it('falls back to the workspace picker after confirmed access denial', async () => { + const session = sessionWithResponse(403, { error: 'Workspace access denied' }) + + await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe( + '/workspace' + ) + }) + + it('does not probe routes without a workspace id', async () => { + const session = sessionWithResponse(200, {}) + + await expect(resolveStartRoute(session, APP, '/workspace')).resolves.toBe('/workspace') + expect(session.fetch).not.toHaveBeenCalled() + }) + + it('preserves the saved route on auth, server, and network failures', async () => { + await expect( + resolveStartRoute(sessionWithResponse(401, {}), APP, '/workspace/ws1/home') + ).resolves.toBe('/workspace/ws1/home') + await expect( + resolveStartRoute(sessionWithResponse(500, {}), APP, '/workspace/ws1/home') + ).resolves.toBe('/workspace/ws1/home') + + const failing = { + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as unknown as Session + await expect(resolveStartRoute(failing, APP, '/workspace/ws1/home')).resolves.toBe( + '/workspace/ws1/home' + ) + }) +}) + +describe('probeSession', () => { + it('reports valid when a session or user is present', async () => { + await expect(probeSession(sessionWithResponse(200, { user: { id: 'u1' } }), APP)).resolves.toBe( + 'valid' + ) + await expect( + probeSession(sessionWithResponse(200, { session: { id: 's1' } }), APP) + ).resolves.toBe('valid') + }) + + it('reports invalid for a null session body', async () => { + await expect(probeSession(sessionWithResponse(200, null), APP)).resolves.toBe('invalid') + }) + + it('reports unknown for server errors and network failures', async () => { + await expect(probeSession(sessionWithResponse(500, {}), APP)).resolves.toBe('unknown') + const failing = { + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as unknown as Session + await expect(probeSession(failing, APP)).resolves.toBe('unknown') + }) + + it('asks the get-session endpoint with the partition cookies', async () => { + const ses = sessionWithResponse(200, null) + await probeSession(ses, APP) + expect(vi.mocked(ses.fetch).mock.calls[0][0]).toBe(`${APP}/api/auth/get-session`) + }) +}) + +describe('tearDownSession', () => { + it('revokes server-side first, then local secrets and the browser profile, then the web session', async () => { + // Order is load-bearing: the server-side revoke needs the partition's + // session cookie, which clearStorageData destroys. + const order: string[] = [] + const session = { + clearStorageData: vi.fn(async () => { + order.push('session') + }), + } as unknown as Session + + await tearDownSession( + session, + async () => { + await Promise.resolve() + order.push('local') + }, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => { + await Promise.resolve() + order.push('browser') + }, + async () => { + await Promise.resolve() + order.push('revoke') + } + ) + + expect(order).toEqual(['revoke', 'local', 'browser', 'session']) + }) + + it('still clears the web session when the browser profile cannot be cleared', async () => { + // Sign-out must complete even if the embedded browser is in a bad state; + // failing to clear its cookies is bad, failing to sign out is worse. + const clearStorageData = vi.fn(async () => {}) + const session = { clearStorageData } as unknown as Session + + await expect( + tearDownSession( + session, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => { + throw new Error('browser view already destroyed') + }, + async () => {} + ) + ).resolves.toBeUndefined() + + expect(clearStorageData).toHaveBeenCalled() + }) + + it('still clears local state when the server-side revoke fails', async () => { + // Offline sign-out must not strand the user signed in locally. + const clearStorageData = vi.fn(async () => {}) + const session = { clearStorageData } as unknown as Session + + await expect( + tearDownSession( + session, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => {}, + async () => { + throw new Error('offline') + } + ) + ).resolves.toBeUndefined() + + expect(clearStorageData).toHaveBeenCalled() + }) +}) + +describe('revokeAppSession', () => { + const origin = 'https://sim.ai' + + function windowAt(url: string, destroyed = false) { + const executeJavaScript = vi.fn(async (_script: string) => 200) + return { + win: { + isDestroyed: () => destroyed, + webContents: { getURL: () => url, executeJavaScript }, + } as unknown as BrowserWindow, + executeJavaScript, + } + } + + it('POSTs sign-out from the app-origin renderer so the session row is deleted', async () => { + const { win, executeJavaScript } = windowAt(`${origin}/workspace`) + + await revokeAppSession(win, origin) + + expect(executeJavaScript).toHaveBeenCalledTimes(1) + const script = executeJavaScript.mock.calls[0][0] + expect(script).toContain('/api/auth/sign-out') + expect(script).toContain("credentials: 'include'") + }) + + it('skips a window that is off-origin or destroyed', async () => { + // The offline page and a lookalike host must never be asked to sign out — + // the request would not be same-origin and could not carry the cookie. + for (const url of ['about:blank', 'https://sim.ai.evil.example/workspace']) { + const { win, executeJavaScript } = windowAt(url) + await revokeAppSession(win, origin) + expect(executeJavaScript).not.toHaveBeenCalled() + } + + const destroyed = windowAt(`${origin}/workspace`, true) + await revokeAppSession(destroyed.win, origin) + expect(destroyed.executeJavaScript).not.toHaveBeenCalled() + }) + + it('does not throw when the renderer rejects', async () => { + const win = { + isDestroyed: () => false, + webContents: { + getURL: () => `${origin}/workspace`, + executeJavaScript: vi.fn(async () => { + throw new Error('render frame disposed') + }), + }, + } as unknown as BrowserWindow + + await expect(revokeAppSession(win, origin)).resolves.toBeUndefined() + }) +}) + +describe('createSessionLifecycleCoordinator', () => { + it('shares session observers and signs every app window out with one teardown', async () => { + const cookiesOn = vi.fn() + const webRequestOnCompleted = vi.fn() + const clearStorageData = vi.fn(async () => {}) + const session = { + cookies: { on: cookiesOn }, + webRequest: { onCompleted: webRequestOnCompleted }, + clearStorageData, + fetch: vi.fn(async () => Response.json(null)), + } as unknown as Session + const first = new BrowserWindow() + const second = new BrowserWindow() + const clearHandoffState = vi.fn(async () => {}) + const coordinator = createSessionLifecycleCoordinator({ + appSession: session, + origin: () => APP, + events: { filePath: '/tmp/events.log', record: vi.fn() }, + clearHandoffState, + clearBrowserProfile: vi.fn(async () => {}), + getWindows: () => [first, second], + }) + + coordinator.attachWindow(first) + coordinator.attachWindow(second) + + expect(cookiesOn).toHaveBeenCalledOnce() + // The shell no longer watches API responses: session expiry is the web + // app's to detect, so nothing here should subscribe to /api/* statuses. + expect(webRequestOnCompleted).not.toHaveBeenCalled() + + const windowEventCalls = vi.mocked(first.webContents.on).mock.calls as unknown as Array< + [string, (...args: unknown[]) => unknown] + > + const navigation = windowEventCalls.find(([event]) => event === 'did-navigate-in-page')?.[1] as + | ((event: unknown, url: string) => void) + | undefined + navigation?.({}, `${APP}/login?fromLogout=true`) + + await vi.waitFor(() => { + expect(clearStorageData).toHaveBeenCalledOnce() + expect(first.loadURL).toHaveBeenCalledWith(`${APP}/login`) + expect(second.loadURL).toHaveBeenCalledWith(`${APP}/login`) + }) + expect(clearHandoffState).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts new file mode 100644 index 00000000000..e922a1bd051 --- /dev/null +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -0,0 +1,411 @@ +import { createLogger } from '@sim/logger' +import type { Session, WebContents } from 'electron' +import { BrowserWindow, dialog } from 'electron' +import { isSafeInternalPath } from '@/main/config' +import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopSessionLifecycle') + +const SESSION_PROBE_TIMEOUT_MS = 5000 +const START_ROUTE_PROBE_TIMEOUT_MS = 1500 +const TEARDOWN_COOLDOWN_MS = 3000 + +const CLEARED_STORAGES = [ + 'cookies', + 'localstorage', + 'indexdb', + 'cachestorage', + 'serviceworkers', +] as const + +export type SessionProbeResult = 'valid' | 'invalid' | 'unknown' + +/** + * Matches the better-auth session cookie across secure and non-secure hosts: + * `better-auth.session_token` (http/localhost) and + * `__Secure-better-auth.session_token` (https). Keying off the better-auth + * cookie name — a stable library contract — is far more robust than sniffing + * a Sim UI redirect URL, and it catches every sign-out path (settings, invite + * page, stale-session recovery) uniformly. + */ +export function isSessionCookieName(name: string): boolean { + return name.endsWith('session_token') +} + +/** + * Detects the web app's sign-out navigation (general settings routes to + * /login?fromLogout=true on sign-out). This is the fast path; the cookie + * watcher below is the robust backstop for sign-out paths that don't use it. + */ +export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean { + try { + const url = new URL(rawUrl) + return ( + url.origin === appOrigin && + url.pathname === '/login' && + url.searchParams.get('fromLogout') === 'true' + ) + } catch { + return false + } +} + +/** + * Picks the route to load at launch: the last visited route (when safe and + * not itself an auth surface), falling back to /workspace. A signed-out + * partition is handled by the web app's own login redirect. + */ +export function decideStartRoute(lastRoute: string | undefined): string { + if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) { + return lastRoute + } + return '/workspace' +} + +function workspaceIdFromRoute(route: string): string | null { + try { + const pathname = new URL(route, 'https://internal.invalid').pathname + const match = /^\/workspace\/([^/]+)/.exec(pathname) + return match ? decodeURIComponent(match[1]) : null + } catch { + return null + } +} + +/** + * Validates a workspace-specific saved route before Desktop restores it. + * Only a confirmed 403 discards the route; auth, network, timeout, and server + * failures preserve normal web-app recovery instead of masquerading as a + * revoked workspace. + */ +export async function resolveStartRoute( + session: Session, + origin: string, + lastRoute: string | undefined, + timeoutMs: number = START_ROUTE_PROBE_TIMEOUT_MS +): Promise { + const route = decideStartRoute(lastRoute) + const workspaceId = workspaceIdFromRoute(route) + if (!workspaceId) { + return route + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch( + `${origin}/api/workspaces/${encodeURIComponent(workspaceId)}/host-context`, + { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + } + ) + if (response.status === 403) { + logger.info('Saved workspace route is no longer accessible; opening workspace picker') + return '/workspace' + } + return route + } catch { + return route + } finally { + clearTimeout(timer) + } +} + +/** + * Checks whether the partition currently holds a valid session by asking + * better-auth's get-session endpoint with the partition's cookies. Network + * trouble reports 'unknown' so offline never masquerades as signed-out. + */ +export async function probeSession( + session: Session, + origin: string, + timeoutMs: number = SESSION_PROBE_TIMEOUT_MS +): Promise { + const controller = new AbortController() + // `finally`, not an inline clear after the await: a thrown fetch is the case + // this function exists for, and an inline clear is skipped on that path. + // The body read is inside the deadline too, so a stalled response cannot + // outlive the timeout. + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch(`${origin}/api/auth/get-session`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (!response.ok) { + return 'unknown' + } + const data = (await response.json().catch(() => null)) as { + session?: unknown + user?: unknown + } | null + return data && (data.session || data.user) ? 'valid' : 'invalid' + } catch { + return 'unknown' + } finally { + clearTimeout(timer) + } +} + +/** + * The user id the app partition is currently signed in as, or null when signed + * out or unreachable. + * + * The OAuth connect handoff runs entirely in the system browser, which holds a + * session of its own — since the app no longer shares the browser's session row, + * the two can be different accounts. Passing this id into `/desktop/connect` + * lets the server refuse rather than silently attach the credential to whichever + * account the browser happens to be signed into. + */ +export async function readSessionUserId( + session: Session, + origin: string, + timeoutMs: number = SESSION_PROBE_TIMEOUT_MS +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await session.fetch(`${origin}/api/auth/get-session`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + cache: 'no-store', + }) + if (!response.ok) { + return null + } + const data = (await response.json().catch(() => null)) as { + user?: { id?: unknown } + } | null + return typeof data?.user?.id === 'string' ? data.user.id : null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +const SIGN_OUT_SCRIPT = `(async () => { + try { + const response = await fetch('/api/auth/sign-out', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: '{}', + }) + return response.status + } catch { + return 0 + } +})()` + +/** + * Whether this window can carry the sign-out request. The trailing slash makes + * the prefix an origin test rather than a string test, so a lookalike host + * (`https://sim.ai.evil.example`) is never asked to sign the user out. + */ +export function canRevokeIn(win: BrowserWindow, origin: string): boolean { + return !win.isDestroyed() && win.webContents.getURL().startsWith(`${origin}/`) +} + +/** + * Revokes the app's session row server-side. + * + * The desktop holds a session of its own (see + * `apps/sim/lib/auth/desktop-handoff.ts`), so clearing the partition alone + * would leave a live 30-day credential on the server that nothing can revoke — + * there is no device-management UI. "Sign Out" has to reach the server. + * + * Runs in the app-origin renderer rather than `session.fetch`: Better Auth + * rejects a cookie-bearing POST that carries no `Origin` header + * (`MISSING_OR_NULL_ORIGIN`), and only a renderer request is genuinely + * same-origin — the same reason the token redeem runs there. Best-effort by + * design: sign-out must still clear local state when offline or when the + * window is showing the offline page, and `/sign-out` is a no-op when the + * cookie is already gone (the cookie-deletion backstop path). + */ +export async function revokeAppSession(win: BrowserWindow, origin: string): Promise { + if (!canRevokeIn(win, origin)) { + return + } + try { + await win.webContents.executeJavaScript(SIGN_OUT_SCRIPT, true) + } catch (error) { + logger.warn('Could not revoke the session server-side', { error }) + } +} + +/** + * Revokes the session server-side, then clears every session-bearing storage in + * the app partition plus any pending handoff secrets — the desktop analogue of + * a browser profile sign-out. + * + * The embedded browser has its own partition, which this clears too: it holds + * the signed-in user's cookies for third-party sites, so leaving it behind + * would hand the next account on this machine a set of live sessions. Injected + * rather than imported, so this module does not pull the whole browser-agent + * subsystem — and its module-load side effects — into the auth path. + */ +export async function tearDownSession( + session: Session, + clearHandoffState: () => void | Promise, + events: EventRecorder, + clearBrowserProfile: () => Promise, + revokeSession: () => Promise +): Promise { + events.record('sign_out') + // Server-side first, while the partition still holds the session cookie the + // revoke needs. Every step below is best-effort for the same reason the + // browser-profile clear is: failing to clear something is bad, failing to + // sign out is worse. + await revokeSession().catch((error) => logger.error('Session revoke failed', { error })) + await clearHandoffState() + await clearBrowserProfile().catch((error) => + logger.error('Browser profile teardown failed', { error }) + ) + await session.clearStorageData({ storages: [...CLEARED_STORAGES] }) +} + +export interface SessionLifecycleDeps { + appSession: Session + origin: () => string + events: EventRecorder + clearHandoffState: () => void | Promise + /** Clears the embedded browser's own partition. See {@link tearDownSession}. */ + clearBrowserProfile: () => Promise +} + +export interface SessionLifecycleCoordinator { + attachWindow(win: BrowserWindow): void + /** + * Signs out through the same path web sign-out takes. Callers must use this + * rather than calling {@link tearDownSession} directly: a direct call skips + * the in-progress guard, and its own cookie removal then trips the cookie + * watcher into a second concurrent teardown. + */ + signOut(): void +} + +interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { + getWindows: () => BrowserWindow[] +} + +/** + * Owns shared app-session observers once per Electron process while allowing + * every full Sim window to contribute navigation signals. This prevents + * duplicate cookie listeners and storage clears when multiple application + * windows are open. + * + * Session *expiry* is deliberately not handled here. The web app owns it: its + * session query settles to `null` and `SessionExpired` signs out and redirects, + * by the same mechanism in the browser and the app. (The app's session row is + * its own, so the two expire on independent clocks — `updateAge` refreshes only + * the row that made the request.) A shell-side detector could only infer that + * state from cookie events and 401 statuses, which is how it ended up prompting + * on ordinary sign-outs and on launching signed out. + */ +export function createSessionLifecycleCoordinator( + deps: SessionLifecycleCoordinatorDeps +): SessionLifecycleCoordinator { + let tearingDown = false + const runTeardown = () => { + if (tearingDown) return + tearingDown = true + logger.info('Sign-out detected; clearing partition') + void tearDownSession( + deps.appSession, + deps.clearHandoffState, + deps.events, + deps.clearBrowserProfile, + // One revoke, not one per window: every window shares the partition's + // single session, so the first on-origin renderer can speak for all of + // them and the rest would be no-ops against an already-deleted row. + async () => { + const origin = deps.origin() + const win = deps.getWindows().find((candidate) => canRevokeIn(candidate, origin)) + if (win) { + await revokeAppSession(win, origin) + } + } + ) + .catch((error) => logger.error('Session teardown failed', { error })) + .finally(() => { + for (const win of deps.getWindows()) { + if (!win.isDestroyed()) { + void win.loadURL(`${deps.origin()}/login`).catch(() => {}) + } + } + // Re-arm after clearStorageData's own cookie-removal events have + // drained, so self-induced deletions never re-trigger teardown. + setTimeout(() => { + tearingDown = false + }, TEARDOWN_COOLDOWN_MS) + }) + } + + // Robust backstop: when the better-auth session cookie is deleted by ANY + // path (not just the fromLogout redirect), confirm the session is really + // gone with a probe — so cookie rotation can't cause a false teardown — then + // clear the partition. This closes the cross-account residue gap. + deps.appSession.cookies.on('changed', (_event, cookie, cause, removed) => { + if (tearingDown || !removed || cause === 'overwrite') { + return + } + if (!isSessionCookieName(cookie.name)) { + return + } + void probeSession(deps.appSession, deps.origin()).then((state) => { + if (state === 'invalid') { + runTeardown() + } + }) + }) + + return { + signOut: runTeardown, + attachWindow(win) { + const onNavigation = (url: string) => { + if (isLogoutNavigation(url, deps.origin())) { + runTeardown() + } + } + // The web app signs out with a Next.js soft navigation to + // /login?fromLogout=true, which fires did-navigate-in-page — not + // did-navigate — so both events must be observed or teardown never runs. + win.webContents.on('did-navigate', (_event, url) => onNavigation(url)) + win.webContents.on('did-navigate-in-page', (_event, url) => onNavigation(url)) + }, + } +} + +/** + * Explains that Google/Microsoft connections must finish in the browser and + * reopens the current page there — the browser holds its own signed-in + * session after the login handoff, so the connect completes and tokens land + * server-side. Back in the app, a refresh picks the connection up. + */ +export async function handleConnectIntercept( + contents: WebContents, + allowHttpLocalhost: boolean +): Promise { + const pageUrl = contents.getURL() + const win = BrowserWindow.fromWebContents(contents) + const options = { + type: 'info' as const, + buttons: ['Open in Browser', 'Cancel'], + defaultId: 0, + cancelId: 1, + message: 'Finish connecting in your browser', + detail: + 'This provider requires completing the connection in your web browser. Sim will open this page there — connect the account, then come back to the app and refresh.', + } + const { response } = win + ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options) + if (response === 0) { + await openExternalSafe(pageUrl, allowHttpLocalhost) + } +} diff --git a/apps/desktop/src/main/telemetry-policy.test.ts b/apps/desktop/src/main/telemetry-policy.test.ts new file mode 100644 index 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/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts new file mode 100644 index 00000000000..70adf203b66 --- /dev/null +++ b/apps/desktop/src/main/terminal/index.ts @@ -0,0 +1,951 @@ +/** + * The agent-terminal service: several concurrent shells, their tab ordering, + * and tool execution against them. + * + * Commands run unattended. There is no per-command approval and no OS jail, so + * anything the agent runs holds the user's own privileges — the only controls + * left are upstream: the desktop capability gate, and the tool-authorization + * check in ipc.ts that ties every call to a real pending Copilot tool call so + * page code cannot invent one. Reintroducing a boundary means adding it back + * here, in main, where the renderer cannot route around it. + */ +import { statSync } from 'node:fs' +import { homedir } from 'node:os' +import { createLogger } from '@sim/logger' +import { + DEFAULT_RUN_WAIT_MS, + isTerminalControlKey, + MAX_INPUT_KEYS, + MAX_RUN_WAIT_MS, + MAX_TERMINALS, + MAX_TOOL_OUTPUT_CHARS, + type TerminalCommandEvent, + type TerminalControlKey, + type TerminalCwdResult, + type TerminalErrorCode, + type TerminalHandoffResult, + type TerminalOperation, + type TerminalPanesResult, + type TerminalStartOptions, + type TerminalTabsState, + type TerminalToolArgs, + type TerminalToolResponse, +} from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' +import type { BrowserWindow, WebContents } from 'electron' +import { elide, TerminalSession } from '@/main/terminal/session' +import { + activePane, + awaitRun, + capturePane, + closeRunWindow, + isTmuxUnavailable, + killPane, + listPanes, + resolveAttachment, + sendKey, + sendText, + startRun, + TMUX_KEY_NAMES, + type TmuxAttachment, +} from '@/main/terminal/tmux' + +const logger = createLogger('DesktopTerminal') + +/** + * How long to let a just-spawned shell finish its startup files before + * concluding it has no integration. Generous because a heavy `.zshrc` + * (nvm, pyenv, starship) can take a while on a cold start. + */ +const SHELL_INTEGRATION_TIMEOUT_MS = 8_000 + +/** Grace for a program to react to input before its screen is worth reading. */ +const INPUT_ECHO_MS = 250 + +/** Enough of the screen to show whether the input took, without a wall of it. */ +const INPUT_SCREEN_LINES = 60 + +/** + * How often the active terminal's directory is reconciled against the OS. + * Fast enough that a `cd` renames the tab about as soon as the user looks at + * it, slow enough that the lookup is nowhere near a hot path. + */ +const CWD_POLL_MS = 1_000 + +/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */ +const TMUX_KEY_GAP_MS = 150 + +/** How long a resolved tmux attachment is reused before re-resolving. */ +const TMUX_ATTACHMENT_TTL_MS = 3_000 + +/** How often a handoff checks whether the user or the command has finished. */ +const HANDOFF_POLL_MS = 500 + +/** + * Ceiling on a handoff. A person can take as long as they like — they may + * have walked away mid-install — so this only exists so a forgotten handoff + * cannot hold a turn open forever. + */ +const HANDOFF_MAX_MS = 12 * 60 * 60 * 1000 + +/** + * Grace given to a command that is still running after the user hands back. + * Answering a prompt usually finishes the job within seconds; anything longer + * is an ordinary long-running command, and comes back as `running` for the + * agent to poll rather than holding the turn. + */ +const HANDOFF_SETTLE_MS = 5_000 + +/** How long to hold the turn before handing a still-running command back. */ +function resolveWaitMs(waitSeconds: number | undefined): number { + const requested = Number(waitSeconds) + return Number.isFinite(requested) && requested > 0 + ? Math.min(requested * 1000, MAX_RUN_WAIT_MS) + : DEFAULT_RUN_WAIT_MS +} + +function elideOutput(value: string): { text: string; truncated: boolean } { + return elide(value, MAX_TOOL_OUTPUT_CHARS) +} + +/** + * The keys an input call wants pressed, in order. A lone `key` is the same + * thing with one element, so both arrive here as one list. + */ +function requestedKeys(args: TerminalToolArgs): TerminalControlKey[] { + const batch = Array.isArray(args.keys) ? args.keys : [] + const keys = batch.length > 0 ? batch : isTerminalControlKey(args.key) ? [args.key] : [] + return keys.filter(isTerminalControlKey).slice(0, MAX_INPUT_KEYS) +} + +const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null } + +class TerminalError extends Error { + constructor( + readonly code: TerminalErrorCode, + message: string + ) { + super(message) + this.name = 'TerminalError' + } +} + +/** Where the service pushes live updates; wired to the renderer by ipc.ts. */ +export interface TerminalSink { + data(terminalId: string, data: string): void + tabs(state: TerminalTabsState): void + command(event: TerminalCommandEvent): void +} + +export interface TerminalServiceOptions { + /** + * Where the last session left off, so reopening the terminal resumes in the + * directory the user was working in rather than dropping them back in + * `$HOME`. Returning undefined (or a path that no longer exists) falls back + * to the home directory. + */ + loadCwd?(): string | undefined + saveCwd?(cwd: string): void +} + +export class TerminalService { + /** Insertion-ordered, which is also the tab order the user sees. */ + private readonly sessions = new Map() + private activeId: string | null = null + /** True while tearing every shell down, so an exit does not respawn one. */ + private disposing = false + private nextId = 1 + private sink: TerminalSink | null = null + private lastEmittedTabs: string | null = null + private cwdTimer: NodeJS.Timeout | null = null + /** + * The renderer whose terminal panel holds the user's keyboard focus, or null. + * + * The claim IS the owner — there is deliberately no separate boolean. A + * second field could hold `true` with no live owner, and that combination is + * precisely the latch this binding exists to prevent: every reader would + * have to remember to reject it, and the one that forgot would close a shell + * nobody was looking at. + */ + private focusOwner: WebContents | null = null + private releaseFocusListeners: (() => void) | null = null + /** Directories of recently closed terminals, newest first, for reopening. */ + private readonly recentlyClosedCwds: string[] = [] + /** Terminals handed to the user; the value is whether they have handed back. */ + private readonly handoffs = new Map() + /** Recently resolved tmux attachments, by terminal id, to avoid re-spawning. */ + private readonly tmuxCache = new Map() + + constructor(private readonly options: TerminalServiceOptions = {}) {} + + setSink(sink: TerminalSink | null): void { + this.sink = sink + // A new sink has seen nothing, so the dedupe baseline has to reset or the + // panel would wait for an unrelated change before learning the tab list. + this.lastEmittedTabs = null + if (sink) this.startCwdWatch() + else this.stopCwdWatch() + } + + /** + * Keeps the visible tab's directory honest without depending on the shell. + * + * Only the active session is polled, and only while a panel is attached and + * nothing is running in the foreground (a running command owns the label + * anyway), so this costs one cheap lookup a second at most — the reason it + * samples rather than watching every keystroke. + */ + private startCwdWatch(): void { + if (this.cwdTimer) return + this.cwdTimer = setInterval(() => { + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (!active || active.isBusy) return + void active.refreshCwd() + }, CWD_POLL_MS) + this.cwdTimer.unref?.() + } + + private stopCwdWatch(): void { + if (!this.cwdTimer) return + clearInterval(this.cwdTimer) + this.cwdTimer = null + } + + getTabs(): TerminalTabsState { + if (this.sessions.size === 0) return EMPTY_TABS + return { + tabs: [...this.sessions.values()].map((session) => + session.tabState(session.terminalId === this.activeId) + ), + activeTerminalId: this.activeId, + } + } + + /** Opens the first terminal, or adopts what is already running. */ + start(options: TerminalStartOptions): TerminalTabsState { + if (this.sessions.size === 0) { + this.spawn(this.startingCwd(), options.cols, options.rows) + } + return this.getTabs() + } + + /** + * Everything on a terminal's screen, for a freshly created view to paint + * itself from. + * + * Pulled by the view rather than pushed on start. Pushing meant the repaint + * was aimed at whoever happened to be subscribed at the time: on a first + * mount that is nobody, because the tab list is still empty and no view + * exists yet, so the paint was dropped and the panel came up blank over a + * shell that had been running all along. On later mounts it was everybody, + * so a panel that already had its content repainted anyway. A view asking + * for its own terminal is right in both cases, and asks exactly once. + */ + getScrollback(terminalId: string): string { + return this.sessions.get(terminalId)?.takeReplaySnapshot() ?? '' + } + + /** Opens an additional terminal and makes it active. */ + openTerminal(cwd?: string): TerminalTabsState { + if (this.sessions.size >= MAX_TERMINALS) { + throw new TerminalError( + 'TOO_MANY_TERMINALS', + `Up to ${MAX_TERMINALS} terminals can be open at once. Close one first.` + ) + } + const active = this.activeId ? this.sessions.get(this.activeId) : null + const size = active ? { cols: active.cols, rows: active.rows } : { cols: 80, rows: 24 } + // A new terminal opens where the current one is: the user is almost always + // continuing the same piece of work in a second shell. + this.spawn(cwd ?? active?.currentCwd ?? this.startingCwd(), size.cols, size.rows) + return this.getTabs() + } + + switchTerminal(terminalId: string): TerminalTabsState { + if (!this.sessions.has(terminalId)) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId)) + } + this.activeId = terminalId + this.emitTabs() + void this.sessions.get(terminalId)?.refreshCwd() + return this.getTabs() + } + + /** + * Closes a terminal, or resets it when it is the only one left. + * + * Emptying the panel is not an option the close button should have: the + * resource IS a terminal, so a panel with no shell in it is a dead end the + * user has to close and reopen to escape. Replacing the last shell with a + * fresh one in the same directory gives the button a sensible meaning at + * every count — the same shape as closing a browser's last tab, which + * leaves you a tab rather than an empty window. + * + * A shell that ends by itself — `exit`, or Ctrl-D — goes the same way. It + * leaves behind a session that can no longer do anything, so it has to be + * reaped either way; treating it as a close means the last one is replaced + * rather than leaving a dead tab that cannot be typed into. + */ + closeTerminal(terminalId: string): TerminalTabsState { + if (!this.sessions.has(terminalId)) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId)) + } + return this.retire(terminalId) + } + + /** + * Drops a terminal and decides what replaces it. Closing and exiting share + * this so the two cannot drift into different answers for "what happens to + * the last one". + */ + private retire(terminalId: string): TerminalTabsState { + const session = this.sessions.get(terminalId) + if (!session) return this.getTabs() + const closedCwd = session.currentCwd + const cols = session.cols + const rows = session.rows + const order = [...this.sessions.keys()] + const index = order.indexOf(terminalId) + session.dispose() + this.sessions.delete(terminalId) + this.tmuxCache.delete(terminalId) + + if (this.sessions.size === 0) { + this.spawn(this.resolveCwd(closedCwd), cols, rows) + return this.getTabs() + } + + this.rememberClosed(closedCwd) + if (this.activeId === terminalId) { + this.activeId = order[index + 1] ?? order[index - 1] ?? null + } + this.emitTabs() + return this.getTabs() + } + + /** + * Reopens the most recently closed terminal, in the directory it was in. + * + * A shell cannot be restored the way a browser tab can — its processes are + * gone and its scrollback with them — so this reopens where it was working, + * which is the part that is expensive for the user to retype. + */ + reopenClosedTerminal(ownerWindow: BrowserWindow | null): boolean { + if (!this.ownsInteraction(ownerWindow)) return false + // Peeked, not shifted: at the cap there is nothing to reopen into, and + // consuming the entry here would drop that directory on the floor. + if (this.recentlyClosedCwds.length === 0 || this.sessions.size >= MAX_TERMINALS) return false + const cwd = this.recentlyClosedCwds.shift() + this.openTerminal(cwd || undefined) + return true + } + + /** Closes the active terminal, but only while the panel owns interaction focus. */ + closeFocusedTerminal(ownerWindow: BrowserWindow | null): boolean { + if (!this.ownsInteraction(ownerWindow) || !this.activeId) return false + this.closeTerminal(this.activeId) + return true + } + + /** + * Whether the terminal panel owns keyboard focus. Menu accelerators are + * global, so Cmd-W has to know whether the user is looking at a terminal or + * at something else in the window before deciding what to close. + * + * The claim is bound to the renderer that made it. A reload or a window close + * never runs the renderer's cleanup, so without that binding the flag latched + * true forever and Cmd-W destroyed an invisible shell. + */ + setPanelFocused(focused: boolean, owner?: WebContents | null): void { + if (!focused) { + // Only the holder may release. Every renderer reports its own blur — a + // second window switching away from its terminal, or tearing its panel + // down, sends `false` from a WebContents that never held the claim. Left + // ungated, that erased the live claim of the window the user was + // actually typing in, and their next Cmd-W closed that window with its + // shells still running. A release with no owner named is the shell's own + // teardown (dispose), which may always release. + if (owner && this.focusOwner && owner !== this.focusOwner) return + this.releaseFocusOwner() + return + } + this.releaseFocusOwner() + // An unattributable claim is dropped rather than recorded: with no live + // renderer behind it there is nothing that could ever release it. + if (!owner || owner.isDestroyed()) return + this.focusOwner = owner + const release = () => this.setPanelFocused(false, owner) + const onNavigate = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { + // A same-document route change keeps the React tree that made the claim. + if (details.isMainFrame && !details.isSameDocument) release() + } + owner.once('destroyed', release) + owner.on('did-start-navigation', onNavigate) + this.releaseFocusListeners = () => { + if (owner.isDestroyed()) return + owner.removeListener('destroyed', release) + owner.removeListener('did-start-navigation', onNavigate) + } + } + + /** Drops the claim and unsubscribes from the owner's lifecycle. */ + private releaseFocusOwner(): void { + this.releaseFocusListeners?.() + this.releaseFocusListeners = null + this.focusOwner = null + } + + /** + * Whether a global accelerator fired in the window that actually holds the + * focused terminal panel. Without the window check a claim made in one window + * answered Cmd-W in every other one. + */ + private ownsInteraction(ownerWindow: BrowserWindow | null): boolean { + if (!this.focusOwner || this.focusOwner.isDestroyed()) return false + // Required, not optional, and null answers no. A window is what an + // accelerator arrives from, so "no window" cannot be a window this claim + // answers for — and making the parameter mandatory means a later caller + // cannot reintroduce the cross-window bug just by leaving it off. + if (!ownerWindow) return false + // The panel lives in the window's own top-level renderer, so this is an + // identity check on that renderer — and it keeps electron a type-only + // import here, which is what lets the service be tested without a shell. + return ownerWindow.webContents === this.focusOwner + } + + private rememberClosed(cwd: string | null): void { + this.recentlyClosedCwds.unshift(cwd ?? '') + if (this.recentlyClosedCwds.length > MAX_TERMINALS) { + this.recentlyClosedCwds.length = MAX_TERMINALS + } + } + + /** A directory that still exists, else the usual starting point. */ + private resolveCwd(candidate: string | null): string { + if (candidate) { + try { + if (statSync(candidate).isDirectory()) return candidate + } catch { + // Deleted while the shell was open; fall through. + } + } + return this.startingCwd() + } + + write(terminalId: string, data: string): void { + this.sessions.get(terminalId)?.write(data) + } + + resize(terminalId: string, cols: number, rows: number): void { + this.sessions.get(terminalId)?.resize(cols, rows) + } + + dispose(): void { + this.disposing = true + this.stopCwdWatch() + // Persisted here rather than left to the onState callback, which resolves + // the active session out of the very map this teardown empties. Losing it + // reopens the next launch in whichever directory last reported a change + // instead of the one the user was working in. + const activeCwd = this.activeId ? this.sessions.get(this.activeId)?.currentCwd : null + if (activeCwd) this.options.saveCwd?.(activeCwd) + // Remove each session before disposing it: dispose() emits state, which + // reads back through getTabs(), and a session still in the map there is + // published to the renderer as a live tab after its shell is gone. + for (const [terminalId, session] of [...this.sessions]) { + this.sessions.delete(terminalId) + session.dispose() + } + this.sessions.clear() + this.tmuxCache.clear() + this.activeId = null + // A stale claim here is what let Cmd-W close a shell that no longer exists. + this.setPanelFocused(false) + this.disposing = false + } + + async executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise { + try { + const result = await this.dispatch(toolCallId, operation, args ?? {}) + return { ok: true, result } + } catch (error) { + if (error instanceof TerminalError) { + logger.warn('Terminal operation refused', { toolCallId, operation, code: error.code }) + return { ok: false, error: error.message, code: error.code } + } + const message = (error as Error).message + logger.error('Terminal operation failed', { toolCallId, operation, error: message }) + return { ok: false, error: message } + } + } + + private async dispatch( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise { + switch (operation) { + case 'list': + return this.getTabs() + case 'new': + return this.openTerminal(typeof args.cwd === 'string' ? args.cwd : undefined) + case 'switch': + return this.switchTerminal(this.requireId(args)) + case 'close': + // A named pane is a tmux thing and needs the session resolved below; + // without one, close means the Sim terminal. + if (typeof args.pane !== 'string' || !args.pane.trim()) { + return this.closeTerminal(this.requireId(args)) + } + break + default: + break + } + + const session = this.requireSession(args) + // A tab either has tmux attached or it does not, and every operation below + // behaves differently depending on which. + const tmux = await this.resolveTmux(session) + + switch (operation) { + case 'cwd': + return { + cwd: session.currentCwd, + shellName: session.shell, + home: homedir(), + terminalId: session.terminalId, + } satisfies TerminalCwdResult + case 'close': { + if (!tmux) { + throw new TerminalError( + 'NO_TMUX', + 'That terminal is a plain shell, so it has no panes. Close the terminal itself by omitting `pane`.' + ) + } + const target = await this.resolvePane(tmux.session, args, session) + const killed = await killPane(target, session.env) + if (!killed.ok) { + throw new TerminalError( + 'NO_SUCH_PANE', + killed.stderr.trim() || `tmux could not close pane ${target}.` + ) + } + return { + closed: target, + terminalId: session.terminalId, + panes: await listPanes(tmux.session, session.env), + } + } + case 'handoff': + return this.handoff(session, args) + case 'panes': { + if (!tmux) { + throw new TerminalError( + 'NO_TMUX', + 'That terminal is a plain shell, not a tmux session, so it has no panes.' + ) + } + return { + terminalId: session.terminalId, + session: tmux.session, + panes: await listPanes(tmux.session, session.env), + } satisfies TerminalPanesResult + } + case 'run': + return tmux + ? this.runInTmux(session, tmux.session, args) + : this.run(toolCallId, session, args) + case 'read': { + const requested = Number(args.lines) + const lines = Number.isFinite(requested) && requested > 0 ? requested : 200 + if (!tmux) return await session.readScrollback(lines) + const target = await this.resolvePane(tmux.session, args, session) + const captured = await capturePane(target, lines, session.env) + if (!captured.ok) { + throw new TerminalError( + 'NO_SUCH_PANE', + captured.stderr.trim() || `tmux could not read pane ${target}.` + ) + } + return { + output: captured.stdout, + cwd: session.currentCwd, + terminalId: session.terminalId, + pane: target, + truncated: false, + running: null, + } + } + case 'input': + return tmux + ? this.inputToTmux(session, tmux.session, args) + : this.inputToShell(session, args) + case 'kill': { + const signal = + args.signal === 'SIGTERM' || args.signal === 'SIGKILL' || args.signal === 'SIGINT' + ? args.signal + : 'SIGINT' + // Inside tmux a signal has to arrive as a keypress in the pane. Killing + // the pty would take down the tmux client instead, detaching the user's + // whole session rather than stopping the one thing they asked about. + if (tmux) { + const target = await this.resolvePane(tmux.session, args, session) + await sendKey(target, signal === 'SIGKILL' ? 'C-\\' : 'C-c', session.env) + return { signal, terminalId: session.terminalId, pane: target } + } + session.kill(signal) + return { signal, terminalId: session.terminalId } + } + default: + throw new TerminalError('INVALID_REQUEST', `Unknown terminal operation: ${operation}`) + } + } + + /** + * Gives the terminal to the user and waits for them. + * + * A command sitting on a prompt it cannot answer — a password, a decision + * that is not the agent's to make — otherwise leaves the tool call spinning + * with nothing on screen to explain why. This surfaces a chip in the chat + * saying what is needed, and resolves when the command that was blocking + * finishes, so the agent resumes knowing the outcome rather than guessing + * whether the user got to it. + */ + private async handoff(session: TerminalSession, args: TerminalToolArgs): Promise { + const reason = typeof args.reason === 'string' ? args.reason.trim() : '' + const terminalId = session.terminalId + this.handoffs.set(terminalId, false) + + const settled = async (handedBack: boolean): Promise => { + const view = await session.readScrollback(INPUT_SCREEN_LINES) + return { + terminalId, + reason, + handedBack, + running: session.foreground, + output: view.output, + cwd: session.currentCwd, + } + } + + try { + const deadline = Date.now() + HANDOFF_MAX_MS + let handedBackAt: number | null = null + while (Date.now() < deadline) { + await sleep(HANDOFF_POLL_MS) + if (!session.alive) { + throw new TerminalError('SESSION_CLOSED', 'That terminal was closed during the handoff.') + } + // The command finishing is the real end of the handoff, whether or not + // the user pressed anything: it means the prompt got answered. + if (!session.isBusy) return await settled(this.handoffs.get(terminalId) === true) + if (this.handoffs.get(terminalId) === true) { + handedBackAt ??= Date.now() + if (Date.now() - handedBackAt >= HANDOFF_SETTLE_MS) return await settled(true) + } + } + return await settled(this.handoffs.get(terminalId) === true) + } finally { + this.handoffs.delete(terminalId) + } + } + + /** The user pressing the hand-back button on a waiting handoff. */ + finishHandoff(terminalId: string): void { + if (this.handoffs.has(terminalId)) this.handoffs.set(terminalId, true) + } + + /** + * The tmux session attached in a terminal, cached briefly. + * + * Resolving it spawns `tmux list-clients` and a whole-machine `ps` — a real + * cost to pay on every tool call, when a shell's attachment does not change + * between calls a second apart. A short TTL keeps the common burst of + * operations (run, then poll with read, then read again) to one resolution, + * while staying fresh enough to notice the user starting or leaving tmux. + */ + private async resolveTmux(session: TerminalSession): Promise { + if (isTmuxUnavailable()) return null + const cached = this.tmuxCache.get(session.terminalId) + if (cached && Date.now() - cached.at < TMUX_ATTACHMENT_TTL_MS) { + return cached.attachment + } + const attachment = await resolveAttachment(session.pid, session.env) + this.tmuxCache.set(session.terminalId, { at: Date.now(), attachment }) + return attachment + } + + /** The pane a call names, or the session's active one. */ + private async resolvePane( + session: string, + args: TerminalToolArgs, + terminal: TerminalSession + ): Promise { + if (typeof args.pane === 'string' && args.pane.trim()) return args.pane.trim() + const active = await activePane(session, terminal.env) + if (!active) { + throw new TerminalError('NO_SUCH_PANE', `tmux session "${session}" has no active pane.`) + } + return active + } + + /** + * Types into a tmux pane rather than the pty. + * + * Writing to the pty would reach whichever pane tmux happens to have focused + * and would be invisible to any targeting the caller asked for; send-keys + * addresses a pane directly. Unlike the plain-shell path this is allowed at + * an idle prompt, because in tmux there is no foreground command to gate on + * and typing a command into a pane is the normal way to drive one. + */ + private async inputToTmux( + terminal: TerminalSession, + session: string, + args: TerminalToolArgs + ): Promise { + const target = await this.resolvePane(session, args, terminal) + const keys = requestedKeys(args) + if (keys.length > 0) { + for (let index = 0; index < keys.length; index += 1) { + // Paced like the pty path: a pane redraws between presses, so a batch + // lands where the same keys pressed by hand would. + if (index > 0) await sleep(TMUX_KEY_GAP_MS) + await sendKey(target, TMUX_KEY_NAMES[keys[index]] ?? keys[index], terminal.env) + } + } else if (typeof args.text === 'string') { + await sendText(target, args.text, terminal.env) + // Enter is a separate send-keys for the same reason it is a separate pty + // write: a program reading one chunk treats text plus a carriage return + // as text, and the message sits unsubmitted. + if (/[\r\n]$/.test(args.text)) await sendKey(target, 'Enter', terminal.env) + } else { + throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.') + } + + await sleep(INPUT_ECHO_MS) + const captured = await capturePane(target, INPUT_SCREEN_LINES, terminal.env) + return { + sent: keys.length > 0 ? keys.join(', ') : args.text, + terminalId: terminal.terminalId, + pane: target, + output: captured.stdout, + } + } + + private async inputToShell(session: TerminalSession, args: TerminalToolArgs): Promise { + // Input is only ever delivered to a program that already holds the + // foreground. At a bare shell prompt these bytes would be a command + // line, and running commands that way would bypass the capture and + // status tracking that `run` provides. + if (!session.isBusy) { + throw new TerminalError( + 'INVALID_REQUEST', + 'Nothing is running in that terminal, so there is nothing to type into. Use the run operation to run a command.' + ) + } + // Every input returns the screen it produced. Reporting only "sent" + // lets the model assume its message went through and start waiting on + // a reply to text still sitting unsubmitted in a composer; the screen + // is the evidence of what the program actually did with the input. + const keys = requestedKeys(args) + if (keys.length > 0) { + await session.pressKeys(keys) + await sleep(INPUT_ECHO_MS) + return { sent: keys.join(', '), ...(await session.readScrollback(INPUT_SCREEN_LINES)) } + } + if (typeof args.text === 'string') { + await session.type(args.text) + await sleep(INPUT_ECHO_MS) + return { sent: args.text, ...(await session.readScrollback(INPUT_SCREEN_LINES)) } + } + throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.') + } + + /** + * Runs a command inside a tmux session, in its own window. + * + * The user's panes are theirs; borrowing one would type over whatever they + * are doing. A dedicated window is still visible to them — they can switch + * to it and watch — while output and the exit status come back through + * files, so the result is structured even though shell integration cannot + * see through tmux. + */ + private async runInTmux( + terminal: TerminalSession, + session: string, + args: TerminalToolArgs + ): Promise { + const command = typeof args.command === 'string' ? args.command.trim() : '' + if (!command) throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.') + + const started = Date.now() + const handle = await startRun(session, command, terminal.currentCwd, terminal.env) + if ('error' in handle) throw new TerminalError('SPAWN_FAILED', handle.error) + + const waitMs = resolveWaitMs(args.waitSeconds) + const outcome = await awaitRun(handle, waitMs) + if (outcome.done) { + await closeRunWindow(handle, terminal.env) + handle.dispose() + } + + const { text, truncated } = elideOutput(outcome.output) + return { + command, + output: text, + status: outcome.done ? 'completed' : 'running', + exitCode: outcome.exitCode, + durationMs: Date.now() - started, + cwd: terminal.currentCwd, + terminalId: terminal.terminalId, + pane: handle.window, + truncated, + } + } + + private async run( + toolCallId: string, + session: TerminalSession, + args: TerminalToolArgs + ): Promise { + const command = typeof args.command === 'string' ? args.command.trim() : '' + if (!command) { + throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.') + } + if (!session.hasShellIntegration) { + await session.waitForShellIntegration(SHELL_INTEGRATION_TIMEOUT_MS) + } + if (!session.hasShellIntegration) { + throw new TerminalError( + 'NO_SHELL_INTEGRATION', + 'This shell did not load Sim shell integration, so command boundaries and exit codes cannot be determined. Ask the user to run the command themselves, or use a bash/zsh session.' + ) + } + if (session.isBusy) { + throw new TerminalError( + 'BUSY', + `"${session.foreground}" is still running in that terminal. Poll it with the read operation, stop it with kill, or open another terminal with new.` + ) + } + + return session.runCommand(command, toolCallId, resolveWaitMs(args.waitSeconds)) + } + + private spawn(cwd: string, cols: number, rows: number): TerminalSession { + const terminalId = String(this.nextId++) + try { + const session = TerminalSession.create({ + terminalId, + cwd, + cols, + rows, + callbacks: { + onData: (id, data) => this.sink?.data(id, data), + onState: () => { + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (active?.currentCwd) this.options.saveCwd?.(active.currentCwd) + this.emitTabs() + }, + onCommand: (event) => this.sink?.command(event), + onExit: (id) => { + // Not during shutdown: every shell is ending then, and replacing + // the last one would spawn a shell as the app is closing. + if (this.disposing) return + this.retire(id) + }, + }, + }) + this.sessions.set(terminalId, session) + this.activeId = terminalId + this.emitTabs() + return session + } catch (error) { + throw new TerminalError('SPAWN_FAILED', (error as Error).message) + } + } + + /** + * Resolves the terminal a tool call targets: the one it named, else the + * active one. Starting a shell on demand keeps a tool call from depending on + * the panel having finished mounting — the renderer opens the resource and + * dispatches the tool in the same tick, so the panel's own `start` usually + * lands after the tool arrives. + */ + private requireSession(args: TerminalToolArgs): TerminalSession { + const requested = typeof args.terminalId === 'string' ? args.terminalId : null + if (requested) { + const session = this.sessions.get(requested) + if (!session?.alive) { + throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(requested)) + } + return session + } + + const active = this.activeId ? this.sessions.get(this.activeId) : null + if (active?.alive) return active + + const spawned = this.spawn(this.startingCwd(), 80, 24) + if (!spawned.alive) { + throw new TerminalError('SPAWN_FAILED', 'Could not open a terminal on this machine.') + } + return spawned + } + + private requireId(args: TerminalToolArgs): string { + const terminalId = typeof args.terminalId === 'string' ? args.terminalId.trim() : '' + if (!terminalId) { + throw new TerminalError( + 'INVALID_REQUEST', + 'This operation needs a `terminalId` from the list operation.' + ) + } + return terminalId + } + + /** + * The remembered directory when it still exists, else home. A saved path can + * disappear between launches (a branch checkout, a deleted clone), and + * spawning into a missing cwd fails outright rather than degrading. + */ + private startingCwd(): string { + const remembered = this.options.loadCwd?.() + if (remembered) { + try { + if (statSync(remembered).isDirectory()) return remembered + } catch { + // Gone since last launch; fall through to home. + } + } + return homedir() + } + + /** + * Broadcasts the tab list only when it has actually changed. + * + * Session state is emitted on every shell-integration marker, and a shell + * repaints its prompt on each resize — so a divider drag would otherwise + * push a stream of identical tab lists at the renderer and re-render the + * panel for nothing. + */ + private emitTabs(): void { + const tabs = this.getTabs() + const serialized = JSON.stringify(tabs) + if (serialized === this.lastEmittedTabs) return + this.lastEmittedTabs = serialized + this.sink?.tabs(tabs) + } +} + +function unknownTerminal(terminalId: string): string { + return `No terminal with id ${terminalId}. Call terminal_list for the open ones.` +} + +/** Narrows an IPC payload to the tool-call shape without trusting the sender. */ +export function parseToolParams(value: unknown): Record { + return isRecordLike(value) ? value : {} +} diff --git a/apps/desktop/src/main/terminal/process-cwd.test.ts b/apps/desktop/src/main/terminal/process-cwd.test.ts new file mode 100644 index 00000000000..6f88f203b2c --- /dev/null +++ b/apps/desktop/src/main/terminal/process-cwd.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { parseLsofCwd, readProcessCwd } from '@/main/terminal/process-cwd' + +describe('parseLsofCwd', () => { + it('picks the path out of lsof field output', () => { + expect(parseLsofCwd('p123\nfcwd\nn/Users/me/project\n')).toBe('/Users/me/project') + }) + + it('keeps paths containing spaces intact', () => { + expect(parseLsofCwd('p1\nfcwd\nn/Users/me/My Code/app\n')).toBe('/Users/me/My Code/app') + }) + + it('returns null when no path field is present', () => { + expect(parseLsofCwd('')).toBeNull() + expect(parseLsofCwd('p123\nfcwd\n')).toBeNull() + // A field line that is not an absolute path is not a cwd. + expect(parseLsofCwd('p123\nnrelative/path\n')).toBeNull() + }) +}) + +describe('readProcessCwd', () => { + it('rejects invalid pids without touching the OS', async () => { + await expect(readProcessCwd(0)).resolves.toBeNull() + await expect(readProcessCwd(-1)).resolves.toBeNull() + await expect(readProcessCwd(Number.NaN)).resolves.toBeNull() + }) + + it('resolves this process to a real directory', async () => { + const cwd = await readProcessCwd(process.pid) + // Unsupported platforms report null rather than guessing. + if (process.platform !== 'darwin' && process.platform !== 'linux') { + expect(cwd).toBeNull() + return + } + expect(cwd?.startsWith('/')).toBe(true) + }) + + it('returns null for a pid that does not exist', async () => { + await expect(readProcessCwd(2_147_483_600)).resolves.toBeNull() + }) +}) diff --git a/apps/desktop/src/main/terminal/process-cwd.ts b/apps/desktop/src/main/terminal/process-cwd.ts new file mode 100644 index 00000000000..4389d4d9c8c --- /dev/null +++ b/apps/desktop/src/main/terminal/process-cwd.ts @@ -0,0 +1,91 @@ +/** + * Reads a running process's working directory from the OS. + * + * The shell announces `cd` through its shell-integration hooks, but only when + * those hooks are installed — a shell we cannot instrument (fish, nushell), a + * config that clobbers the prompt hooks, or a shell started before the hooks + * loaded leaves the reported directory frozen at spawn. Asking the OS for the + * shell's real cwd needs no cooperation from the shell at all, so it is the + * source of truth the tab title falls back on. This mirrors how VS Code + * resolves a terminal's cwd (`lsof` on macOS, procfs on Linux). + */ +import { spawn } from 'node:child_process' +import { readlink } from 'node:fs/promises' +import { createLogger } from '@sim/logger' + +const logger = createLogger('DesktopTerminalProcessCwd') + +/** A hung `lsof` must never wedge the poller; the call is best-effort. */ +const LOOKUP_TIMEOUT_MS = 2_000 + +/** + * The current working directory of `pid`, or null when it cannot be resolved + * (process gone, permission denied, unsupported platform). Never throws. + */ +export async function readProcessCwd(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) return null + if (process.platform === 'linux') return readProcCwd(pid) + if (process.platform === 'darwin') return readLsofCwd(pid) + return null +} + +/** Linux: the kernel exposes the cwd as a symlink, so no subprocess is needed. */ +async function readProcCwd(pid: number): Promise { + try { + return await readlink(`/proc/${pid}/cwd`) + } catch { + return null + } +} + +/** + * macOS has no procfs, so the cwd comes from `lsof`. The query is narrowed to + * one process and one descriptor (`-a -d cwd -p `) and asks for field + * output (`-Fn`), which prints `n` — far cheaper than an unfiltered + * `lsof` that would enumerate every open file on the system. + */ +function readLsofCwd(pid: number): Promise { + return new Promise((resolve) => { + let child: ReturnType + try { + child = spawn('lsof', ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch (error) { + logger.warn('Could not run lsof for terminal cwd', { error: (error as Error).message }) + resolve(null) + return + } + + let stdout = '' + let settled = false + const finish = (value: string | null) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + + const timer = setTimeout(() => { + child.kill('SIGKILL') + finish(null) + }, LOOKUP_TIMEOUT_MS) + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.on('error', () => finish(null)) + child.on('close', () => finish(parseLsofCwd(stdout))) + }) +} + +/** + * Picks the path out of `lsof -Fn` field output, whose lines are a one-letter + * field type followed by the value (`n/Users/me/project`). + */ +export function parseLsofCwd(stdout: string): string | null { + for (const line of stdout.split('\n')) { + if (line.startsWith('n/')) return line.slice(1) + } + return null +} diff --git a/apps/desktop/src/main/terminal/selection.test.ts b/apps/desktop/src/main/terminal/selection.test.ts new file mode 100644 index 00000000000..a791350d845 --- /dev/null +++ b/apps/desktop/src/main/terminal/selection.test.ts @@ -0,0 +1,136 @@ +import { sleep } from '@sim/utils/helpers' +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { findSelectedRow } from '@/main/terminal/session' + +const REVERSE = '\u001b[7m' +const RESET = '\u001b[0m' + +/** + * Writes to a real headless emulator and lets it settle, so these exercise the + * same buffer the agent reads rather than a hand-built fake. xterm parses + * asynchronously, hence the flush. + */ +async function screen(write: (term: Terminal) => void, rows = 8): Promise { + const term = new Terminal({ cols: 40, rows, allowProposedApi: true }) + write(term) + await sleep(30) + return term +} + +/** A row painted end to end, the way a TUI marks the current item. */ +function painted(text: string): string { + return `${REVERSE}${text.padEnd(40)}${RESET}` +} + +describe('findSelectedRow', () => { + it('finds the row a menu has highlighted', async () => { + const term = await screen((t) => { + t.write('Pick one:\r\n') + t.write(' alpha\r\n') + t.write(`${painted('> bravo')}\r\n`) + t.write(' charlie\r\n') + }) + + const row = findSelectedRow(term.buffer.active) + + expect(row).not.toBeNull() + expect( + term.buffer.active + .getLine(row as number) + ?.translateToString(true) + .trim() + ).toBe('> bravo') + }) + + it('marks nothing on an ordinary screen', async () => { + // Plain command output must never come back with a row labelled selected. + const term = await screen((t) => { + t.write('total 24\r\ndrwxr-xr-x src\r\n-rw-r--r-- package.json\r\n') + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) + + it('is not fooled by a few coloured words in output', async () => { + const term = await screen((t) => { + t.write(`\u001b[31mERROR\u001b[0m something went wrong\r\n`) + t.write(`\u001b[32mPASS\u001b[0m all good\r\n`) + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) + + it('prefers a menu row over a status bar painted at the bottom', async () => { + // tmux, vim and htop all paint a full-width bar at an edge; taking that as + // the selection would point the agent at the wrong row entirely. + const term = await screen((t) => { + t.write(' alpha\r\n') + t.write(`${painted('> bravo')}\r\n`) + t.write(' charlie\r\n') + t.write('\u001b[8;1H') + t.write(painted('[0] 0:zsh* "host" 12:00')) + }) + + const row = findSelectedRow(term.buffer.active) + + expect( + term.buffer.active + .getLine(row as number) + ?.translateToString(true) + .trim() + ).toBe('> bravo') + }) + + it('marks nothing rather than guessing between several painted rows', async () => { + // A wrong label sends the agent somewhere it did not intend to go, which + // is worse than it having to look for itself. + const term = await screen((t) => { + t.write(`${painted('one')}\r\n`) + t.write(`${painted('two')}\r\n`) + t.write(`${painted('three')}\r\n`) + t.write('plain\r\n') + }) + + expect(findSelectedRow(term.buffer.active)).toBeNull() + }) +}) + +describe('replaying retained bytes into a fresh emulator', () => { + /** + * The screen is now rendered by replaying the retained byte stream into an + * emulator built for the read, rather than keeping one fed forever. Two + * things have to hold for that to be equivalent. + */ + it('has the whole stream parsed by the time the write callback fires', async () => { + // xterm parses asynchronously in 12ms slices, so reading the buffer right + // after write() would catch it half-parsed. The callback is the signal. + const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true }) + const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`) + + await new Promise((resolve) => term.write(`${lines.join('\r\n')}\r\n`, resolve)) + + const buffer = term.buffer.active + const rendered: string[] = [] + for (let row = 0; row < buffer.length; row++) { + const text = buffer.getLine(row)?.translateToString(true) ?? '' + if (text.trim()) rendered.push(text.trim()) + } + expect(rendered.at(-1)).toBe('line 199') + expect(rendered).toHaveLength(200) + }) + + it('reconstructs the final screen of a program that redraws in place', async () => { + // A TUI overwrites the same rows, so a replay must end on the last frame + // rather than showing every frame stacked up — the reason a raw byte dump + // cannot be handed to the model directly. + const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true }) + const frames = ['Loading ', 'Loading. ', 'Loading.. ', 'Done! '] + const stream = frames.map((frame) => `\u001b[H\u001b[2K${frame}`).join('') + + await new Promise((resolve) => term.write(stream, resolve)) + + const firstRow = term.buffer.active.getLine(0)?.translateToString(true).trim() + expect(firstRow).toBe('Done!') + }) +}) diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts new file mode 100644 index 00000000000..11b1c1b5645 --- /dev/null +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -0,0 +1,451 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAX_TERMINALS } from '@sim/terminal-protocol' +import { describe, expect, it, vi } from 'vitest' +import { TerminalService } from '@/main/terminal' + +/** Stub sessions by terminal id, populated by the mock below. */ +const { stubSessions } = vi.hoisted(() => ({ + stubSessions: new Map(), +})) + +/** + * These cover the rules the service enforces around closing, without spawning + * real shells: node-pty is stubbed so a "session" is just an object the + * service tracks. The behaviour under test is which terminals exist afterwards + * and which one is active, not anything a pty does. + */ +vi.mock('@/main/terminal/session', async () => { + const actual = + await vi.importActual('@/main/terminal/session') + let nextPid = 1000 + return { + ...actual, + TerminalSession: { + create: ({ + terminalId, + cwd, + cols, + rows, + callbacks, + }: Record & { + terminalId: string + callbacks: { onExit(terminalId: string): void } + }) => { + const state = { cwd, disposed: false, busy: false } + const stub = { + setBusy: (busy: boolean) => { + state.busy = busy + }, + /** Stands in for the user running `exit` or pressing Ctrl-D. */ + exit: () => { + state.disposed = true + callbacks.onExit(terminalId) + }, + terminalId, + cols, + rows, + pid: nextPid++, + env: {}, + get alive() { + return !state.disposed + }, + get currentCwd() { + return state.cwd + }, + shell: 'zsh', + get foreground() { + return state.busy ? 'sleep 1' : null + }, + get isBusy() { + return state.busy + }, + hasShellIntegration: true, + /** Real sessions poll the pty here; a stub's cwd only ever changes on open. */ + refreshCwd: async () => {}, + dispose: () => { + state.disposed = true + }, + tabState: (active: boolean) => ({ + terminalId, + title: 'zsh', + cwd: state.cwd, + running: null, + interactive: false, + active, + }), + takeReplaySnapshot: () => '', + readScrollback: () => ({ + output: 'Do you want to proceed? [y/N]', + cwd: state.cwd, + terminalId, + truncated: false, + running: state.busy ? 'sleep 1' : null, + }), + } + stubSessions.set(terminalId, stub) + return stub + }, + }, + } +}) + +function service(): TerminalService { + return new TerminalService({ loadCwd: () => '/tmp', saveCwd: () => {} }) +} + +describe('closing terminals', () => { + it('closes one of several and activates a neighbour', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal() + const secondId = second.activeTerminalId + + const after = terminal.closeTerminal(secondId as string) + + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(secondId) + }) + + it('resets the last terminal instead of emptying the panel', () => { + // A panel whose resource IS a terminal must never be left with no shell: + // there is nothing to show and no way back from inside it. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const onlyId = started.activeTerminalId as string + + const after = terminal.closeTerminal(onlyId) + + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(onlyId) + }) + + it('refuses to close a terminal that does not exist', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + + expect(() => terminal.closeTerminal('no-such-terminal')).toThrow() + }) + + it('persists the active terminal cwd on dispose', () => { + // The saved cwd is what the next launch reopens into. It is otherwise only + // written when a session REPORTS a cwd change, and switching tabs is not + // one — so without an explicit save at teardown, quitting after a switch + // reopens in the directory of whichever tab last moved. + // Real directories: a remembered cwd that no longer exists falls back to + // home, which would make this assert nothing. + const projectA = mkdtempSync(join(tmpdir(), 'sim-term-a-')) + const projectB = mkdtempSync(join(tmpdir(), 'sim-term-b-')) + const saveCwd = vi.fn() + const terminal = new TerminalService({ loadCwd: () => projectA, saveCwd }) + terminal.start({ cols: 80, rows: 24 }) + const first = terminal.getTabs().activeTerminalId as string + terminal.openTerminal(projectB) + terminal.switchTerminal(first) + saveCwd.mockClear() + + terminal.dispose() + + expect(saveCwd).toHaveBeenCalledWith(projectA) + }) +}) + +type OwnerWindow = Parameters[0] + +/** + * A stand-in for one app window and the renderer inside it. + * + * Focus claims are bound to the renderer that made them, and every accelerator + * arrives from a window, so the pair travels together — `contents` makes the + * claim, `window` is what a Cmd-W from that window looks like. Two stubs model + * two windows, which is what the cross-window cases need. + */ +function rendererStub() { + const listeners = new Map void>() + const contents = { + isDestroyed: () => false, + once: (event: string, fn: (...args: unknown[]) => void) => listeners.set(event, fn), + on: (event: string, fn: (...args: unknown[]) => void) => listeners.set(event, fn), + removeListener: (event: string) => listeners.delete(event), + } as unknown as Parameters[1] + return { + contents, + /** The window hosting this renderer, for the accelerator-side calls. */ + window: { webContents: contents } as unknown as OwnerWindow, + /** Fire a main-process lifecycle event the renderer would not survive. */ + emit: (event: string, ...args: unknown[]) => listeners.get(event)?.(...args), + } +} + +describe('focus-gated shortcuts', () => { + it('ignores close and reopen while the panel is not focused', () => { + // Cmd-W and Cmd-Shift-T are global menu accelerators, so they arrive even + // when the user is working somewhere else entirely. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + }) + + it('closes the active terminal once the panel has focus', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('reopens a closed terminal, and has nothing to reopen before one closes', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + + const second = terminal.openTerminal() + terminal.closeTerminal(second.activeTerminalId as string) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(2) + }) + + it('does not remember a reset as a closed terminal to reopen', () => { + // Resetting the last terminal replaces it in place; offering to "reopen" + // it would just add a duplicate of the shell already on screen. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + terminal.closeTerminal(started.activeTerminalId as string) + + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('drops the focus claim when the renderer that made it reloads', () => { + // A reload never runs the renderer's cleanup, so nothing sends focus:false. + // The claim used to latch true forever, and the next Cmd-W destroyed a + // shell the user could no longer see. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(2) + }) + + it('keeps the claim across a same-document route change', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + }) + + it('answers only the window whose renderer holds the claim', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + expect(terminal.closeFocusedTerminal(rendererStub().window)).toBe(false) + // And null — no window at all cannot be the window a claim answers for. + expect(terminal.closeFocusedTerminal(null)).toBe(false) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true) + }) + + it('does not consume the reopen history when already at the terminal cap', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + const closed = terminal.openTerminal('/alpha') + terminal.closeTerminal(closed.activeTerminalId as string) + while (terminal.getTabs().tabs.length < MAX_TERMINALS) terminal.openTerminal() + + // Refused, and without opening anything. + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false) + expect(terminal.getTabs().tabs).toHaveLength(MAX_TERMINALS) + + // NOTE: that the '/alpha' entry SURVIVES the refusal is the actual point of + // the guard, and it is not observable from out here — every close prepends + // one history entry and frees exactly one slot, so a reopen can never walk + // back past the entries created by the closes that made room for it. The + // ordering in reopenClosedTerminal (check the cap, then shift) is what + // carries it; this test only pins the refusal itself. + terminal.closeTerminal(terminal.getTabs().activeTerminalId as string) + expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true) + }) + + it('ignores a blur reported by a renderer that does not hold the claim', () => { + // Every renderer reports its own blur, so a second window switching away + // from its terminal sends `false` from a WebContents that never claimed. + // Honouring that erased the live claim of the window the user was typing + // in, and their next Cmd-W closed that window with its shells running. + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const holder = rendererStub() + const other = rendererStub() + terminal.setPanelFocused(true, holder.contents) + + terminal.setPanelFocused(false, other.contents) + + expect(terminal.closeFocusedTerminal(holder.window)).toBe(true) + }) + + it('honours a blur from the renderer that does hold the claim', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + terminal.openTerminal() + const holder = rendererStub() + terminal.setPanelFocused(true, holder.contents) + + terminal.setPanelFocused(false, holder.contents) + + expect(terminal.closeFocusedTerminal(holder.window)).toBe(false) + }) + + it('drops the focus claim when the whole service is disposed', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const renderer = rendererStub() + terminal.setPanelFocused(true, renderer.contents) + + terminal.dispose() + // A fresh shell after teardown, so this asserts the CLAIM was dropped + // rather than passing on the "no active terminal" arm. + terminal.start({ cols: 80, rows: 24 }) + + expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false) + }) +}) + +describe('handing the terminal to the user', () => { + it('resolves when the blocked command finishes, without the user pressing anything', async () => { + // Answering the prompt in the panel is the common case: the command + // completes and the agent should just carry on. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const id = started.activeTerminalId as string + stubSessions.get(id)?.setBusy(true) + + const handoff = terminal.executeTool('call-1', 'handoff', { + terminalId: id, + reason: 'Confirm the install', + }) + setTimeout(() => stubSessions.get(id)?.setBusy(false), 20) + const response = await handoff + + expect(response.ok).toBe(true) + const result = response.result as { + handedBack: boolean + running: string | null + reason: string + } + expect(result.handedBack).toBe(false) + expect(result.running).toBeNull() + expect(result.reason).toBe('Confirm the install') + }) + + it('ignores a hand-back for a terminal that is not waiting', () => { + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + + expect(() => terminal.finishHandoff(started.activeTerminalId as string)).not.toThrow() + }) + + it('fails the handoff if the terminal is closed while it waits', async () => { + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + const id = started.activeTerminalId as string + stubSessions.get(id)?.setBusy(true) + + const handoff = terminal.executeTool('call-1', 'handoff', { terminalId: id, reason: 'Sign in' }) + setTimeout(() => terminal.dispose(), 20) + const response = await handoff + + expect(response.ok).toBe(false) + expect(response.code).toBe('SESSION_CLOSED') + }) +}) + +describe('closing', () => { + it('closes the Sim terminal when no pane is named', async () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal() + + const response = await terminal.executeTool('call-1', 'close', { + terminalId: second.activeTerminalId as string, + }) + + expect(response.ok).toBe(true) + expect(terminal.getTabs().tabs).toHaveLength(1) + }) + + it('refuses to close a pane in a terminal that has no tmux', async () => { + // Naming a pane in a plain shell is a mistake worth saying out loud, not + // silently closing the whole terminal instead. + const terminal = service() + const started = terminal.start({ cols: 80, rows: 24 }) + + const response = await terminal.executeTool('call-1', 'close', { + terminalId: started.activeTerminalId as string, + pane: 'main:1.0', + }) + + expect(response.ok).toBe(false) + expect(response.code).toBe('NO_TMUX') + expect(terminal.getTabs().tabs).toHaveLength(1) + }) +}) + +describe('a shell that ends by itself', () => { + it('replaces the only terminal instead of leaving a dead tab', () => { + const terminal = service() + const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) + const original = activeTerminalId as string + + stubSessions.get(original)?.exit() + + // The panel's whole content is the terminal, so an exited last shell used + // to sit there unusable — nothing to type into and no way to get it back. + const after = terminal.getTabs() + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).not.toBe(original) + expect(after.tabs[0]?.terminalId).toBe(after.activeTerminalId) + }) + + it('removes one of several and activates a neighbour', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + const second = terminal.openTerminal().activeTerminalId as string + + stubSessions.get(second)?.exit() + + const after = terminal.getTabs() + expect(after.tabs.map((tab) => tab.terminalId)).not.toContain(second) + expect(after.tabs).toHaveLength(1) + expect(after.activeTerminalId).toBe(after.tabs[0]?.terminalId) + }) +}) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts new file mode 100644 index 00000000000..d11886d3071 --- /dev/null +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { stripAnsi, stripTerminalQueries, toInputChunks } from '@/main/terminal/session' + +describe('toInputChunks', () => { + it('separates Enter from the text so the text is actually submitted', () => { + // A full-screen program reads one stdin chunk as one input event: "hi\r" + // arriving together is read as text, lands in the composer, and never + // submits. Enter has to be its own chunk. + expect(toInputChunks('hi\n')).toEqual(['hi', '\r']) + }) + + it('sends Enter as carriage return, not linefeed', () => { + expect(toInputChunks('hi\n')[1]).toBe('\r') + }) + + it('collapses CRLF so one Enter is not sent twice', () => { + expect(toInputChunks('hi\r\n')).toEqual(['hi', '\r']) + }) + + it('treats a bare carriage return as one Enter', () => { + expect(toInputChunks('hi\r')).toEqual(['hi', '\r']) + }) + + it('breaks multi-line input into alternating text and Enter', () => { + expect(toInputChunks('one\ntwo\nthree')).toEqual(['one', '\r', 'two', '\r', 'three']) + }) + + it('keeps a blank line as an Enter rather than an empty write', () => { + expect(toInputChunks('\n')).toEqual(['\r']) + expect(toInputChunks('a\n\nb')).toEqual(['a', '\r', '\r', 'b']) + }) + + it('leaves text without line breaks as a single chunk', () => { + expect(toInputChunks('y')).toEqual(['y']) + }) + + it('sends nothing for empty text', () => { + expect(toInputChunks('')).toEqual([]) + }) +}) + +describe('stripAnsi', () => { + it('removes colour and cursor sequences so the model reads plain text', () => { + expect(stripAnsi('\u001b[31mred\u001b[0m')).toBe('red') + expect(stripAnsi('a\u001b[2Kb')).toBe('ab') + }) + + it('removes OSC sequences including their terminator', () => { + expect(stripAnsi('before\u001b]0;window title\u0007after')).toBe('beforeafter') + }) + + it('keeps newlines and tabs, which carry real structure', () => { + expect(stripAnsi('one\ntwo\tthree')).toBe('one\ntwo\tthree') + }) +}) + +describe('stripTerminalQueries', () => { + // Replaying recorded bytes into a live emulator makes it answer every query + // in the recording, and those answers are written to the pty as keystrokes. + // With the asker long gone they pile up on the shell prompt as junk. + it('removes device attribute queries and their replies', () => { + expect(stripTerminalQueries('a\u001b[cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[>cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[?1;2cb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[>0;276;0cb')).toBe('ab') + }) + + it('removes device status reports', () => { + expect(stripTerminalQueries('a\u001b[6nb')).toBe('ab') + expect(stripTerminalQueries('a\u001b[?6nb')).toBe('ab') + }) + + it('removes the OSC colour queries that echo as rgb: junk', () => { + expect(stripTerminalQueries('a\u001b]10;?\u0007b')).toBe('ab') + expect(stripTerminalQueries('a\u001b]11;?\u001b\\b')).toBe('ab') + }) + + it('removes XTVERSION without touching cursor style', () => { + expect(stripTerminalQueries('a\u001b[>0qb')).toBe('ab') + // `CSI q` sets the cursor shape and must survive, so the `>` is required. + expect(stripTerminalQueries('a\u001b[2 qb')).toBe('a\u001b[2 qb') + }) + + it('removes the capability query', () => { + expect(stripTerminalQueries('a\u001bP+q544e\u001b\\b')).toBe('ab') + }) + + it('leaves colour and formatting in the recording intact', () => { + expect(stripTerminalQueries('\u001b[31mred\u001b[0m\r\n')).toBe('\u001b[31mred\u001b[0m\r\n') + }) + + it('leaves text with no queries untouched', () => { + expect(stripTerminalQueries('plain output\n')).toBe('plain output\n') + }) +}) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts new file mode 100644 index 00000000000..62fecc6f598 --- /dev/null +++ b/apps/desktop/src/main/terminal/session.ts @@ -0,0 +1,990 @@ +/** + * The PTY session behind the agent terminal. + * + * One `node-pty` process, shared by the user and the agent, so `cd`, exported + * variables, and scrollback are common to both. The session owns output + * batching, the scrollback ring buffer, and the command lifecycle derived from + * shell-integration markers. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { IPty } from '@lydell/node-pty' +import { spawn } from '@lydell/node-pty' +import { createLogger } from '@sim/logger' +import { + MAX_CAPTURE_CHARS, + MAX_SCROLLBACK_CHARS, + MAX_TOOL_OUTPUT_CHARS, + PROMPT_IDLE_MS, + type TerminalCommandEvent, + type TerminalControlKey, + type TerminalReadResult, + type TerminalRunResult, + type TerminalTabState, +} from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' +import { + Terminal as HeadlessTerminal, + type IBuffer, + type IBufferCell, + type IBufferLine, +} from '@xterm/headless' +import { readProcessCwd } from '@/main/terminal/process-cwd' +import { + buildShellLaunch, + createNonce, + detectShell, + ShellIntegrationParser, +} from '@/main/terminal/shell-integration' + +const logger = createLogger('DesktopTerminalSession') + +/** + * Output is batched rather than forwarded per chunk. A command like `yes` or + * `cat` on a large file emits far faster than the renderer can paint, and one + * IPC message per chunk locks up the UI process. + */ +const FLUSH_INTERVAL_MS = 8 + +/** + * When this much unflushed output has accumulated, the pty is paused until the + * next flush. Without it a runaway process grows the pending buffer without + * bound between ticks. + */ +const PAUSE_HIGH_WATER_CHARS = 512 * 1024 + +/** + * How far a retained buffer may overshoot its limit before being trimmed. + * + * Trimming to the exact limit means copying the entire buffer on every chunk + * once it is full — a quarter of a megabyte per chunk, hundreds of times a + * second under heavy output, on the process that also feeds the renderer. + * That copying is what makes the panel stutter while something is printing. + * Letting the buffer overshoot and trimming in one go amortizes it to a single + * copy per slack-sized batch. + */ +const TRIM_SLACK_CHARS = 64_000 + +/** Control keys mapped to the bytes a terminal actually sends. */ +const CONTROL_KEY_BYTES: Record = { + 'ctrl-c': '\u0003', + 'ctrl-d': '\u0004', + 'ctrl-z': '\u001a', + enter: '\r', + up: '\u001b[A', + down: '\u001b[B', + right: '\u001b[C', + left: '\u001b[D', + escape: '\u001b', + tab: '\t', +} + +/** Enter, as a keyboard sends it: carriage return, never linefeed. */ +const ENTER = '\r' + +/** + * Splits model-authored text into the separate writes a keyboard would produce. + * + * Enter has to be its own write. A full-screen program reads stdin in chunks + * and treats one chunk as one input event, so "message\r" written in a single + * call is read as text that happens to end in a carriage return: it lands in + * the composer and is never submitted, because the program's Enter handler + * only fires for a chunk that *is* Enter. Returning the breaks as their own + * chunks lets the caller space them out in time, which is what forces the + * program to see two events instead of one. + */ +export function toInputChunks(text: string): string[] { + const chunks: string[] = [] + const lines = text.replace(/\r\n|\r/g, '\n').split('\n') + lines.forEach((line, index) => { + if (line) chunks.push(line) + if (index < lines.length - 1) chunks.push(ENTER) + }) + return chunks +} + +/** + * Sequences that make a terminal answer back, removed from replayed history. + * + * A repaint feeds recorded bytes to a live emulator, and xterm cannot tell + * them from a program talking to it now: it dutifully replies to every query + * in the recording, and those replies are written to the pty as if the user + * had typed them. The original asker is long gone, so they land on the shell + * prompt as junk — `1;2c0;276;0c10;rgb:1f1f/...` sitting in front of the + * cursor. Answering history is meaningless, so history is stripped of the + * questions. Live output is untouched: a program waiting on a reply must get + * one. + * + * Covers device attributes (CSI c), device status reports (CSI n), the OSC + * colour queries, and XTVERSION. `CSI > q` is matched with its `>` so cursor + * style (`CSI q`) survives. + */ +const TERMINAL_QUERIES = [ + /\u001b\[[?>=]?[0-9;]*c/g, + /\u001b\[\??[0-9;]*n/g, + /\u001b\][0-9]+;\?(?:\u0007|\u001b\\)/g, + /\u001b\[>[0-9;]*q/g, + /\u001bP\+q[^\u001b]*\u001b\\/g, +] + +export function stripTerminalQueries(value: string): string { + return TERMINAL_QUERIES.reduce((text, pattern) => text.replace(pattern, ''), value) +} + +/** Marks the row a menu has selected, for a reader that cannot see colour. */ +const SELECTED_ROW_PREFIX = '[selected] ' + +/** + * Share of a row's cells that must be painted for it to count as highlighted. + * High enough that a few coloured words in ordinary output do not qualify — + * a selected row is painted end to end. + */ +const PAINTED_ROW_RATIO = 0.6 + +/** + * The row a menu has selected, or null when nothing looks selected. + * + * A TUI marks its current row by painting it — reverse video, or a background + * colour — and plain text throws all of that away, so an agent reading the + * screen cannot tell where it is before it starts pressing arrows. + * + * Painted rows are not always selections, though: a tmux status bar, a vim + * status line and an htop header are all painted end to end. Those live at the + * edges of the screen, so edge rows are set aside when something else is + * painted too. If that still leaves more than one, the cursor breaks the tie, + * and failing that nothing is marked — a missing label is recoverable, a label + * on the wrong row sends the agent somewhere it did not intend to go. + */ +export function findSelectedRow(buffer: IBuffer): number | null { + const painted: number[] = [] + const cell = buffer.getNullCell() + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row) + if (line && isPaintedRow(line, cell)) painted.push(row) + } + if (painted.length === 0) return null + if (painted.length === 1) return painted[0] + + const top = buffer.baseY + const bottom = buffer.baseY + buffer.viewportY + buffer.length - 1 + const inner = painted.filter((row) => row !== top && row !== bottom && row !== buffer.length - 1) + if (inner.length === 1) return inner[0] + + const cursorRow = buffer.baseY + buffer.cursorY + const candidates = inner.length > 0 ? inner : painted + return candidates.includes(cursorRow) ? cursorRow : null +} + +/** Whether a row is drawn highlighted: reverse video, or a filled background. */ +function isPaintedRow(line: IBufferLine, cell: IBufferCell): boolean { + let painted = 0 + for (let column = 0; column < line.length; column++) { + line.getCell(column, cell) + if (cell.isInverse() || !cell.isBgDefault()) painted++ + } + return line.length > 0 && painted / line.length >= PAINTED_ROW_RATIO +} + +/** Strips CSI/OSC sequences so the model reads text rather than escape codes. */ +export function stripAnsi(value: string): string { + return value + .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '') + .replace(/\u001b[[\]][0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\u001b[@-Z\\-_]/g, '') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '') +} + +/** + * Keeps the head and tail of oversized output. A long build log's useful parts + * are the start and the failure at the end; the middle is filler. + */ +export function elide(value: string, limit: number): { text: string; truncated: boolean } { + if (value.length <= limit) return { text: value, truncated: false } + const half = Math.floor(limit / 2) + const omitted = value.length - half * 2 + return { + text: `${value.slice(0, half)}\n\n[... ${omitted} characters omitted ...]\n\n${value.slice(-half)}`, + truncated: true, + } +} + +/** + * A program that switches to the alternate screen buffer has taken the whole + * terminal: an editor, a pager, or a coding agent. Its output is a stream of + * repaints rather than text, and it will not exit on its own. + */ +const ALT_SCREEN_ENTER = '\u001b[?1049h' +/** Restoring the normal screen: the full-screen program has quit. */ +const ALT_SCREEN_EXIT = '\u001b[?1049l' + +/** + * Scrollback the on-demand emulator keeps while rendering a read. + * + * Only ever needs to cover what a read returns — a couple of hundred lines by + * default — so this is generous rather than the 5,000 the old always-on + * emulator held per terminal, which at ~12 bytes a cell was megabytes of + * buffer per shell for rows nothing ever asked for. + */ +const EMULATOR_SCROLLBACK_LINES = 1_000 + +/** How often a running command is checked for having stopped mid-line. */ +const PROMPT_POLL_INTERVAL_MS = 500 + +/** + * Gap held between the writes of one typed message. Long enough that the + * program gets a separate stdin read per chunk rather than coalescing them + * into a single paste-like event, which is the whole point of splitting them. + */ +const KEYSTROKE_GAP_MS = 150 + +/** + * Cap on waiting for a program to finish redrawing after each chunk. A TUI + * with an animating spinner never goes quiet, so the settle wait needs a + * ceiling or typing would stall on it. + */ +const KEYSTROKE_SETTLE_MAX_MS = 1_000 + +interface PendingCommand { + command: string + toolCallId: string + startedAt: number + /** Last time the command produced output; the basis for prompt detection. */ + lastActivityAt: number + promptWatchdog: NodeJS.Timeout + /** Capped head of the captured output. */ + output: string + /** Rolling tail kept once {@link MAX_CAPTURE_CHARS} is exceeded. */ + overflow: string + capturing: boolean + timer: NodeJS.Timeout + resolve(result: TerminalRunResult): void +} + +export interface TerminalSessionCallbacks { + onData(terminalId: string, data: string): void + onState(): void + onCommand(event: TerminalCommandEvent): void + /** + * The shell ended by itself — the user ran `exit`, or pressed Ctrl-D at an + * empty prompt. Distinct from the service disposing the session, which is + * already-known and never reaches here. + */ + onExit(terminalId: string): void +} + +export interface TerminalSessionOptions { + terminalId: string + cwd: string + cols: number + rows: number + callbacks: TerminalSessionCallbacks +} + +export class TerminalSession { + private readonly pty: IPty + /** The environment the shell was spawned with, for tmux to reuse. */ + private readonly shellEnv: NodeJS.ProcessEnv + private readonly parser: ShellIntegrationParser + private readonly integrationDir: string + private readonly callbacks: TerminalSessionCallbacks + private readonly shellName: string + readonly terminalId: string + + /** + * Raw bytes as the shell produced them, ANSI intact. + * + * Kept rather than a rendered screen because rendering needs an emulator, + * and these replay into one on demand — for a panel repainting, or for + * answering a read. Handing the model this stream directly would not do: + * a full-screen program redraws constantly, so stripping escape codes from + * it leaves dozens of overlapping copies of the same screen rather than what + * is actually on it. + */ + private scrollback = '' + private pendingOutput = '' + /** When the program last painted anything; the basis for the settle wait. */ + private lastOutputAt = 0 + private flushTimer: NodeJS.Timeout | null = null + private paused = false + private disposed = false + + private cwd: string + private columns: number + private lines: number + private shellIntegration = false + private altScreen = false + private foregroundCommand: string | null = null + private pendingCommand: PendingCommand | null = null + /** Command line reported by the shell but not yet bracketed by output-start. */ + private announcedCommand: string | null = null + private integrationWaiters: Array<() => void> = [] + + private constructor( + options: TerminalSessionOptions, + pty: IPty, + integrationDir: string, + nonce: string, + shellName: string, + shellEnv: NodeJS.ProcessEnv + ) { + this.callbacks = options.callbacks + this.terminalId = options.terminalId + this.cwd = options.cwd + this.columns = options.cols + this.lines = options.rows + this.pty = pty + this.shellEnv = shellEnv + this.integrationDir = integrationDir + this.shellName = shellName + this.parser = new ShellIntegrationParser(nonce) + + this.pty.onData((chunk) => this.handleData(chunk)) + this.pty.onExit(() => this.handleExit()) + } + + static create(options: TerminalSessionOptions): TerminalSession { + const shellPath = process.env.SHELL || '/bin/zsh' + const shell = detectShell(shellPath) + const nonce = createNonce() + const integrationDir = mkdtempSync(join(tmpdir(), 'sim-terminal-')) + + // ELECTRON_RUN_AS_NODE is stripped by omission rather than assignment: the + // child environment is passed through verbatim, so leaving the key present + // with an undefined value hands the shell the literal string "undefined" + // and it boots as Node instead of a shell. + const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env as Record + + // A shell we cannot instrument still gives the user a working terminal; + // the agent is refused separately via NO_SHELL_INTEGRATION. + const launch = shell + ? buildShellLaunch(shell, integrationDir, nonce, env) + : { args: ['-l'], env: {} } + + const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } + const pty = spawn(shellPath, launch.args, { + name: 'xterm-256color', + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: shellEnv, + }) + + logger.info('Started terminal session', { shell: shellPath, instrumented: shell !== null }) + return new TerminalSession(options, pty, integrationDir, nonce, shell ?? shellPath, shellEnv) + } + + /** + * The shell's process id. tmux clients launched from this shell descend + * from it, which is how a terminal is matched to its tmux session. + */ + get pid(): number { + return this.pty.pid + } + + /** + * Reconciles the tracked cwd with the shell's real one, read from the OS. + * + * The shell-integration hooks report `cd` instantly when they are installed, + * but they cannot be relied on alone: a shell we cannot instrument, a config + * that overrides the prompt hooks, or a session whose hooks never loaded + * would otherwise leave the tab labelled with the directory the shell + * started in — the user's home, whose basename is their username. Asking the + * OS needs no cooperation from the shell, so it is what makes the label + * correct in every case. + */ + async refreshCwd(): Promise { + if (this.disposed) return + const cwd = await readProcessCwd(this.pty.pid) + if (this.disposed || !cwd || cwd === this.cwd) return + this.cwd = cwd + this.emitState() + } + + /** + * The shell's environment. tmux invocations made on this terminal's behalf + * reuse it so they reach the same server socket the user's client is on. + */ + get env(): NodeJS.ProcessEnv { + return this.shellEnv + } + + get alive(): boolean { + return !this.disposed + } + + get cols(): number { + return this.columns + } + + get rows(): number { + return this.lines + } + + get currentCwd(): string | null { + return this.cwd + } + + get shell(): string | null { + return this.shellName + } + + get foreground(): string | null { + return this.foregroundCommand + } + + /** + * Tab-strip view of this terminal. The label prefers the running command, + * which is what the user is actually waiting on, and falls back to the + * directory name — the two things that distinguish one terminal from another + * at a glance. + */ + tabState(active: boolean): TerminalTabState { + const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null + return { + terminalId: this.terminalId, + // The directory, always: whether to show the running command instead is + // a presentation choice, and the panel makes it (it holds a label back + // until a command has run long enough to be worth naming). Reporting the + // command here would also mean gating `running` to match, and that is + // data the agent reads — it must stay true the instant a command starts. + title: directory || 'Terminal', + cwd: this.cwd, + // Never an empty string. A shell can start a command without announcing + // its text, and "" would read as "nothing is running" to everything + // downstream while the terminal is in fact busy — the agent would treat + // it as free and the tab would render a blank label. + running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', + interactive: this.altScreen, + active, + } + } + + get isBusy(): boolean { + return this.foregroundCommand !== null + } + + get hasShellIntegration(): boolean { + return this.shellIntegration + } + + /** + * Resolves once the shell has emitted its first integration marker, or when + * `timeoutMs` elapses. A shell takes a few hundred milliseconds to run its + * startup files, so a command issued immediately after spawn would otherwise + * be refused for having no integration when it is merely early. + */ + waitForShellIntegration(timeoutMs: number): Promise { + if (this.shellIntegration) return Promise.resolve(true) + if (this.disposed) return Promise.resolve(false) + return new Promise((resolve) => { + const notify = () => { + clearTimeout(timer) + resolve(this.shellIntegration) + } + const timer = setTimeout(() => { + this.integrationWaiters = this.integrationWaiters.filter((entry) => entry !== notify) + resolve(this.shellIntegration) + }, timeoutMs) + this.integrationWaiters.push(notify) + }) + } + + write(data: string): void { + if (this.disposed) return + this.pty.write(data) + } + + sendKey(key: TerminalControlKey): void { + this.write(CONTROL_KEY_BYTES[key]) + } + + /** + * Presses keys in order, spaced the way typed text is. + * + * Same reasoning as {@link type}: a program reads one stdin chunk as one + * event, so three arrows written together can arrive as a single keystroke. + * The pause also lets a menu redraw between presses, which is what makes a + * batch land on the row a person pressing the same keys would reach. + */ + async pressKeys(keys: TerminalControlKey[]): Promise { + for (let index = 0; index < keys.length; index += 1) { + if (this.disposed) return + if (index > 0) await this.settleBetweenKeystrokes() + this.sendKey(keys[index]) + } + } + + /** + * Types text the way a person would: each line and each Enter as its own + * write, spaced out so the program reads them as separate keystrokes and + * gets a chance to redraw between them. See {@link toInputChunks} for why + * sending it all at once leaves the text unsubmitted. + */ + async type(text: string): Promise { + const chunks = toInputChunks(text) + for (let index = 0; index < chunks.length; index += 1) { + if (this.disposed) return + if (index > 0) await this.settleBetweenKeystrokes() + this.write(chunks[index]) + } + } + + /** Holds a gap, then lets any resulting redraw finish before the next write. */ + private async settleBetweenKeystrokes(): Promise { + await sleep(KEYSTROKE_GAP_MS) + const deadline = Date.now() + KEYSTROKE_SETTLE_MAX_MS + while (!this.disposed) { + const quietFor = Date.now() - this.lastOutputAt + const remaining = Math.min(KEYSTROKE_GAP_MS - quietFor, deadline - Date.now()) + if (remaining <= 0) return + await sleep(remaining) + } + } + + resize(cols: number, rows: number): void { + if (this.disposed || cols <= 0 || rows <= 0) return + this.columns = cols + this.lines = rows + try { + this.pty.resize(cols, rows) + } catch (error) { + logger.warn('Failed to resize pty', { error: (error as Error).message }) + } + this.emitState() + } + + kill(signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void { + if (this.disposed) return + // SIGINT is delivered as a keystroke so the foreground process group gets + // it the way Ctrl-C would, rather than only the shell. + if (signal === 'SIGINT') { + this.write('\u0003') + return + } + try { + this.pty.kill(signal) + } catch (error) { + logger.warn('Failed to signal pty', { signal, error: (error as Error).message }) + } + } + + /** + * Writes a command into the shell so it echoes and streams exactly as if the + * user had typed it, then waits for it to finish. + * + * The wait is deliberately short. Anything still going when it elapses comes + * back as `running` with the output so far, rather than blocking the turn: + * the agent polls it from there, which keeps the user seeing progress and + * lets the agent react to what appears. + */ + runCommand(command: string, toolCallId: string, waitMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => this.resolveStillRunning(false), waitMs) + const promptWatchdog = setInterval(() => this.checkForPrompt(), PROMPT_POLL_INTERVAL_MS) + this.pendingCommand = { + command, + toolCallId, + startedAt: Date.now(), + lastActivityAt: Date.now(), + promptWatchdog, + output: '', + overflow: '', + capturing: false, + timer, + resolve, + } + this.foregroundCommand = command + this.emitState() + this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId }) + + // Ctrl-U clears anything half-typed at the prompt so the agent's command + // is not appended to a partial line. Safe because a command only starts + // when nothing holds the foreground. + this.write('\u0015') + this.write(`${command}\r`) + }) + } + + /** + * Raw scrollback for repainting a freshly mounted xterm, with the pending + * batch consumed rather than flushed: those bytes are already part of the + * scrollback, so delivering them again after the repaint would duplicate + * them on screen. + */ + takeReplaySnapshot(): string { + this.pendingOutput = '' + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + return stripTerminalQueries(this.scrollback) + } + + /** + * Renders the terminal's screen, building an emulator on demand. + * + * The bytes are already retained raw, so the screen can be reconstructed by + * replaying them — the same thing the panel does when a view repaints. The + * alternative, keeping a live emulator fed with every byte forever, meant a + * full VT parser per terminal running on the Electron main process for the + * life of the shell, and xterm parses in self-rescheduling 12ms slices + * because it was written for a renderer with one terminal to a thread. With + * several terminals producing output that is most of the event loop that + * every window's IPC also has to get through, which is why unrelated UI went + * sluggish. Parsing on demand moves that cost from always to per read. + */ + async readScrollback(lines: number): Promise { + const emulator = new HeadlessTerminal({ + cols: this.columns, + rows: this.lines, + scrollback: EMULATOR_SCROLLBACK_LINES, + allowProposedApi: true, + }) + try { + // xterm parses asynchronously in slices, so the buffer is only complete + // once the write callback fires. + await new Promise((resolve) => emulator.write(this.scrollback, resolve)) + return this.renderScreen(emulator.buffer.active, lines) + } finally { + emulator.dispose() + } + } + + private renderScreen(buffer: IBuffer, lines: number): TerminalReadResult { + const selected = findSelectedRow(buffer) + // `buffer.active` is the alternate buffer while a full-screen program is + // up and the normal one otherwise, so this reads correctly either way. + const rowAt = (row: number) => buffer.getLine(row)?.translateToString(true) ?? '' + + // The buffer is always a full screen tall, so its bottom rows are blank + // padding below the content. Anchor to the last row with anything on it — + // counting back from the raw bottom would return nothing but blanks. + let lastRow = buffer.length - 1 + while (lastRow >= 0 && rowAt(lastRow).trim() === '') lastRow-- + if (lastRow < 0) { + return { + output: '', + cwd: this.cwd, + terminalId: this.terminalId, + truncated: false, + running: this.foregroundCommand, + } + } + + const wanted = lines > 0 ? lines : lastRow + 1 + const firstRow = Math.max(0, lastRow - wanted + 1) + const rendered: string[] = [] + for (let row = firstRow; row <= lastRow; row++) { + rendered.push(row === selected ? `${SELECTED_ROW_PREFIX}${rowAt(row)}` : rowAt(row)) + } + + const { text, truncated } = elide(rendered.join('\n'), MAX_TOOL_OUTPUT_CHARS) + return { + output: text, + cwd: this.cwd, + terminalId: this.terminalId, + truncated: truncated || firstRow > 0, + running: this.foregroundCommand, + } + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = null + this.finishCommand(null) + try { + this.pty.kill() + } catch { + // Already gone. + } + this.cleanupIntegrationDir() + this.emitState() + } + + /** + * Removes the generated startup files. Never throws: the shell writes into + * this directory as it exits (zsh drops a `.zcompdump` there, since it is + * also ZDOTDIR), which races the delete and raises ENOTEMPTY. Teardown runs + * on app quit, so letting that escape would break shutdown over a temp file + * the OS reclaims anyway. One deferred retry catches the common race. + */ + private cleanupIntegrationDir(retry = true): void { + try { + rmSync(this.integrationDir, { recursive: true, force: true }) + } catch { + if (!retry) return + setTimeout(() => this.cleanupIntegrationDir(false), 2_000).unref() + } + } + + private handleData(chunk: string): void { + const { text, markers } = this.parser.parse(chunk) + + for (const marker of markers) { + this.applyMarker(marker) + } + + if (text) { + this.lastOutputAt = Date.now() + this.trackAltScreen(text) + this.scrollback += text + if (this.scrollback.length > MAX_SCROLLBACK_CHARS + TRIM_SLACK_CHARS) { + this.scrollback = this.scrollback.slice(-MAX_SCROLLBACK_CHARS) + } + if (this.pendingCommand?.capturing) { + this.pendingCommand.lastActivityAt = Date.now() + this.captureOutput(this.pendingCommand, text) + if (text.includes(ALT_SCREEN_ENTER)) { + this.resolveInteractiveCommand() + } + } + this.pendingOutput += text + this.scheduleFlush() + } + } + + private applyMarker( + marker: ReturnType['markers'][number] + ): void { + switch (marker.kind) { + case 'prompt-start': + if (!this.shellIntegration) { + this.shellIntegration = true + this.emitState() + const waiters = this.integrationWaiters + this.integrationWaiters = [] + for (const notify of waiters) notify() + } + break + case 'command-line': + this.announcedCommand = marker.command + break + case 'output-start': { + if (this.pendingCommand) { + this.pendingCommand.capturing = true + break + } + // No agent command in flight, so the user typed this one. + const command = this.announcedCommand ?? '' + this.foregroundCommand = command + this.emitState() + this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command }) + break + } + case 'output-end': + this.finishCommand(marker.exitCode) + break + case 'cwd': + if (marker.cwd && marker.cwd !== this.cwd) { + this.cwd = marker.cwd + this.emitState() + } + break + } + } + + /** + * Follows the alternate-screen switches so the tab can tell an open + * application from a command that is merely slow. Entering is what makes a + * program full-screen; leaving means it has quit and given the shell back. + */ + private trackAltScreen(text: string): void { + const entered = text.lastIndexOf(ALT_SCREEN_ENTER) + const exited = text.lastIndexOf(ALT_SCREEN_EXIT) + if (entered === -1 && exited === -1) return + const next = entered > exited + if (next === this.altScreen) return + this.altScreen = next + this.emitState() + } + + /** + * Buffers captured output with a fixed ceiling: the head is kept intact and + * everything past the cap collapses into a rolling tail, so a program + * repainting the screen thousands of times cannot exhaust memory. + */ + private captureOutput(pending: PendingCommand, text: string): void { + if (pending.output.length < MAX_CAPTURE_CHARS) { + pending.output += text + return + } + pending.overflow += text + if (pending.overflow.length > MAX_CAPTURE_CHARS + TRIM_SLACK_CHARS) { + pending.overflow = pending.overflow.slice(-MAX_CAPTURE_CHARS) + } + } + + /** + * Answers a command that has taken over the screen, without waiting for it to + * finish — it will not. The foreground stays held because the program really + * is still running: the user can drive it in the panel, and the agent can + * stop it with terminal_kill. The captured redraws are discarded rather than + * returned, since they are frames rather than output. + */ + private resolveInteractiveCommand(): void { + this.detachStillRunning((pending) => ({ + command: pending.command, + output: + 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', + status: 'interactive', + exitCode: null, + durationMs: Date.now() - pending.startedAt, + cwd: this.cwd, + terminalId: this.terminalId, + truncated: false, + })) + } + + /** + * Hands back a command that is still going when the wait window elapses, + * with whatever it has printed so far. Not a failure: the agent polls from + * here with wait + terminal_read, which keeps the user seeing progress and + * lets the agent notice a prompt or an error as it appears. + */ + /** + * Hands a command back early when it has stopped mid-line and gone quiet — + * the shape of something sitting on a prompt. Output that ends with a + * newline, or that is still arriving, is a command doing work and is left to + * run out the full wait window. + */ + private checkForPrompt(): void { + const pending = this.pendingCommand + if (!pending?.capturing) return + if (Date.now() - pending.lastActivityAt < PROMPT_IDLE_MS) return + const captured = this.capturedText(pending) + if (!captured.trim() || /[\r\n]$/.test(captured)) return + this.resolveStillRunning(true) + } + + private resolveStillRunning(awaitingInput: boolean): void { + this.detachStillRunning((pending) => { + const { text, truncated } = elide( + stripAnsi(this.capturedText(pending)).trim(), + MAX_TOOL_OUTPUT_CHARS + ) + return { + command: pending.command, + output: text, + status: 'running', + exitCode: null, + durationMs: Date.now() - pending.startedAt, + cwd: this.cwd, + terminalId: this.terminalId, + truncated, + ...(awaitingInput ? { awaitingInput: true } : {}), + } + }) + } + + /** + * Resolves the pending promise while LEAVING the foreground held. In both + * non-completion cases the command really is still running, so releasing the + * slot would let the next terminal_run interleave with it instead of + * correctly reporting BUSY. + */ + private detachStillRunning(build: (pending: PendingCommand) => TerminalRunResult): void { + const pending = this.pendingCommand + if (!pending) return + clearTimeout(pending.timer) + clearInterval(pending.promptWatchdog) + this.pendingCommand = null + pending.resolve(build(pending)) + } + + private capturedText(pending: PendingCommand): string { + return pending.overflow + ? `${pending.output}\n\n[... output truncated ...]\n\n${pending.overflow}` + : pending.output + } + + private finishCommand(exitCode: number | null): void { + const pending = this.pendingCommand + const command = this.foregroundCommand + + if (pending) { + clearTimeout(pending.timer) + clearInterval(pending.promptWatchdog) + this.pendingCommand = null + const { text, truncated } = elide( + stripAnsi(this.capturedText(pending)).trim(), + MAX_TOOL_OUTPUT_CHARS + ) + const durationMs = Date.now() - pending.startedAt + pending.resolve({ + command: pending.command, + output: text, + status: 'completed', + exitCode, + durationMs, + cwd: this.cwd, + terminalId: this.terminalId, + truncated, + }) + this.callbacks.onCommand({ + terminalId: this.terminalId, + phase: 'end', + command: pending.command, + toolCallId: pending.toolCallId, + ...(exitCode === null ? {} : { exitCode }), + durationMs, + }) + } else if (command !== null) { + this.callbacks.onCommand({ + terminalId: this.terminalId, + phase: 'end', + command, + ...(exitCode === null ? {} : { exitCode }), + }) + } + + this.foregroundCommand = null + this.announcedCommand = null + this.altScreen = false + this.emitState() + } + + private scheduleFlush(): void { + if (this.pendingOutput.length >= PAUSE_HIGH_WATER_CHARS && !this.paused) { + this.paused = true + this.pty.pause() + } + if (this.flushTimer) return + this.flushTimer = setTimeout(() => { + this.flushTimer = null + this.flush() + }, FLUSH_INTERVAL_MS) + } + + private flush(): void { + if (!this.pendingOutput) return + const data = this.pendingOutput + this.pendingOutput = '' + this.callbacks.onData(this.terminalId, data) + if (this.paused) { + this.paused = false + this.pty.resume() + } + } + + private handleExit(): void { + if (this.disposed) return + this.disposed = true + const waiters = this.integrationWaiters + this.integrationWaiters = [] + for (const notify of waiters) notify() + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = null + this.flush() + this.finishCommand(null) + this.cleanupIntegrationDir() + this.emitState() + this.callbacks.onExit(this.terminalId) + } + + private emitState(): void { + this.callbacks.onState() + } +} diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts new file mode 100644 index 00000000000..ab47bf5a8ab --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { detectShell, ShellIntegrationParser } from '@/main/terminal/shell-integration' + +const NONCE = 'testnonce' + +function osc(body: string): string { + return `\u001b]633;${body}\u0007` +} + +describe('ShellIntegrationParser', () => { + it('extracts command lifecycle markers and strips them from the display stream', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text, markers } = parser.parse( + `${osc(`E;npm test;${NONCE}`)}${osc(`C;${NONCE}`)}output here${osc(`D;0;${NONCE}`)}` + ) + + expect(text).toBe('output here') + expect(markers).toEqual([ + { kind: 'command-line', command: 'npm test' }, + { kind: 'output-start' }, + { kind: 'output-end', exitCode: 0 }, + ]) + }) + + it('ignores markers carrying the wrong nonce', () => { + const parser = new ShellIntegrationParser(NONCE) + // A file rendered with `cat` can contain a literal finish sequence. Acting + // on it would hand the agent a fabricated exit code, so it must be inert. + const { markers } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) + + expect(markers).toEqual([]) + }) + + it('still removes forged sequences from the display stream', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) + + expect(text).toBe('BEFOREAFTER') + }) + + it('reassembles sequences split across chunks', () => { + const parser = new ShellIntegrationParser(NONCE) + const full = `hello${osc(`D;7;${NONCE}`)}world` + const split = full.length - 8 + + const first = parser.parse(full.slice(0, split)) + const second = parser.parse(full.slice(split)) + + expect(first.markers).toEqual([]) + expect(second.markers).toEqual([{ kind: 'output-end', exitCode: 7 }]) + expect(first.text + second.text).toBe('helloworld') + }) + + it('unescapes semicolons and newlines in command lines', () => { + const parser = new ShellIntegrationParser(NONCE) + const { markers } = parser.parse(osc(`E;echo a\\x3bb\\x0ac;${NONCE}`)) + + expect(markers).toEqual([{ kind: 'command-line', command: 'echo a;b\nc' }]) + }) + + it('tracks the working directory', () => { + const parser = new ShellIntegrationParser(NONCE) + const { markers } = parser.parse(osc(`P;Cwd=/tmp/some dir;${NONCE}`)) + + expect(markers).toEqual([{ kind: 'cwd', cwd: '/tmp/some dir' }]) + }) + + it('accepts ST as well as BEL as a terminator', () => { + const parser = new ShellIntegrationParser(NONCE) + const { text, markers } = parser.parse(`a\u001b]633;A;${NONCE}\u001b\\b`) + + expect(text).toBe('ab') + expect(markers).toEqual([{ kind: 'prompt-start' }]) + }) + + it('releases an unterminated sequence rather than buffering without bound', () => { + const parser = new ShellIntegrationParser(NONCE) + const runaway = `\u001b]633;${'x'.repeat(9000)}` + const { text } = parser.parse(runaway) + + expect(text).toBe(runaway) + }) +}) + +describe('detectShell', () => { + it('recognises the shells we can instrument', () => { + expect(detectShell('/bin/zsh')).toBe('zsh') + expect(detectShell('/usr/local/bin/bash')).toBe('bash') + }) + + it('returns null for shells without hooks, leaving the terminal uninstrumented', () => { + expect(detectShell('/usr/bin/fish')).toBeNull() + expect(detectShell('/bin/sh')).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts new file mode 100644 index 00000000000..f69e86e1d2d --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -0,0 +1,297 @@ +/** + * Shell integration: the mechanism that turns an opaque byte stream into + * structured commands. + * + * Writing `npm test\r` into a PTY tells you nothing about where that command's + * output begins, when it ends, or what it exited with. The fix, pioneered by + * FinalTerm and standardised in practice by VS Code, is to have the shell + * itself announce those boundaries with OSC escape sequences emitted from its + * prompt hooks. We speak VS Code's `OSC 633` grammar because it is the + * best-tested variant and its semantics are documented. + * + * Every sequence carries a per-session nonce. This is a security requirement, + * not decoration: the terminal renders untrusted bytes, so `cat` of a file + * containing a literal `\e]633;D;0\a` would otherwise let arbitrary file + * content forge "the command finished successfully" and feed the agent a + * fabricated exit code. Markers whose nonce does not match are ignored. + */ +import { randomBytes } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' + +/** Shells we can install prompt hooks into. */ +export type SupportedShell = 'zsh' | 'bash' + +export function detectShell(shellPath: string): SupportedShell | null { + const name = basename(shellPath) + if (name === 'zsh' || name === '-zsh') return 'zsh' + if (name === 'bash' || name === '-bash') return 'bash' + return null +} + +export function createNonce(): string { + return randomBytes(16).toString('hex') +} + +/** + * Marker kinds we act on. `A` (prompt start) doubles as the "integration is + * live" signal; `C`/`D` bracket a command's output; `E` reports the exact + * command line; `P` tracks the working directory across `cd`. + */ +export type ShellMarker = + | { kind: 'prompt-start' } + | { kind: 'command-line'; command: string } + | { kind: 'output-start' } + | { kind: 'output-end'; exitCode: number } + | { kind: 'cwd'; cwd: string } + +export interface ParseResult { + /** Stream with the OSC 633 sequences removed, safe to hand to xterm.js. */ + text: string + markers: ShellMarker[] +} + +/** Undoes the escaping applied by the shell hooks. */ +function unescapeValue(value: string): string { + return value.replace(/\\x([0-9a-fA-F]{2})/g, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ) +} + +/** + * Incremental parser. PTY chunks split escape sequences at arbitrary byte + * offsets, so a partial trailing sequence is held back until the rest arrives + * rather than being emitted as garbage or mis-parsed. + */ +export class ShellIntegrationParser { + private pending = '' + + constructor(private readonly nonce: string) {} + + /** + * Cap on a held-back partial sequence. A stream containing a bare `\e]` that + * never terminates would otherwise grow `pending` without bound; past this + * length we accept that it was not a marker and release it. + */ + private static readonly MAX_PENDING = 8192 + + parse(chunk: string): ParseResult { + const buffer = this.pending + chunk + this.pending = '' + + const markers: ShellMarker[] = [] + let text = '' + let index = 0 + + while (index < buffer.length) { + const start = buffer.indexOf('\u001b]633;', index) + if (start === -1) { + text += buffer.slice(index) + break + } + text += buffer.slice(index, start) + + const terminator = findTerminator(buffer, start) + if (terminator === null) { + // Incomplete sequence: hold it for the next chunk unless it has grown + // implausibly long, in which case treat it as ordinary text. + const tail = buffer.slice(start) + if (tail.length > ShellIntegrationParser.MAX_PENDING) { + text += tail + } else { + this.pending = tail + } + break + } + + const body = buffer.slice(start + '\u001b]633;'.length, terminator.index) + const marker = this.toMarker(body) + if (marker) markers.push(marker) + index = terminator.index + terminator.length + } + + return { text, markers } + } + + private toMarker(body: string): ShellMarker | null { + const parts = body.split(';') + const kind = parts[0] + + // The nonce is always last. Without a match the sequence did not come from + // our prompt hooks, so it is untrusted output that must not be acted on. + const nonce = parts[parts.length - 1] + if (nonce !== this.nonce) return null + + switch (kind) { + case 'A': + return { kind: 'prompt-start' } + case 'C': + return { kind: 'output-start' } + case 'D': { + const exitCode = Number.parseInt(parts[1] ?? '', 10) + return { kind: 'output-end', exitCode: Number.isFinite(exitCode) ? exitCode : 0 } + } + case 'E': + return { kind: 'command-line', command: unescapeValue(parts.slice(1, -1).join(';')) } + case 'P': { + const value = parts.slice(1, -1).join(';') + if (!value.startsWith('Cwd=')) return null + return { kind: 'cwd', cwd: unescapeValue(value.slice('Cwd='.length)) } + } + default: + return null + } + } +} + +/** OSC sequences end with BEL or ST; both appear in the wild. */ +function findTerminator(buffer: string, from: number): { index: number; length: number } | null { + const bel = buffer.indexOf('\u0007', from) + const st = buffer.indexOf('\u001b\\', from) + if (bel !== -1 && (st === -1 || bel < st)) return { index: bel, length: 1 } + if (st !== -1) return { index: st, length: 2 } + return null +} + +/** + * zsh reads all of its startup files from `ZDOTDIR`, so pointing that at a + * generated directory is the only hook that works for login *and* interactive + * shells. Each generated file sources the user's real one first, so their + * prompt, aliases, and PATH win over ours. + */ +function writeZshFiles(dir: string, nonce: string, originalZdotdir: string): void { + const sourceOriginal = (file: string) => + `[ -f "$SIM_ZDOTDIR_ORIG/${file}" ] && builtin source "$SIM_ZDOTDIR_ORIG/${file}"` + + writeFileSync( + join(dir, '.zshenv'), + `SIM_ZDOTDIR_ORIG="\${SIM_ZDOTDIR_ORIG:-${originalZdotdir}}"\n${sourceOriginal('.zshenv')}\n` + ) + writeFileSync(join(dir, '.zprofile'), `${sourceOriginal('.zprofile')}\n`) + writeFileSync(join(dir, '.zlogin'), `${sourceOriginal('.zlogin')}\n`) + + writeFileSync( + join(dir, '.zshrc'), + `${sourceOriginal('.zshrc')} + +# Restore ZDOTDIR so anything the user's config spawns behaves normally. +ZDOTDIR="$SIM_ZDOTDIR_ORIG" + +__sim_nonce='${nonce}' +__sim_in_cmd='' + +__sim_esc() { + local s=\${1//\\\\/\\\\\\\\} + s=\${s//;/\\\\x3b} + s=\${s//$'\\n'/\\\\x0a} + builtin printf '%s' "$s" +} + +__sim_preexec() { + __sim_in_cmd=1 + builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$1")" "$__sim_nonce" + builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" +} + +__sim_precmd() { + local st=$? + # Cwd is reported before the finish marker so a \`cd\` is already visible by + # the time the command's result is resolved. + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + if [ -n "$__sim_in_cmd" ]; then + builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" + fi + __sim_in_cmd='' + builtin printf '\\e]633;A;%s\\a' "$__sim_nonce" +} + +# zsh appends this marker when output does not end in a newline. It is display +# noise that would otherwise be captured as part of a command's output. +PROMPT_EOL_MARK='' + +autoload -Uz add-zsh-hook +add-zsh-hook preexec __sim_preexec +add-zsh-hook precmd __sim_precmd +` + ) +} + +/** + * bash has no preexec hook, so command start is detected with a DEBUG trap and + * command end from PROMPT_COMMAND. The trap fires once per command in a + * pipeline, hence the in-command latch. + */ +function writeBashFile(dir: string, nonce: string): string { + const rcPath = join(dir, 'sim-bash-rc.sh') + writeFileSync( + rcPath, + `[ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc" + +__sim_nonce='${nonce}' +__sim_in_cmd='' + +__sim_esc() { + local s=\${1//\\\\/\\\\\\\\} + s=\${s//;/\\\\x3b} + s=\${s//$'\\n'/\\\\x0a} + builtin printf '%s' "$s" +} + +__sim_preexec() { + case "$BASH_COMMAND" in __sim_*) return ;; esac + [ -n "$__sim_in_cmd" ] && return + __sim_in_cmd=1 + builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$BASH_COMMAND")" "$__sim_nonce" + builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" +} + +__sim_precmd() { + local st=$? + # Cwd is reported before the finish marker so a \`cd\` is already visible by + # the time the command's result is resolved. + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + if [ -n "$__sim_in_cmd" ]; then + builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" + fi + __sim_in_cmd='' + builtin printf '\\e]633;A;%s\\a' "$__sim_nonce" + return $st +} + +trap '__sim_preexec' DEBUG +PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" +` + ) + return rcPath +} + +export interface ShellLaunch { + args: string[] + env: Record +} + +/** + * Generates the startup files for `shell` inside `dir` and returns the + * arguments and environment overrides needed to make it load them. + */ +export function buildShellLaunch( + shell: SupportedShell, + dir: string, + nonce: string, + env: Record +): ShellLaunch { + mkdirSync(dir, { recursive: true }) + + if (shell === 'zsh') { + writeZshFiles(dir, nonce, env.ZDOTDIR || env.HOME || '') + return { + args: ['-l'], + env: { ZDOTDIR: dir, SIM_ZDOTDIR_ORIG: env.ZDOTDIR || env.HOME || '' }, + } + } + + const rcPath = writeBashFile(dir, nonce) + // `--init-file` is honoured only by interactive non-login bash, so the login + // flag is deliberately omitted here; the generated file sources ~/.bashrc. + return { args: ['--init-file', rcPath, '-i'], env: {} } +} diff --git a/apps/desktop/src/main/terminal/tmux.test.ts b/apps/desktop/src/main/terminal/tmux.test.ts new file mode 100644 index 00000000000..b89c6cc7eb8 --- /dev/null +++ b/apps/desktop/src/main/terminal/tmux.test.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + awaitRun, + isDescendantOf, + parseFormatLines, + parseProcessParents, + pollRun, + type TmuxRunHandle, +} from '@/main/terminal/tmux' + +/** The separator the format strings use; no tmux field can contain it. */ +const F = '\u001f' + +describe('parseFormatLines', () => { + it('splits a line into its fields', () => { + expect(parseFormatLines(`12345${F}/dev/ttys004${F}main\n`, 3)).toEqual([ + ['12345', '/dev/ttys004', 'main'], + ]) + }) + + it('keeps fields that contain spaces intact', () => { + // Splitting on whitespace would shift every later field for a window named + // "my project" or a path under "Application Support". + const line = `main:1.0${F}my project${F}zsh${F}/Users/me/Application Support${F}1` + expect(parseFormatLines(line, 5)).toEqual([ + ['main:1.0', 'my project', 'zsh', '/Users/me/Application Support', '1'], + ]) + }) + + it('ignores blank lines and trailing newlines', () => { + expect(parseFormatLines(`a${F}b\n\n\n`, 2)).toEqual([['a', 'b']]) + }) + + it('drops lines with the wrong field count rather than mis-assigning them', () => { + expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']]) + }) + + it('returns nothing for empty output', () => { + expect(parseFormatLines('', 3)).toEqual([]) + }) +}) + +describe('parseProcessParents', () => { + it('reads pid and parent pid pairs', () => { + const parents = parseProcessParents(' 501 1\n 777 501\n') + expect(parents.get(501)).toBe(1) + expect(parents.get(777)).toBe(501) + }) + + it('skips lines that are not two numbers', () => { + expect(parseProcessParents('header row\n 42 7\n').size).toBe(1) + }) +}) + +describe('isDescendantOf', () => { + // A tmux client is usually a direct child of the shell, but an rc file that + // execs through a wrapper can put another process in between. + const parents = new Map([ + [100, 1], + [200, 100], + [300, 200], + [900, 1], + ]) + + it('matches the process itself', () => { + expect(isDescendantOf(100, 100, parents)).toBe(true) + }) + + it('matches a direct child', () => { + expect(isDescendantOf(200, 100, parents)).toBe(true) + }) + + it('matches through an intermediate process', () => { + expect(isDescendantOf(300, 100, parents)).toBe(true) + }) + + it('rejects an unrelated process', () => { + expect(isDescendantOf(900, 100, parents)).toBe(false) + }) + + it('rejects rather than looping when the chain cycles', () => { + const cyclic = new Map([ + [10, 20], + [20, 10], + ]) + expect(isDescendantOf(10, 999, cyclic)).toBe(false) + }) +}) + +describe('run status files', () => { + const dirs: string[] = [] + + const handleIn = (dir: string): TmuxRunHandle => ({ + window: '@1', + outPath: join(dir, 'out'), + statusPath: join(dir, 'status'), + dispose: () => {}, + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'tmux-run-test-')) + dirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + it('reports a command as unfinished until the status file exists', () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'building...\n') + + expect(pollRun(handleIn(dir))).toEqual({ output: 'building...\n', exitCode: null, done: false }) + }) + + it('reads the real exit code once the status file lands', () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'boom\n') + writeFileSync(join(dir, 'status'), '7') + + expect(pollRun(handleIn(dir))).toEqual({ output: 'boom\n', exitCode: 7, done: true }) + }) + + it('treats a command that produced no output as finished, not missing', () => { + const dir = scratch() + writeFileSync(join(dir, 'status'), '0') + + expect(pollRun(handleIn(dir))).toEqual({ output: '', exitCode: 0, done: true }) + }) + + it('reports done with no code rather than NaN when the status is unreadable', () => { + const dir = scratch() + writeFileSync(join(dir, 'status'), 'not-a-number') + + expect(pollRun(handleIn(dir))).toEqual({ output: '', exitCode: null, done: true }) + }) + + it('returns as soon as a command finishes rather than waiting out the window', async () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'done\n') + writeFileSync(join(dir, 'status'), '0') + + const started = Date.now() + const outcome = await awaitRun(handleIn(dir), 5_000) + + expect(outcome.done).toBe(true) + expect(Date.now() - started).toBeLessThan(1_000) + }) + + it('hands back a still-running command when the window elapses', async () => { + const dir = scratch() + writeFileSync(join(dir, 'out'), 'still going\n') + + // The whole point of the status file over `tmux wait-for`: a command that + // outlives the wait leaves a pollable state rather than a blocked waiter. + const outcome = await awaitRun(handleIn(dir), 300) + + expect(outcome).toEqual({ output: 'still going\n', exitCode: null, done: false }) + }) +}) diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts new file mode 100644 index 00000000000..2af584a6d0f --- /dev/null +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -0,0 +1,423 @@ +/** + * tmux support for the agent terminal. + * + * When a user runs tmux in one of Sim's shells, the agent goes blind: tmux is + * itself a terminal emulator, so it parses its children's output and re-renders + * it, and the OSC 633 markers our shell integration relies on never reach us. + * Command boundaries, exit codes, and the working directory all disappear + * behind a full-screen program. + * + * tmux does expose all of it through its own CLI, though, so this module talks + * to the tmux server directly instead of guessing at the screen. Every call is + * a short-lived child process sharing the shell's environment, which is what + * points it at the same socket the user's client is on. + * + * The tab-to-session mapping goes through process ids: node-pty gives us the + * shell's pid, tmux reports each client's pid, and the client is a descendant + * of the shell that launched it. + */ +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import type { TerminalPaneState } from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' + +const logger = createLogger('DesktopTmux') + +/** + * Ceiling on any single tmux invocation. These are local socket round trips + * that normally return in milliseconds; a hang means the server is wedged, and + * blocking a tool call on it forever is worse than reporting failure. + */ +const TMUX_TIMEOUT_MS = 5_000 + +/** How often the status file is checked while a tmux-run command is going. */ +const RUN_POLL_INTERVAL_MS = 250 + +/** Field separator for `-F` output. Chosen because no tmux field contains it. */ +const FIELD = '\u001f' + +export interface TmuxCommandResult { + ok: boolean + stdout: string + stderr: string +} + +export interface TmuxRunOutcome { + output: string + /** Null while the command is still going. */ + exitCode: number | null + done: boolean +} + +/** A shell's tmux attachment, resolved from its pid. */ +export interface TmuxAttachment { + session: string + clientTty: string +} + +/** + * Runs one tmux command. Never throws: a missing binary, a dead server, and a + * bad target all arrive as `ok: false` with tmux's own message, which is more + * useful to the model than an exception. + */ +/** + * Set once a `tmux` spawn fails with ENOENT: the binary is not installed, and + * it will not appear mid-session, so every later call short-circuits instead + * of paying a failed spawn. Most machines running this have no tmux at all, + * and without this every terminal tool call spawned a doomed process. + */ +let tmuxBinaryMissing = false + +export function isTmuxUnavailable(): boolean { + return tmuxBinaryMissing +} + +export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve) => { + if (tmuxBinaryMissing) { + resolve({ ok: false, stdout: '', stderr: 'tmux is not installed' }) + return + } + let child: ReturnType + try { + child = spawn('tmux', args, { env, stdio: ['ignore', 'pipe', 'pipe'] }) + } catch (error) { + resolve({ ok: false, stdout: '', stderr: (error as Error).message }) + return + } + + let stdout = '' + let stderr = '' + let settled = false + const finish = (result: TmuxCommandResult) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + const timer = setTimeout(() => { + child.kill('SIGKILL') + finish({ ok: false, stdout, stderr: 'tmux did not respond' }) + }, TMUX_TIMEOUT_MS) + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') tmuxBinaryMissing = true + finish({ ok: false, stdout, stderr: error.message }) + }) + child.on('close', (code) => { + finish({ ok: code === 0, stdout, stderr }) + }) + }) +} + +/** + * Parses `list-clients`/`list-panes` output into records. + * + * Split on a control character rather than whitespace: window names and + * working directories contain spaces, and a path with a space would otherwise + * shift every later field by one. + */ +export function parseFormatLines(stdout: string, fields: number): string[][] { + return stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .map((line) => line.split(FIELD)) + .filter((parts) => parts.length === fields) +} + +/** + * Builds the pid -> parent pid map used to decide whether a tmux client + * belongs to one of our shells. One `ps` call rather than one per candidate: + * the client is usually a direct child, but a wrapper (`exec tmux` from an rc + * file, a login shell in between) can put it further down. + */ +export function parseProcessParents(psOutput: string): Map { + const parents = new Map() + for (const line of psOutput.split('\n')) { + const [pid, ppid] = line.trim().split(/\s+/) + const childId = Number(pid) + const parentId = Number(ppid) + if (Number.isInteger(childId) && Number.isInteger(parentId)) { + parents.set(childId, parentId) + } + } + return parents +} + +/** + * Whether `pid` is `ancestor` or descends from it. Bounded rather than + * following the chain to init, so a cycle in malformed `ps` output cannot spin. + */ +export function isDescendantOf( + pid: number, + ancestor: number, + parents: Map +): boolean { + let current = pid + for (let hops = 0; hops < 32; hops += 1) { + if (current === ancestor) return true + const parent = parents.get(current) + if (parent === undefined || parent <= 1) return false + current = parent + } + return false +} + +function listProcessParents(): Promise> { + return new Promise((resolve) => { + const child = spawn('ps', ['-Ao', 'pid=,ppid='], { stdio: ['ignore', 'pipe', 'ignore'] }) + let out = '' + child.stdout?.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + child.on('error', () => resolve(new Map())) + child.on('close', () => resolve(parseProcessParents(out))) + }) +} + +/** + * Finds the tmux session attached in the shell with `shellPid`, or null when + * that shell is not running tmux. + * + * Matching on the client's pid rather than its tty because node-pty exposes + * the shell's pid but not the pty's device path, and the client's tty is only + * comparable if we already know ours. + */ +export async function resolveAttachment( + shellPid: number, + env: NodeJS.ProcessEnv +): Promise { + const format = ['#{client_pid}', '#{client_tty}', '#{client_session}'].join(FIELD) + const listed = await runTmux(['list-clients', '-F', format], env) + if (!listed.ok) return null + + const clients = parseFormatLines(listed.stdout, 3) + if (clients.length === 0) return null + + const parents = await listProcessParents() + for (const [clientPid, clientTty, session] of clients) { + const pid = Number(clientPid) + if (!Number.isInteger(pid)) continue + if (isDescendantOf(pid, shellPid, parents)) { + return { session, clientTty } + } + } + return null +} + +/** The active pane of a session, as a target usable by every other call. */ +export async function activePane(session: string, env: NodeJS.ProcessEnv): Promise { + const result = await runTmux( + ['display-message', '-p', '-t', session, '#{session_name}:#{window_index}.#{pane_index}'], + env + ) + const target = result.stdout.trim() + return result.ok && target ? target : null +} + +export async function listPanes( + session: string, + env: NodeJS.ProcessEnv +): Promise { + const format = [ + '#{session_name}:#{window_index}.#{pane_index}', + '#{window_name}', + '#{pane_current_command}', + '#{pane_current_path}', + '#{pane_active}', + ].join(FIELD) + const result = await runTmux(['list-panes', '-s', '-t', session, '-F', format], env) + if (!result.ok) return [] + return parseFormatLines(result.stdout, 5).map( + ([target, windowName, command, cwd, active]): TerminalPaneState => ({ + target, + windowName, + command, + cwd: cwd || null, + active: active === '1', + }) + ) +} + +/** Captures a pane's visible screen plus `lines` of scrollback above it. */ +export async function capturePane( + target: string, + lines: number, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['capture-pane', '-p', '-t', target, '-S', `-${Math.max(0, lines)}`], env) +} + +/** + * Types into a pane. `-l` sends the text literally, so a command containing + * something like `C-c` is typed rather than interpreted as a key. + */ +export async function sendText( + target: string, + text: string, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['send-keys', '-t', target, '-l', '--', text], env) +} + +/** Presses a key in a pane, using tmux's key names (`Enter`, `C-c`, `Up`). */ +export async function sendKey( + target: string, + key: string, + env: NodeJS.ProcessEnv +): Promise { + return runTmux(['send-keys', '-t', target, key], env) +} + +/** Control keys as tmux names them, for `input` against a pane. */ +export const TMUX_KEY_NAMES: Record = { + 'ctrl-c': 'C-c', + 'ctrl-d': 'C-d', + 'ctrl-z': 'C-z', + enter: 'Enter', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', + escape: 'Escape', + tab: 'Tab', +} + +/** + * A command running in its own tmux window, with its output and exit status + * landing in files rather than being scraped off the screen. + */ +export interface TmuxRunHandle { + window: string + outPath: string + statusPath: string + dispose(): void +} + +/** + * Starts a command in a dedicated tmux window. + * + * The window is the user's to watch — this is their tmux session, so work Sim + * does should be visible in it rather than hidden. Output is teed so it both + * scrolls on screen and lands in a file, and the exit status is written + * separately once the pipeline finishes. + * + * Deliberately not built on `tmux wait-for`: that is a rendezvous rather than + * a latch, so a command that finishes before the waiter starts leaves the wait + * blocked forever. A file appearing has no such race. + */ +export async function startRun( + session: string, + command: string, + cwd: string | null, + env: NodeJS.ProcessEnv +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-run-')) + const outPath = join(dir, 'out') + const statusPath = join(dir, 'status') + const dispose = () => { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Temp dir; the OS reclaims it. + } + } + + // PIPESTATUS keeps the command's own exit code rather than tee's, which is + // always 0. bash rather than the user's shell because PIPESTATUS is not + // portable and this wrapper is ours, not something they have to read. + const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)}` + const wrapper = `bash -lc ${JSON.stringify(`{ ${script}; } 2>&1 | tee ${JSON.stringify(outPath)}`)}` + + const args = ['new-window', '-d', '-P', '-F', '#{window_id}', '-t', session, '-n', 'sim-run'] + if (cwd) args.push('-c', cwd) + args.push(wrapper) + + const created = await runTmux(args, env) + if (!created.ok) { + dispose() + return { error: created.stderr.trim() || 'tmux could not open a window for the command.' } + } + + return { window: created.stdout.trim(), outPath, statusPath, dispose } +} + +function readIfPresent(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** Reads a run's current output and, once written, its exit code. */ +export function pollRun(handle: TmuxRunHandle): TmuxRunOutcome { + const status = readIfPresent(handle.statusPath) + const output = readIfPresent(handle.outPath) ?? '' + if (status === null) { + return { output, exitCode: null, done: false } + } + const exitCode = Number.parseInt(status.trim(), 10) + return { output, exitCode: Number.isNaN(exitCode) ? null : exitCode, done: true } +} + +/** + * Whether the run has finished, decided from the tiny status file alone. + * + * The command's output file grows without bound and `tee` appends to it for + * the whole run, so reading it every poll — as reading the full outcome did — + * meant re-reading and decoding everything printed so far several times a + * second, quadratic in output size. Liveness only needs the status file, which + * is a few bytes; the output is read once, when the run is settled. + */ +function isRunComplete(handle: TmuxRunHandle): boolean { + return readIfPresent(handle.statusPath) !== null +} + +/** + * Waits for a run to finish, up to `waitMs`. Returns as soon as the status + * file appears; a command still going when the window elapses comes back + * undone, for the caller to report as running and poll again later. The full + * output is read only once, on the terminating poll. + */ +export async function awaitRun(handle: TmuxRunHandle, waitMs: number): Promise { + const deadline = Date.now() + waitMs + for (;;) { + if (isRunComplete(handle)) return pollRun(handle) + const remaining = deadline - Date.now() + if (remaining <= 0) return pollRun(handle) + await sleep(Math.min(RUN_POLL_INTERVAL_MS, remaining)) + } +} + +/** + * Closes a pane, taking whatever runs in it with it. + * + * The way to be rid of a program that will not take an interrupt — a coding + * agent, an editor with unsaved state, anything that treats Ctrl-C as "cancel + * the current thing" rather than "quit". tmux tidies up after it: emptying a + * window closes the window, and emptying the last window ends the session. + */ +export async function killPane(target: string, env: NodeJS.ProcessEnv): Promise { + return runTmux(['kill-pane', '-t', target], env) +} + +/** Closes a window opened by {@link startRun}. */ +export async function closeRunWindow(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise { + if (!handle.window) return + const killed = await runTmux(['kill-window', '-t', handle.window], env) + if (!killed.ok) { + logger.warn('Could not close the tmux run window', { error: killed.stderr.trim() }) + } +} diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts new file mode 100644 index 00000000000..84a8e0ad059 --- /dev/null +++ b/apps/desktop/src/main/tray.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { nativeImage, session } from 'electron' +import { + addTrayEnvironmentSubscript, + buildTrayMenuTemplate, + chatRoute, + installTray, + parseRecentChats, + type RecentChat, + type TrayDeps, + trayEnvironmentMarker, +} from '@/main/tray' +// Same module instance the vi.mock factory returns, with mock-typed statics. +import { Menu, Tray } from '@/test/electron-mock' + +function makeDeps(overrides: Partial = {}): TrayDeps { + return { + partition: () => 'persist:sim', + appOrigin: () => 'https://sim.ai', + lastRoute: () => '/workspace/ws1/home', + openMainWindow: vi.fn(), + ...overrides, + } +} + +function chat(id: number): RecentChat { + return { id: `c${id}`, title: `Chat ${id}`, workspaceId: 'ws1', status: 'none' } +} + +describe('parseRecentChats', () => { + it('keeps only well-formed workspace chats, capped at the limit', () => { + const payload = { + chats: [ + { id: 'c1', title: 'Build a workflow', workspaceId: 'ws1' }, + { id: 'c2', title: '', workspaceId: 'ws1' }, + { id: 'c3', title: 'No workspace', workspaceId: null }, + { id: 42, title: 'Bad id', workspaceId: 'ws1' }, + { id: 'c4', title: 'Four', workspaceId: 'ws2' }, + { id: 'c5', title: 'Five', workspaceId: 'ws2' }, + { id: 'c6', title: 'Six', workspaceId: 'ws2' }, + { id: 'c7', title: 'Seven', workspaceId: 'ws2' }, + ], + } + const chats = parseRecentChats(payload, 5) + expect(chats.map((chat) => chat.id)).toEqual(['c1', 'c2', 'c4', 'c5', 'c6']) + expect(chats[1].title).toBe('Untitled chat') + }) + + it('returns empty for malformed payloads', () => { + expect(parseRecentChats(null)).toEqual([]) + expect(parseRecentChats({})).toEqual([]) + expect(parseRecentChats({ chats: 'nope' })).toEqual([]) + }) + + it('derives the sidebar status semantics: active > unread > none', () => { + const payload = { + chats: [ + // Streaming right now → active, regardless of seen state. + { + id: 'c1', + title: 'Streaming', + workspaceId: 'ws1', + activeStreamId: 's1', + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T11:00:00Z', + }, + // Finished after last seen → unread. + { + id: 'c2', + title: 'Fresh reply', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T09:00:00Z', + }, + // Never opened → unread. + { + id: 'c3', + title: 'Never seen', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: null, + }, + // Seen since the last update → no dot. + { + id: 'c4', + title: 'Caught up', + workspaceId: 'ws1', + activeStreamId: null, + updatedAt: '2026-07-19T10:00:00Z', + lastSeenAt: '2026-07-19T11:00:00Z', + }, + // Legacy row without the status fields → no dot. + { id: 'c5', title: 'Legacy', workspaceId: 'ws1' }, + ], + } + expect(parseRecentChats(payload).map((chat) => chat.status)).toEqual([ + 'active', + 'unread', + 'unread', + 'none', + 'none', + ]) + }) +}) + +describe('routes', () => { + it('deep-links chats into their workspace', () => { + expect(chatRoute({ id: 'c1', title: 't', workspaceId: 'ws9', status: 'none' })).toBe( + '/workspace/ws9/chat/c1' + ) + }) +}) + +describe('tray environment marker', () => { + it('marks only non-production channels', () => { + expect(trayEnvironmentMarker('prod')).toBeNull() + expect(trayEnvironmentMarker('local')).toBe('L') + expect(trayEnvironmentMarker('dev')).toBe('D') + expect(trayEnvironmentMarker('staging')).toBe('S') + }) + + it('adds a larger antialiased subscript without modifying the source icon', () => { + const source = nativeImage.createFromPath('/tmp/simTemplate.png') + const marked = addTrayEnvironmentSubscript(source, 'D') + + expect(marked).not.toBe(source) + const [bitmap, options] = vi.mocked(nativeImage.createFromBitmap).mock.calls.at(-1) ?? [] + expect(options).toEqual({ width: 76, height: 32, scaleFactor: 2 }) + expect(Buffer.isBuffer(bitmap)).toBe(true) + expect((bitmap as Buffer).some((value) => value === 255)).toBe(true) + expect((bitmap as Buffer).some((value) => value > 0 && value < 255)).toBe(true) + }) +}) + +describe('buildTrayMenuTemplate', () => { + it('shows recents inline, then actions and quit', () => { + const deps = makeDeps() + const template = buildTrayMenuTemplate(deps, [ + { id: 'c1', title: 'Fix the sync', workspaceId: 'ws1', status: 'none' }, + ]) + const labels = template.map((item) => item.label ?? item.role ?? item.type) + expect(labels).toEqual([ + 'Recent Chats', + 'Fix the sync', + 'separator', + 'New Chat', + 'Open Sim', + 'separator', + 'Quit Sim', + ]) + + const chatItem = template.find((item) => item.label === 'Fix the sync') + ;(chatItem?.click as () => void)() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/chat/c1') + + const newChat = template.find((item) => item.label === 'New Chat') + ;(newChat?.click as () => void)() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/home') + + // Quit is a plain item (role:'quit' would get a system icon on macOS 26). + const quit = template.find((item) => item.label === 'Quit Sim') + expect(quit?.role).toBeUndefined() + ;(quit?.click as () => void)() + }) + + it('marks active and unread chats with a status dot icon', () => { + const template = buildTrayMenuTemplate(makeDeps(), [ + { id: 'c1', title: 'Working', workspaceId: 'ws1', status: 'active' }, + { id: 'c2', title: 'Fresh', workspaceId: 'ws1', status: 'unread' }, + { id: 'c3', title: 'Seen', workspaceId: 'ws1', status: 'none' }, + ]) + const working = template.find((item) => item.label === 'Working') + const fresh = template.find((item) => item.label === 'Fresh') + const seen = template.find((item) => item.label === 'Seen') + expect(working?.icon).toBeDefined() + expect(fresh?.icon).toBeDefined() + // Active (yellow) and unread (green) use distinct images; read chats get none. + expect(working?.icon).not.toBe(fresh?.icon) + expect(seen?.icon).toBeUndefined() + }) + + it('overflows chats beyond the inline count into a More submenu', () => { + const deps = makeDeps() + const chats = Array.from({ length: 9 }, (_, i) => chat(i + 1)) + const template = buildTrayMenuTemplate(deps, chats) + + const inlineLabels = template + .filter((item) => item.label?.startsWith('Chat ')) + .map((item) => item.label) + expect(inlineLabels).toEqual(['Chat 1', 'Chat 2', 'Chat 3', 'Chat 4', 'Chat 5']) + + const more = template.find((item) => item.label === 'More') + expect(more).toBeDefined() + const submenu = more?.submenu as { label?: string; click?: () => void }[] + expect(submenu.map((item) => item.label)).toEqual(['Chat 6', 'Chat 7', 'Chat 8', 'Chat 9']) + submenu[0].click?.() + expect(deps.openMainWindow).toHaveBeenCalledWith('/workspace/ws1/chat/c6') + }) + + it('omits More when everything fits inline and recents when empty', () => { + const fits = buildTrayMenuTemplate(makeDeps(), [chat(1), chat(2)]) + expect(fits.some((item) => item.label === 'More')).toBe(false) + + const empty = buildTrayMenuTemplate(makeDeps(), []) + expect(empty.some((item) => item.label === 'Recent Chats')).toBe(false) + expect(empty.map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'New Chat', + 'Open Sim', + 'separator', + 'Quit Sim', + ]) + }) +}) + +describe('installTray', () => { + beforeEach(() => { + Tray.instances.length = 0 + vi.mocked(nativeImage.createFromBitmap).mockClear() + Menu.buildFromTemplate.mockClear() + }) + + it('fetches fresh chats on click and pops the menu', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Hello', workspaceId: 'ws1' }] }), + })) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + const handle = installTray(makeDeps()) + expect(handle).not.toBeNull() + expect(Tray.instances).toHaveLength(1) + const tray = Tray.instances[0] + + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1)) + expect(fetchMock).toHaveBeenCalledWith( + 'https://sim.ai/api/copilot/chats', + expect.objectContaining({ credentials: 'include' }) + ) + }) + + it('still pops the menu when the chats fetch fails', async () => { + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as never) + + installTray(makeDeps()) + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1)) + }) + + it('drops the previous user’s chats when the server says signed out', async () => { + // A 401 is an answer ("no user, no chats"), not a transport failure. If it + // were treated as a failure the menu would keep listing the signed-out + // user's chat titles to whoever picks the machine up next. + let signedIn = true + const fetchMock = vi.fn(async () => + signedIn + ? { + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), + } + : { ok: false, status: 401, json: async () => ({}) } + ) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + installTray(makeDeps()) + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + + // Each click pops from cache then refreshes it, so poll until the menu + // settles rather than assuming which click sees the new state. + const lastMenu = () => JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1)) + await vi.waitFor(() => { + clickHandler() + expect(lastMenu()).toContain('Secret') + }) + + signedIn = false + await vi.waitFor(() => { + clickHandler() + expect(lastMenu()).not.toContain('Secret') + }) + expect(tray.popUpContextMenu).toHaveBeenCalled() + }) + + it('clearRecentChats empties the menu immediately and voids an in-flight fetch', async () => { + // Sign-out teardown calls this. The menu pops from cache BEFORE refreshing, + // so without it the next open would show the old titles one more time. + let release: (value: unknown) => void = () => {} + const inFlight = new Promise((resolve) => { + release = resolve + }) + const fetchMock = vi.fn(async () => { + await inFlight + return { + ok: true, + status: 200, + json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), + } + }) + vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) + + const handle = installTray(makeDeps()) + handle?.clearRecentChats() + release(undefined) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + const tray = Tray.instances[0] + const clickHandler = tray.on.mock.calls.find( + ([event]: unknown[]) => event === 'click' + )?.[1] as () => void + clickHandler() + await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalled()) + expect(JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1))).not.toContain('Secret') + }) + + it('marks dev, staging, and local status items while leaving production unchanged', () => { + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => { + throw new Error('offline') + }), + } as never) + + installTray(makeDeps()) + expect(nativeImage.createFromBitmap).not.toHaveBeenCalled() + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim') + + installTray(makeDeps({ appOrigin: () => 'https://dev.sim.ai' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(1) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Dev') + + installTray(makeDeps({ appOrigin: () => 'https://staging.sim.ai' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(2) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Staging') + + installTray(makeDeps({ appOrigin: () => 'http://localhost:3000' })) + expect(nativeImage.createFromBitmap).toHaveBeenCalledTimes(3) + expect(Tray.instances.at(-1)?.setToolTip).toHaveBeenCalledWith('Sim Local') + }) +}) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000000..59384682d5e --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,507 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { truncate } from '@sim/utils/string' +import type { MenuItemConstructorOptions, NativeImage } from 'electron' +import { app, Menu, nativeImage, session, Tray } from 'electron' +import { newChatRoute } from '@/main/app-routes' +import { APP_NAME_FOR_CHANNEL, channelForOrigin, type DesktopChannel } from '@/main/config' + +const logger = createLogger('DesktopTray') + +/** Chat status dot colors, mirroring the sidebar's ConversationListItem. */ +const ACTIVE_DOT_COLOR = { r: 0xea, g: 0xb3, b: 0x08 } // yellow: stream in progress +const UNREAD_DOT_COLOR = { r: 0x33, g: 0xc4, b: 0x82 } // green: finished, not yet seen + +/** Chats shown inline at the top of the menu. */ +const RECENT_CHATS_INLINE = 5 +/** Total chats kept (inline + the "More" hover submenu). */ +const RECENT_CHATS_TOTAL = 30 +// Generous: the refresh is async (a click always pops the cached menu +// immediately), so a slow dev-server compile just delays the NEXT open's +// recents instead of dropping them. +const CHATS_FETCH_TIMEOUT_MS = 5000 +const TRAY_ICON_SCALE_FACTOR = 2 +const TRAY_SUBSCRIPT_WIDTH = 5 +const TRAY_SUBSCRIPT_HEIGHT = 7 +const TRAY_SUBSCRIPT_STROKE_WIDTH = 1.1 +const TRAY_SUBSCRIPT_SUPERSAMPLE = 4 + +type TrayEnvironmentMarker = 'L' | 'D' | 'S' +type GlyphPoint = { x: number; y: number } +type GlyphSegment = readonly [GlyphPoint, GlyphPoint] + +function segmentsFromPoints(points: GlyphPoint[]): GlyphSegment[] { + return points.slice(1).map((point, index) => [points[index], point] as const) +} + +function cubicBezier( + start: GlyphPoint, + control1: GlyphPoint, + control2: GlyphPoint, + end: GlyphPoint, + steps = 10 +): GlyphSegment[] { + const points = Array.from({ length: steps + 1 }, (_, index) => { + const t = index / steps + const inverse = 1 - t + return { + x: + inverse ** 3 * start.x + + 3 * inverse ** 2 * t * control1.x + + 3 * inverse * t ** 2 * control2.x + + t ** 3 * end.x, + y: + inverse ** 3 * start.y + + 3 * inverse ** 2 * t * control1.y + + 3 * inverse * t ** 2 * control2.y + + t ** 3 * end.y, + } + }) + return segmentsFromPoints(points) +} + +function traySubscriptSegments(marker: TrayEnvironmentMarker): GlyphSegment[] { + if (marker === 'L') { + return [ + [ + { x: 0.65, y: 0.55 }, + { x: 0.65, y: 6.4 }, + ], + [ + { x: 0.65, y: 6.4 }, + { x: 4.4, y: 6.4 }, + ], + ] + } + if (marker === 'D') { + return [ + [ + { x: 0.65, y: 0.55 }, + { x: 0.65, y: 6.45 }, + ], + ...cubicBezier( + { x: 0.65, y: 0.55 }, + { x: 3.15, y: 0.55 }, + { x: 4.4, y: 1.55 }, + { x: 4.4, y: 3.5 }, + 12 + ), + ...cubicBezier( + { x: 4.4, y: 3.5 }, + { x: 4.4, y: 5.45 }, + { x: 3.15, y: 6.45 }, + { x: 0.65, y: 6.45 }, + 12 + ), + ] + } + return [ + ...cubicBezier( + { x: 4.4, y: 1.05 }, + { x: 3.3, y: 0.25 }, + { x: 0.6, y: 0.3 }, + { x: 0.6, y: 2.25 } + ), + ...cubicBezier( + { x: 0.6, y: 2.25 }, + { x: 0.6, y: 3.4 }, + { x: 4.4, y: 3.15 }, + { x: 4.4, y: 4.65 } + ), + ...cubicBezier( + { x: 4.4, y: 4.65 }, + { x: 4.4, y: 6.75 }, + { x: 1.45, y: 6.75 }, + { x: 0.55, y: 5.85 } + ), + ] +} + +function distanceToSegment(point: GlyphPoint, [start, end]: GlyphSegment): number { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + const projection = + lengthSquared === 0 + ? 0 + : Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared) + ) + return Math.hypot(point.x - (start.x + projection * dx), point.y - (start.y + projection * dy)) +} + +export function trayEnvironmentMarker(channel: DesktopChannel): TrayEnvironmentMarker | null { + switch (channel) { + case 'local': + return 'L' + case 'dev': + return 'D' + case 'staging': + return 'S' + default: + return null + } +} + +/** + * Adds a compact lower-right environment letter to the monochrome status + * icon. The glyph is drawn as a supersampled vector stroke so its curves match + * the smooth Sim mark at menu-bar scale; template rendering lets macOS tint + * both parts together. + */ +export function addTrayEnvironmentSubscript( + source: NativeImage, + marker: TrayEnvironmentMarker +): NativeImage { + const sourceSize = source.getSize() + const sourceWidth = sourceSize.width * TRAY_ICON_SCALE_FACTOR + const sourceHeight = sourceSize.height * TRAY_ICON_SCALE_FACTOR + const sourceBitmap = source.toBitmap({ scaleFactor: TRAY_ICON_SCALE_FACTOR }) + const targetLogicalWidth = sourceSize.width + TRAY_SUBSCRIPT_WIDTH + 1 + const targetWidth = targetLogicalWidth * TRAY_ICON_SCALE_FACTOR + const targetBitmap = Buffer.alloc(targetWidth * sourceHeight * 4) + + for (let y = 0; y < sourceHeight; y++) { + sourceBitmap.copy( + targetBitmap, + y * targetWidth * 4, + y * sourceWidth * 4, + (y + 1) * sourceWidth * 4 + ) + } + + const glyphX = (sourceSize.width + 1) * TRAY_ICON_SCALE_FACTOR + const glyphY = (sourceSize.height - TRAY_SUBSCRIPT_HEIGHT) * TRAY_ICON_SCALE_FACTOR + const segments = traySubscriptSegments(marker) + const sampleCount = TRAY_SUBSCRIPT_SUPERSAMPLE ** 2 + for (let y = glyphY; y < sourceHeight; y++) { + for (let x = glyphX; x < targetWidth; x++) { + let coveredSamples = 0 + for (let sampleY = 0; sampleY < TRAY_SUBSCRIPT_SUPERSAMPLE; sampleY++) { + for (let sampleX = 0; sampleX < TRAY_SUBSCRIPT_SUPERSAMPLE; sampleX++) { + const point = { + x: (x - glyphX + (sampleX + 0.5) / TRAY_SUBSCRIPT_SUPERSAMPLE) / TRAY_ICON_SCALE_FACTOR, + y: (y - glyphY + (sampleY + 0.5) / TRAY_SUBSCRIPT_SUPERSAMPLE) / TRAY_ICON_SCALE_FACTOR, + } + if ( + segments.some( + (segment) => distanceToSegment(point, segment) <= TRAY_SUBSCRIPT_STROKE_WIDTH / 2 + ) + ) { + coveredSamples++ + } + } + } + const offset = (y * targetWidth + x) * 4 + targetBitmap[offset + 3] = Math.round((coveredSamples / sampleCount) * 255) + } + } + + return nativeImage.createFromBitmap(targetBitmap, { + width: targetWidth, + height: sourceHeight, + scaleFactor: TRAY_ICON_SCALE_FACTOR, + }) +} +const CHATS_API_PATH = '/api/copilot/chats' + +/** + * Chat status for the menu dot, mirroring the sidebar's semantics: + * `active` (yellow) = a stream is running; `unread` (green) = finished after + * the user last saw the chat. + */ +export type RecentChatStatus = 'active' | 'unread' | 'none' + +export interface RecentChat { + id: string + title: string + workspaceId: string + status: RecentChatStatus +} + +function deriveChatStatus(chat: { + activeStreamId?: unknown + lastSeenAt?: unknown + updatedAt?: unknown +}): RecentChatStatus { + if (typeof chat.activeStreamId === 'string' && chat.activeStreamId) { + return 'active' + } + const updatedAt = typeof chat.updatedAt === 'string' ? Date.parse(chat.updatedAt) : Number.NaN + if (Number.isNaN(updatedAt)) { + return 'none' + } + if (chat.lastSeenAt === null || chat.lastSeenAt === undefined) { + return 'unread' + } + const lastSeenAt = typeof chat.lastSeenAt === 'string' ? Date.parse(chat.lastSeenAt) : Number.NaN + if (Number.isNaN(lastSeenAt)) { + return 'none' + } + return updatedAt > lastSeenAt ? 'unread' : 'none' +} + +/** + * Extracts tray-usable chats from the /api/copilot/chats response. Chats + * without a workspace id are dropped — the deep link needs one — and the + * response is already sorted by recency server-side. + */ +export function parseRecentChats( + payload: unknown, + limit: number = RECENT_CHATS_TOTAL +): RecentChat[] { + if (typeof payload !== 'object' || payload === null) { + return [] + } + const chats = (payload as { chats?: unknown }).chats + if (!Array.isArray(chats)) { + return [] + } + const result: RecentChat[] = [] + for (const chat of chats) { + if (result.length >= limit) { + break + } + if (typeof chat !== 'object' || chat === null) { + continue + } + const { id, title, workspaceId } = chat as { + id?: unknown + title?: unknown + workspaceId?: unknown + } + if (typeof id !== 'string' || !id || typeof workspaceId !== 'string' || !workspaceId) { + continue + } + result.push({ + id, + title: typeof title === 'string' && title.trim() ? title.trim() : 'Untitled chat', + workspaceId, + status: deriveChatStatus(chat as Record), + }) + } + return result +} + +/** + * Renders a small filled circle as a menu-item icon (menus can't color text, + * so the sidebar's status dot becomes a NativeImage). Drawn at 2x with a 1px + * anti-aliased edge; BGRA premultiplied, as createFromBitmap expects. + */ +function createDotImage(color: { r: number; g: number; b: number }): NativeImage { + const scaleFactor = 2 + const size = 6 * scaleFactor + const buffer = Buffer.alloc(size * size * 4) + const center = (size - 1) / 2 + const radius = size / 2 + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const alpha = Math.max(0, Math.min(1, radius - Math.hypot(x - center, y - center))) + const i = (y * size + x) * 4 + buffer[i] = Math.round(color.b * alpha) + buffer[i + 1] = Math.round(color.g * alpha) + buffer[i + 2] = Math.round(color.r * alpha) + buffer[i + 3] = Math.round(255 * alpha) + } + } + return nativeImage.createFromBitmap(buffer, { width: size, height: size, scaleFactor }) +} + +let dotImages: { active: NativeImage; unread: NativeImage } | null = null + +function statusDotImage(status: RecentChatStatus): NativeImage | undefined { + if (status === 'none') { + return undefined + } + if (!dotImages) { + dotImages = { + active: createDotImage(ACTIVE_DOT_COLOR), + unread: createDotImage(UNREAD_DOT_COLOR), + } + } + return status === 'active' ? dotImages.active : dotImages.unread +} + +export function chatRoute(chat: RecentChat): string { + return `/workspace/${chat.workspaceId}/chat/${chat.id}` +} + +export interface TrayDeps { + partition: () => string + appOrigin: () => string + lastRoute: () => string | undefined + openMainWindow: (route?: string) => void +} + +function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { + const icon = statusDotImage(chat.status) + return { + label: truncate(chat.title, 57, '…'), + ...(icon ? { icon } : {}), + click: () => deps.openMainWindow(chatRoute(chat)), + } +} + +/** + * Menu shape (modeled on ChatGPT's status item): a Recent section with the + * newest chats inline and the rest under a "More" hover submenu, then New + * Chat / Open Sim grouped together, then Quit in its own section. + */ +export function buildTrayMenuTemplate( + deps: TrayDeps, + recentChats: RecentChat[] +): MenuItemConstructorOptions[] { + const template: MenuItemConstructorOptions[] = [] + if (recentChats.length > 0) { + template.push({ label: 'Recent Chats', enabled: false }) + for (const chat of recentChats.slice(0, RECENT_CHATS_INLINE)) { + template.push(chatMenuItem(chat, deps)) + } + const overflow = recentChats.slice(RECENT_CHATS_INLINE) + if (overflow.length > 0) { + template.push({ + label: 'More', + submenu: overflow.map((chat) => chatMenuItem(chat, deps)), + }) + } + template.push({ type: 'separator' }) + } + template.push( + { label: 'New Chat', click: () => deps.openMainWindow(newChatRoute(deps.lastRoute())) }, + { label: 'Open Sim', click: () => deps.openMainWindow() }, + { type: 'separator' }, + // Plain item, not role:'quit' — macOS Tahoe auto-decorates standard roles + // with SF Symbol icons and the ⌘Q badge, which this menu doesn't want. + { label: 'Quit Sim', click: () => app.quit() } + ) + return template +} + +export interface TrayHandle { + /** + * Drops the cached chat titles. Called from the sign-out teardown: the titles + * are the previous user's data and must not outlive their session, and the + * menu pops from cache before it refreshes, so waiting for the next fetch + * would show them one more time. + */ + clearRecentChats(): void + destroy(): void +} + +/** + * The macOS status item. No static context menu is attached — macOS shows an + * attached menu synchronously without emitting 'click', which would freeze + * the recent-chats section at creation time. Instead each click fetches the + * chat list (bounded by a short timeout, falling back to the last good list) + * and pops the freshly built menu. + */ +export function installTray(deps: TrayDeps): TrayHandle | null { + const iconPath = join(app.getAppPath(), 'static', 'tray', 'simTemplate.png') + const sourceIcon = nativeImage.createFromPath(iconPath) + if (sourceIcon.isEmpty()) { + logger.error('Tray icon missing; skipping tray install', { iconPath }) + return null + } + const channel = channelForOrigin(deps.appOrigin()) + const marker = trayEnvironmentMarker(channel) + const icon = marker ? addTrayEnvironmentSubscript(sourceIcon, marker) : sourceIcon + icon.setTemplateImage(true) + + const tray = new Tray(icon) + tray.setToolTip(APP_NAME_FOR_CHANNEL[channel]) + + let cachedChats: RecentChat[] = [] + let refreshing = false + let cacheGeneration = 0 + + const fetchRecentChats = async (): Promise => { + const ses = session.fromPartition(deps.partition()) + const response = await ses.fetch(`${deps.appOrigin()}${CHATS_API_PATH}`, { + credentials: 'include', + signal: AbortSignal.timeout(CHATS_FETCH_TIMEOUT_MS), + }) + // Signed out is an ANSWER, not a failure: the correct list for no user is + // empty. Throwing here would fall into the catch below and leave the + // previous user's chat titles in the menu until someone signed back in. + if (response.status === 401 || response.status === 403) { + return [] + } + if (!response.ok) { + throw new Error(`chats fetch failed: ${response.status}`) + } + return parseRecentChats(await response.json()) + } + + /** Update the cached chat list for the NEXT open; never blocks a click. */ + const refreshChats = async () => { + if (refreshing) return + refreshing = true + const generation = cacheGeneration + try { + const chats = await fetchRecentChats() + // A sign-out while this was in flight invalidates the result — it was + // read with the previous user's cookie. + if (generation === cacheGeneration) { + cachedChats = chats + } + } catch (error) { + // Offline or an older server: keep the last good list rather than + // blanking a menu that is still correct. + logger.info('Recent chats unavailable for tray menu', { error }) + } finally { + refreshing = false + } + } + + /** + * Pop the menu from the cached chat list so the click feels instant, then + * refresh the cache in the background for the next open. One exception: when + * the cache is EMPTY (failed launch warm-up, fresh sign-in) the menu would + * pop without a Recent section and stay wrong until the next click — so wait + * briefly for a refresh, popping no later than the grace period either way. + */ + const EMPTY_CACHE_POP_GRACE_MS = 600 + const popMenu = async () => { + if (cachedChats.length === 0) { + await Promise.race([refreshChats(), sleep(EMPTY_CACHE_POP_GRACE_MS)]) + } + if (tray.isDestroyed()) return + tray.popUpContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, cachedChats))) + void refreshChats() + } + + // Warm the cache with retries: at launch the first fetch races the server + // (dev recompiles, app cold start), and a single failed warm-up would leave + // the first tray open without recents until a second click. + const WARM_UP_BACKOFF_MS = [2_000, 5_000, 10_000, 20_000] + const warmUp = async (attempt = 0) => { + await refreshChats() + if (cachedChats.length === 0 && attempt < WARM_UP_BACKOFF_MS.length && !tray.isDestroyed()) { + setTimeout(() => void warmUp(attempt + 1), WARM_UP_BACKOFF_MS[attempt]).unref?.() + } + } + void warmUp() + + // Keep the cache (and the status dots) current even when the tray hasn't + // been clicked in a while. + const refreshTimer = setInterval(() => void refreshChats(), 60_000) + refreshTimer.unref?.() + + tray.on('click', () => void popMenu()) + tray.on('right-click', () => void popMenu()) + + return { + clearRecentChats() { + cacheGeneration += 1 + cachedChats = [] + }, + destroy() { + clearInterval(refreshTimer) + if (!tray.isDestroyed()) { + tray.destroy() + } + }, + } +} diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts new file mode 100644 index 00000000000..c5d1140851c --- /dev/null +++ b/apps/desktop/src/main/updater.test.ts @@ -0,0 +1,424 @@ +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const autoUpdaterMock = { + channel: '', + allowDowngrade: false, + autoDownload: true, + autoInstallOnAppQuit: false, + logger: null as unknown, + on: vi.fn(), + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(() => Promise.resolve(null)), + downloadUpdate: vi.fn(() => Promise.resolve([])), + quitAndInstall: vi.fn(), +} + +import { app, dialog, shell } from 'electron' +import { + checkForUpdatesInteractive, + feedUrlForOrigin, + initUpdater, + isDowngrade, + isNewerVersion, + parseSemver, + resolveUpdateChannel, +} from '@/main/updater' + +describe('resolveUpdateChannel', () => { + it('maps stable versions to latest', () => { + expect(resolveUpdateChannel('1.2.3')).toBe('latest') + expect(resolveUpdateChannel('0.5.24')).toBe('latest') + }) + + it('maps prerelease versions to their channel', () => { + expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('beta') + expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('alpha') + }) +}) + +describe('parseSemver', () => { + it('parses plain and v-prefixed versions', () => { + expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) + expect(parseSemver('v0.5.24')).toEqual({ major: 0, minor: 5, patch: 24, prerelease: '' }) + expect(parseSemver('1.2.3-beta.1')?.prerelease).toBe('beta.1') + }) + + it('returns null for garbage', () => { + expect(parseSemver('latest')).toBeNull() + expect(parseSemver('1.2')).toBeNull() + expect(parseSemver('')).toBeNull() + }) +}) + +describe('isDowngrade', () => { + it('rejects lower versions', () => { + expect(isDowngrade('1.2.3', '1.2.2')).toBe(true) + expect(isDowngrade('1.2.3', '1.1.9')).toBe(true) + expect(isDowngrade('2.0.0', '1.9.9')).toBe(true) + }) + + it('accepts equal and higher versions', () => { + expect(isDowngrade('1.2.3', '1.2.3')).toBe(false) + expect(isDowngrade('1.2.3', '1.2.4')).toBe(false) + expect(isDowngrade('1.2.3', '2.0.0')).toBe(false) + }) + + it('treats a prerelease of the current stable core as a downgrade', () => { + expect(isDowngrade('1.2.3', '1.2.3-beta.1')).toBe(true) + expect(isDowngrade('1.2.3-beta.1', '1.2.3')).toBe(false) + }) + + it('compares prerelease identifiers within the same core version', () => { + expect(isDowngrade('1.4.0-beta.5', '1.4.0-beta.2')).toBe(true) + expect(isDowngrade('1.4.0-beta.2', '1.4.0-beta.10')).toBe(false) + expect(isDowngrade('1.4.0-beta.2', '1.4.0-beta.2')).toBe(false) + expect(isDowngrade('1.4.0-rc.1', '1.4.0-beta.9')).toBe(true) + }) + + it('treats unparseable versions as downgrades', () => { + expect(isDowngrade('1.2.3', 'nightly')).toBe(true) + expect(isDowngrade('garbage', '1.2.3')).toBe(true) + }) +}) + +describe('isNewerVersion', () => { + it('is true only for strictly newer candidates', () => { + expect(isNewerVersion('1.2.4', '1.2.3')).toBe(true) + expect(isNewerVersion('1.2.4-alpha.3', '1.2.3')).toBe(true) + expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false) + expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false) + }) + + it('never offers an unparseable feed version', () => { + expect(isNewerVersion('latest', '1.2.3')).toBe(false) + expect(isNewerVersion('', '1.2.3')).toBe(false) + }) +}) + +describe('feedUrlForOrigin', () => { + it('builds the per-env feed URL from the configured origin', () => { + expect(feedUrlForOrigin('https://www.dev.sim.ai')).toBe( + 'https://www.dev.sim.ai/api/desktop/update' + ) + expect(feedUrlForOrigin('http://localhost:3000')).toBe( + 'http://localhost:3000/api/desktop/update' + ) + }) + + it('rejects non-http origins and garbage', () => { + expect(feedUrlForOrigin('file:///tmp/app')).toBeNull() + expect(feedUrlForOrigin('not a url')).toBeNull() + }) +}) + +describe('initUpdater state machine', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + function emit(event: string, ...args: unknown[]) { + for (const [name, listener] of autoUpdaterMock.on.mock.calls) { + if (name === event) { + ;(listener as (...values: unknown[]) => void)(...args) + } + } + } + + async function createUpdater(options?: { autoDownload?: boolean; feedAvailable?: boolean }) { + const states: DesktopUpdateState[] = [] + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + autoDownload: () => options?.autoDownload ?? true, + onStateChange: (state) => states.push(state), + loadAutoUpdater: () => + autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], + probeOriginFeed: async () => options?.feedAvailable ?? false, + canSelfUpdate: async () => true, + }) + // Engine selection (signature detection) resolves asynchronously. + await vi.advanceTimersByTimeAsync(0) + return { handle, states } + } + + beforeEach(() => { + vi.useFakeTimers() + autoUpdaterMock.on.mockClear() + autoUpdaterMock.setFeedURL.mockClear() + autoUpdaterMock.checkForUpdates.mockClear() + autoUpdaterMock.downloadUpdate.mockClear() + autoUpdaterMock.quitAndInstall.mockClear() + // Keep the update-downloaded dialog from resolving into quitAndInstall. + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('walks check -> download -> ready and installs only from ready', async () => { + const { handle, states } = await createUpdater() + expect(handle.getState()).toEqual({ status: 'idle' }) + + handle.install() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + emit('checking-for-update') + emit('update-available', { version: '2.0.0' }) + emit('download-progress', { percent: 41.7 }) + emit('update-downloaded', { version: '2.0.0' }) + + expect(states).toEqual([ + { status: 'checking' }, + { status: 'downloading', version: '2.0.0' }, + { status: 'downloading', version: '2.0.0', percent: 42 }, + { status: 'ready', version: '2.0.0' }, + ]) + + handle.install() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('stops at available and downloads on demand when auto-download is off', async () => { + autoUpdaterMock.autoDownload = false + const { handle } = await createUpdater({ autoDownload: false }) + + emit('update-available', { version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) + + handle.check() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('checks from idle and ignores re-entrant checks while busy', async () => { + const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + emit('checking-for-update') + handle.check() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + it('resets to idle when a downloaded update is a blocked downgrade', async () => { + const { handle } = await createUpdater() + emit('update-downloaded', { version: '0.0.1' }) + expect(handle.getState()).toEqual({ status: 'idle' }) + handle.install() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + }) + + it('surfaces updater errors and recovers via update-not-available', async () => { + const { handle } = await createUpdater() + emit('error', new Error('feed unreachable')) + expect(handle.getState()).toEqual({ status: 'error' }) + emit('update-not-available') + expect(handle.getState()).toEqual({ status: 'idle' }) + }) + + it('switches to the per-env origin feed when the origin serves one', async () => { + await createUpdater({ feedAvailable: true }) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: 'https://www.dev.sim.ai/api/desktop/update', + channel: 'latest', + }) + expect(autoUpdaterMock.channel).toBe('latest') + }) + + it('keeps the packaged GitHub feed when the origin has no feed', async () => { + await createUpdater({ feedAvailable: false }) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + }) + + it('skips checks on prerelease builds when the origin feed is down', async () => { + // The GitHub fallback is stable-only: a Sim Dev shell can never apply a + // prod-identity artifact, so it must not check against it. + vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + try { + const { handle } = await createUpdater({ feedAvailable: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) + + it('checks prerelease builds normally through the origin feed', async () => { + vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + try { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) +}) + +function manifest(version: string): string { + return [ + `version: ${version}`, + 'files:', + ` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + ' sha512: abc', + ` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal.dmg`, + ' sha512: def', + `path: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') +} + +describe('initUpdater manual mode (no Developer ID signature)', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + async function createManualUpdater(fetchManifest: (url: string) => Promise) { + const states: DesktopUpdateState[] = [] + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + onStateChange: (state) => states.push(state), + canSelfUpdate: async () => false, + fetchManifest, + }) + await vi.advanceTimersByTimeAsync(0) + return { handle, states } + } + + beforeEach(() => { + vi.useFakeTimers() + events.record.mockClear() + vi.mocked(shell.openExternal).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('offers a newer feed version as a manual download of the dmg', async () => { + const fetchManifest = vi.fn(async () => manifest('9.9.9')) + const { handle } = await createManualUpdater(fetchManifest) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(fetchManifest).toHaveBeenCalledWith( + 'https://www.dev.sim.ai/api/desktop/update/latest-mac.yml' + ) + expect(handle.getState()).toEqual({ status: 'available', version: '9.9.9', manual: true }) + + // The `available` advance opens the browser instead of downloading. + handle.check() + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-universal.dmg' + ) + + // install() from manual `available` opens the same download. + handle.install() + expect(shell.openExternal).toHaveBeenCalledTimes(2) + }) + + it('stays idle when the feed version is not newer', async () => { + const { handle } = await createManualUpdater(async () => manifest(app.getVersion())) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('stays idle when the origin serves no feed', async () => { + const { handle } = await createManualUpdater(async () => null) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + }) + + it('surfaces manifest fetch failures as errors', async () => { + const { handle } = await createManualUpdater(async () => { + throw new Error('network down') + }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + }) + + it('checks on the scheduled interval', async () => { + const fetchManifest = vi.fn(async () => manifest('9.9.9')) + await createManualUpdater(fetchManifest) + await vi.advanceTimersByTimeAsync(10_000) + expect(fetchManifest).toHaveBeenCalledTimes(1) + }) +}) + +describe('checkForUpdatesInteractive', () => { + const events = { record: vi.fn(), filePath: '/tmp/desktop-events.log' } + + async function manualHandle(version: string) { + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest: async () => manifest(version), + }) + await vi.advanceTimersByTimeAsync(0) + return handle + } + + beforeEach(() => { + vi.useFakeTimers() + ;(app as unknown as { isPackaged: boolean }).isPackaged = true + events.record.mockClear() + vi.mocked(dialog.showMessageBox).mockClear() + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) + vi.mocked(shell.openExternal).mockClear() + }) + + afterEach(() => { + ;(app as unknown as { isPackaged: boolean }).isPackaged = false + vi.useRealTimers() + }) + + it('offers the manual download and opens it on Download', async () => { + const handle = await manualHandle('9.9.9') + vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false }) + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Sim 9.9.9 is available' }) + ) + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-universal.dmg' + ) + }) + + it('reports up to date when the feed has nothing newer', async () => { + const handle = await manualHandle(app.getVersion()) + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Sim is up to date' }) + ) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('only explains packaged-build updates when unpackaged', async () => { + ;(app as unknown as { isPackaged: boolean }).isPackaged = false + checkForUpdatesInteractive({ getWindow: () => null, events, handle: null }) + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Updates are only available in packaged builds' }) + ) + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 00000000000..82d3a16f32e --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,630 @@ +import { execFile } from 'node:child_process' +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { BrowserWindow } from 'electron' +import { app, dialog, net, shell } from 'electron' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopUpdater') + +const INITIAL_CHECK_DELAY_MS = 10_000 +const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 + +export type UpdateChannel = 'latest' | 'beta' | 'alpha' + +/** + * The per-environment update feed served by the Sim deployment this shell is + * pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins + * which shell build its clients are offered — dev serves alpha builds, + * staging beta, prod stable — so the environment, not the client, is the + * channel. Returns null for origins that can't host a feed. + */ +export function feedUrlForOrigin(origin: string): string | null { + try { + const url = new URL(origin) + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + return `${url.origin}/api/desktop/update` + } catch { + return null + } +} + +/** + * Maps the running version to its update channel: prerelease builds follow + * their prerelease channel, stable builds only ever see stable releases. + * Channels are strictly isolated — alpha/beta builds carry their own app + * identity (Sim Dev / Sim Staging) and update only via their origin feed; + * the packaged GitHub fallback feed is stable-only. + */ +export function resolveUpdateChannel(version: string): UpdateChannel { + if (version.includes('-alpha')) { + return 'alpha' + } + if (version.includes('-beta')) { + return 'beta' + } + return 'latest' +} + +interface ParsedSemver { + major: number + minor: number + patch: number + prerelease: string +} + +/** + * Minimal semver parser for the defensive downgrade check (electron-updater + * already enforces allowDowngrade=false; this guards against a tampered or + * misconfigured feed). + */ +export function parseSemver(version: string): ParsedSemver | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim()) + if (!match) { + return null + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? '', + } +} + +/** + * Compares two prerelease strings by semver precedence: a missing prerelease + * outranks any prerelease, dotted identifiers compare left to right, numeric + * identifiers compare numerically and rank below alphanumeric ones, and a + * shorter identifier set ranks lower when all preceding fields are equal. + * Returns <0 when a precedes b, 0 when equal, >0 when a follows b. + */ +function comparePrerelease(a: string, b: string): number { + if (a === b) { + return 0 + } + if (a === '') { + return 1 + } + if (b === '') { + return -1 + } + const as = a.split('.') + const bs = b.split('.') + for (let i = 0; i < Math.max(as.length, bs.length); i++) { + const ai = as[i] + const bi = bs[i] + if (ai === undefined) { + return -1 + } + if (bi === undefined) { + return 1 + } + const aNum = /^\d+$/.test(ai) + const bNum = /^\d+$/.test(bi) + if (aNum && bNum) { + const diff = Number(ai) - Number(bi) + if (diff !== 0) { + return diff < 0 ? -1 : 1 + } + } else if (aNum) { + return -1 + } else if (bNum) { + return 1 + } else if (ai !== bi) { + return ai < bi ? -1 : 1 + } + } + return 0 +} + +/** + * True when candidate is a lower version than current, including a lower + * prerelease of the same core version. Unparseable versions are treated as + * downgrades and rejected. + */ +export function isDowngrade(currentVersion: string, candidateVersion: string): boolean { + const current = parseSemver(currentVersion) + const candidate = parseSemver(candidateVersion) + if (!current || !candidate) { + return true + } + const currentCore = [current.major, current.minor, current.patch] + const candidateCore = [candidate.major, candidate.minor, candidate.patch] + for (let i = 0; i < 3; i++) { + if (candidateCore[i] !== currentCore[i]) { + return candidateCore[i] < currentCore[i] + } + } + return comparePrerelease(candidate.prerelease, current.prerelease) < 0 +} + +export interface UpdaterDeps { + getWindow: () => BrowserWindow | null + events: EventRecorder + /** The Sim origin this shell is pointed at — hosts the per-env update feed. */ + appOrigin: () => string + autoDownload?: () => boolean + /** Pushed on every pipeline state change (renderer update UI). */ + onStateChange?: (state: DesktopUpdateState) => void + /** Test seam: overrides the lazy electron-updater load. */ + loadAutoUpdater?: () => typeof import('electron-updater')['autoUpdater'] + /** Test seam: overrides the origin feed availability probe. */ + probeOriginFeed?: (feedUrl: string) => Promise + /** + * Test seam: overrides Squirrel self-update capability detection (whether + * the running bundle carries a real Developer ID signature). + */ + canSelfUpdate?: () => Promise + /** Test seam: overrides the manual-mode manifest fetch (body or null). */ + fetchManifest?: (url: string) => Promise +} + +export interface UpdaterHandle { + setAutoDownload(enabled: boolean): void + /** Current pipeline state for the renderer update UI. */ + getState(): DesktopUpdateState + /** + * Renderer-initiated advance: checks for an update, or starts the download + * when one is already known to be available (auto-download off / manual). + */ + check(): void + /** + * Quit and install a downloaded update (`ready`), or open the manual + * download for an `available` update on a shell that can't self-update. + */ + install(): void + /** Main-process state subscription (menu feedback). Returns unsubscribe. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +const NOOP_UPDATER_HANDLE: UpdaterHandle = { + setAutoDownload: () => {}, + getState: () => ({ status: 'idle' }), + check: () => {}, + install: () => {}, + onState: () => () => {}, +} + +/** True when candidate is strictly newer than current. */ +export function isNewerVersion(candidateVersion: string, currentVersion: string): boolean { + if (!parseSemver(candidateVersion)) { + return false + } + return isDowngrade(candidateVersion, currentVersion) +} + +/** + * Whether Squirrel.Mac can swap this bundle in place. It validates a + * downloaded update against the running app's code signature, so only builds + * carrying a real Developer ID (a TeamIdentifier) can self-update. Local + * `install:local` builds and pre-signing CI prereleases are ad-hoc signed + * (`TeamIdentifier=not set`) and would fail the swap — those shells get the + * manual pipeline instead. + */ +async function detectSelfUpdateCapability(): Promise { + if (process.platform !== 'darwin') { + return true + } + const exe = app.getPath('exe') + const bundleEnd = exe.indexOf('.app/') + if (bundleEnd < 0) { + return false + } + const bundlePath = exe.slice(0, bundleEnd + 4) + return new Promise((resolve) => { + execFile('codesign', ['-dv', '--verbose=2', bundlePath], (error, _stdout, stderr) => { + if (error) { + resolve(false) + return + } + const team = /^TeamIdentifier=(.+)$/m.exec(stderr ?? '') + resolve(team !== null && team[1].trim() !== 'not set') + }) + }) +} + +/** + * One update pipeline behind the shared handle: `check` looks for an update, + * `advance` performs the `available` action (background download vs opening + * the download in the browser), `install` performs the `ready` action. + */ +interface UpdateEngine { + check(): void + advance(): void + install(): void + setAutoDownload(enabled: boolean): void +} + +/** + * Keeps installed shells current against the per-environment update feed: + * checks on launch and every four hours, and mirrors pipeline state to the + * renderer for the settings update UI and the minimum-shell-version gate. + * + * Developer-ID-signed builds use electron-updater (background download, + * install on user confirmation — never mid-session without consent). Builds + * that can't self-update (ad-hoc signed: local installs, pre-signing CI + * prereleases) still poll the same feed but surface `available` as a manual + * download link, so the whole pipeline is testable before signing exists. + */ +export function initUpdater(deps: UpdaterDeps): UpdaterHandle { + if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { + return NOOP_UPDATER_HANDLE + } + + const currentVersion = app.getVersion() + let state: DesktopUpdateState = { status: 'idle' } + const listeners = new Set<(state: DesktopUpdateState) => void>() + const setState = (next: DesktopUpdateState) => { + state = next + deps.onStateChange?.(next) + for (const listener of listeners) { + listener(next) + } + } + + const buildAutoEngine = (): UpdateEngine | null => { + let autoUpdater: typeof import('electron-updater')['autoUpdater'] + try { + autoUpdater = deps.loadAutoUpdater + ? deps.loadAutoUpdater() + : (require('electron-updater') as typeof import('electron-updater')).autoUpdater + } catch (error) { + logger.error('electron-updater unavailable', { error }) + return null + } + + autoUpdater.channel = resolveUpdateChannel(currentVersion) + autoUpdater.allowDowngrade = false + autoUpdater.autoDownload = deps.autoDownload?.() ?? true + // Never install without vetting the downloaded version first. Enabled per + // download in the update-downloaded handler, but only for accepted updates + // — so a blocked/downgrade build that was already downloaded is never + // silently installed on quit. + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.logger = null + + autoUpdater.on('checking-for-update', () => { + setState({ status: 'checking' }) + }) + + autoUpdater.on('update-not-available', () => { + setState({ status: 'idle' }) + }) + + autoUpdater.on('update-available', (info) => { + deps.events.record('update_check', { available: info.version }) + // With auto-download on, download-progress events follow immediately; + // `available` is the terminal state only when downloads are manual. + setState({ + status: autoUpdater.autoDownload ? 'downloading' : 'available', + version: info.version, + }) + }) + + autoUpdater.on('download-progress', (progress) => { + setState({ + status: 'downloading', + version: state.version, + percent: Math.round(progress.percent), + }) + }) + + autoUpdater.on('update-downloaded', (info) => { + if (isDowngrade(currentVersion, info.version)) { + autoUpdater.autoInstallOnAppQuit = false + deps.events.record('update_blocked_version', { version: info.version }) + setState({ status: 'idle' }) + return + } + autoUpdater.autoInstallOnAppQuit = true + deps.events.record('update_downloaded', { version: info.version }) + setState({ status: 'ready', version: info.version }) + const win = deps.getWindow() + const options = { + type: 'info' as const, + buttons: ['Restart Now', 'Later'], + defaultId: 0, + cancelId: 1, + message: `Sim ${info.version} is ready to install`, + detail: 'Restart to finish updating. If you choose Later, the update installs on quit.', + } + const prompt = win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options) + void prompt.then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall() + } + }) + }) + + autoUpdater.on('error', (error) => { + deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version }) + }) + + /** + * Prefer the per-environment feed served by the configured origin; fall + * back to the packaged GitHub feed when the origin doesn't serve one (an + * older or partial deployment). The origin feed is channel-resolved + * server-side, so the client always requests plain `latest-mac.yml`. + * + * The fallback is stable-only: prerelease (dev/staging) builds exist + * solely on their origin feed, and the GitHub feed could only offer them + * a stable prod-identity artifact their Squirrel identity can't apply — + * so when the origin feed is down, a prerelease shell skips checking + * rather than erroring on every cycle. Resolves to whether checks may run. + */ + const probeOriginFeed = + deps.probeOriginFeed ?? + (async (feedUrl: string) => { + const response = await net.fetch(`${feedUrl}/latest-mac.yml`) + return response.ok + }) + const feedConfigured: Promise = (async () => { + const feedUrl = feedUrlForOrigin(deps.appOrigin()) + const stableBuild = resolveUpdateChannel(currentVersion) === 'latest' + if (!feedUrl) { + return stableBuild + } + try { + if (!(await probeOriginFeed(feedUrl))) { + throw new Error('feed responded non-OK') + } + autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' }) + autoUpdater.channel = 'latest' + deps.events.record('update_feed', { url: feedUrl }) + return true + } catch (error) { + logger.warn( + stableBuild + ? 'Origin update feed unavailable; using default GitHub feed' + : 'Origin update feed unavailable; prerelease build skips update checks', + { feedUrl, message: getErrorMessage(error, 'unknown') } + ) + return stableBuild + } + })() + + return { + check() { + void feedConfigured.then((mayCheck) => { + if (!mayCheck) { + return + } + autoUpdater.checkForUpdates().catch((error) => { + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + }) + }) + }, + advance() { + autoUpdater.downloadUpdate().catch((error) => { + logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version }) + }) + }, + install() { + autoUpdater.quitAndInstall() + }, + setAutoDownload(enabled) { + autoUpdater.autoDownload = enabled + }, + } + } + + const buildManualEngine = (): UpdateEngine => { + const fetchManifest = + deps.fetchManifest ?? + (async (url: string) => { + const response = await net.fetch(url) + return response.ok ? await response.text() : null + }) + let downloadUrl: string | null = null + + const doCheck = async () => { + setState({ status: 'checking', manual: true }) + try { + const feedUrl = feedUrlForOrigin(deps.appOrigin()) + const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null + if (!manifest || !version || !isNewerVersion(version, currentVersion)) { + setState({ status: 'idle', manual: true }) + return + } + // The feed rewrites manifest urls to absolute GitHub asset URLs; + // prefer the dmg for a human download. + const urls = Array.from(manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1]) + downloadUrl = + urls.find((url) => url.endsWith('.dmg')) ?? + urls.find((url) => url.endsWith('.zip')) ?? + urls[0] ?? + null + deps.events.record('update_check', { available: version, manual: true }) + setState({ status: 'available', version, manual: true }) + } catch (error) { + logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) + setState({ status: 'error', version: state.version, manual: true }) + } + } + + const openDownload = () => { + if (downloadUrl) { + deps.events.record('update_manual_download', { url: downloadUrl }) + void shell.openExternal(downloadUrl) + } + } + + return { + check: () => void doCheck(), + advance: openDownload, + install: openDownload, + setAutoDownload: () => {}, + } + } + + let engine: UpdateEngine | null = null + let pendingAutoDownload: boolean | null = null + + const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability + void canSelfUpdate() + .catch(() => true) + .then((capable) => { + engine = capable ? buildAutoEngine() : buildManualEngine() + if (!engine) { + return + } + if (pendingAutoDownload !== null) { + engine.setAutoDownload(pendingAutoDownload) + } + if (!capable) { + deps.events.record('update_manual_mode', {}) + } + const check = () => engine?.check() + setTimeout(check, INITIAL_CHECK_DELAY_MS) + setInterval(check, CHECK_INTERVAL_MS) + }) + + return { + setAutoDownload(enabled) { + if (engine) { + engine.setAutoDownload(enabled) + } else { + pendingAutoDownload = enabled + } + }, + getState: () => state, + check() { + if (!engine || state.status === 'checking' || state.status === 'downloading') { + return + } + if (state.status === 'available') { + engine.advance() + return + } + engine.check() + }, + install() { + if (!engine) { + return + } + if (state.status === 'ready') { + engine.install() + return + } + if (state.status === 'available' && state.manual) { + engine.advance() + } + }, + onState(callback) { + listeners.add(callback) + return () => { + listeners.delete(callback) + } + }, + } +} + +const INTERACTIVE_CHECK_TIMEOUT_MS = 30_000 + +/** + * Menu-triggered manual check with user-visible feedback. A thin dialog layer + * over the updater handle — it drives whichever pipeline initUpdater selected + * (electron-updater or the manual feed poll), so a shell that can't + * self-update gets a working "Download" dialog instead of a Squirrel error. + */ +export function checkForUpdatesInteractive( + deps: Pick & { handle: UpdaterHandle | null } +): void { + if (!app.isPackaged) { + void dialog.showMessageBox({ + type: 'info', + message: 'Updates are only available in packaged builds', + }) + return + } + const handle = deps.handle + if (!handle) { + return + } + deps.events.record('update_check', { manual: true }) + + const showDialog = (options: Electron.MessageBoxOptions) => { + const win = deps.getWindow() + return win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options) + } + + const settle = (state: DesktopUpdateState) => { + switch (state.status) { + case 'available': { + const label = state.version ? `Sim ${state.version} is available` : 'An update is available' + void showDialog({ + type: 'info', + buttons: ['Download', 'Later'], + defaultId: 0, + cancelId: 1, + message: label, + detail: state.manual + ? 'This build updates manually: the download opens in your browser, then replace the installed app.' + : 'The update downloads in the background and installs when you restart.', + }).then(({ response }) => { + if (response !== 0) { + return + } + if (state.manual) { + handle.install() + } else { + handle.check() + } + }) + return + } + case 'downloading': + void showDialog({ + type: 'info', + message: state.version ? `Downloading Sim ${state.version}…` : 'Downloading update…', + detail: 'You will be prompted to restart when it is ready.', + }) + return + case 'ready': + // The download pipeline already shows its own restart prompt. + return + case 'error': + void showDialog({ + type: 'error', + message: 'Could not check for updates', + detail: 'Something went wrong reaching the update server. Try again later.', + }) + return + default: + void showDialog({ type: 'info', message: 'Sim is up to date' }) + } + } + + const initial = handle.getState() + // Already mid-pipeline (or an update already found): report it directly + // instead of advancing the pipeline behind a "check" click. + if (initial.status !== 'idle' && initial.status !== 'checking' && initial.status !== 'error') { + settle(initial) + return + } + + let settled = false + const finish = (state: DesktopUpdateState) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + unsubscribe() + settle(state) + } + const unsubscribe = handle.onState((state) => { + if (state.status === 'checking') { + return + } + finish(state) + }) + const timeout = setTimeout(() => finish(handle.getState()), INTERACTIVE_CHECK_TIMEOUT_MS) + handle.check() +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts new file mode 100644 index 00000000000..97096cb5c77 --- /dev/null +++ b/apps/desktop/src/main/window.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { BrowserWindow } from 'electron' +import type { ConfigStore } from '@/main/config' +import type { EventRecorder } from '@/main/observability' +import { + backgroundColorFor, + createMainWindow, + createSecureWebPreferences, + resolvePermission, + sanitizeBounds, +} from '@/main/window' + +const APP = 'https://sim.ai' + +describe('resolvePermission', () => { + it('allows sanitized clipboard writes from the trusted origin', () => { + expect(resolvePermission('clipboard-sanitized-write', APP, APP)).toBe(true) + expect(resolvePermission('clipboard-sanitized-write', 'https://evil.example', APP)).toBe(false) + expect(resolvePermission('clipboard-sanitized-write', '', APP)).toBe(false) + }) + + it('allows clipboard reads from the trusted origin only, so terminal Paste works', () => { + expect(resolvePermission('clipboard-read', APP, APP)).toBe(true) + expect(resolvePermission('clipboard-read', 'https://evil.example', APP)).toBe(false) + expect(resolvePermission('clipboard-read', '', APP)).toBe(false) + }) + + it('default-denies everything else, including media and unknown future permissions', () => { + for (const permission of [ + 'media', + 'geolocation', + 'notifications', + 'camera', + 'midi', + 'pointerLock', + 'openExternal', + 'some-future-permission', + ]) { + expect(resolvePermission(permission, APP, APP)).toBe(false) + } + }) +}) + +describe('backgroundColorFor', () => { + it('matches the persisted web-app theme', () => { + expect(backgroundColorFor('dark', false)).toBe('#0c0c0c') + expect(backgroundColorFor('light', true)).toBe('#ffffff') + }) + + it('falls back to the system theme before first capture', () => { + expect(backgroundColorFor(undefined, true)).toBe('#0c0c0c') + expect(backgroundColorFor(undefined, false)).toBe('#ffffff') + }) +}) + +describe('sanitizeBounds', () => { + it('passes plausible bounds through', () => { + const bounds = { x: 20, y: 40, width: 1200, height: 800 } + expect(sanitizeBounds(bounds)).toEqual(bounds) + }) + + it('drops implausible or malformed bounds', () => { + expect(sanitizeBounds(undefined)).toBeUndefined() + expect(sanitizeBounds({ width: 10, height: 10 })).toBeUndefined() + expect(sanitizeBounds({ width: Number.NaN, height: 800 })).toBeUndefined() + }) + + it('drops only the position when coordinates are malformed', () => { + expect(sanitizeBounds({ x: Number.NaN, y: 0, width: 1200, height: 800 })).toEqual({ + width: 1200, + height: 800, + }) + }) +}) + +describe('createSecureWebPreferences', () => { + it('locks down the renderer', () => { + const prefs = createSecureWebPreferences('persist:sim', '/tmp/preload.cjs', true) + expect(prefs).toMatchObject({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: false, + partition: 'persist:sim', + preload: '/tmp/preload.cjs', + }) + }) + + it('enables DevTools only for unpackaged builds', () => { + expect(createSecureWebPreferences('persist:sim', '/p', false).devTools).toBe(true) + }) + + it('passes the shell version to the preload as an argv flag', () => { + expect(createSecureWebPreferences('persist:sim', '/p', true).additionalArguments).toEqual([ + '--sim-desktop-version=1.0.0', + ]) + }) +}) + +describe('createMainWindow', () => { + it('keeps the native macOS fullscreen titlebar blank', () => { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + + const win = createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + platform: 'darwin', + }) + + const MockBrowserWindow = BrowserWindow as typeof BrowserWindow & { + lastOptions?: Record + } + const windowEventCalls = vi.mocked(win.on).mock.calls as unknown as Array< + [string, (...args: unknown[]) => unknown] + > + + expect(MockBrowserWindow.lastOptions?.title).toBe('Sim') + // The overlay is what publishes `titlebar-area-*` to the page, so the web + // app can reserve the traffic-light lane from the platform rather than from + // pixels that shrink under page zoom while the OS-drawn lights do not. + expect(MockBrowserWindow.lastOptions).toMatchObject({ + titleBarStyle: 'hiddenInset', + titleBarOverlay: true, + trafficLightPosition: { x: 12, y: 12 }, + }) + + const pageTitleHandler = windowEventCalls.find( + ([event]) => event === 'page-title-updated' + )?.[1] as ((event: { preventDefault: () => void }) => void) | undefined + const enterFullscreenHandler = windowEventCalls.find( + ([event]) => event === 'enter-full-screen' + )?.[1] as (() => void) | undefined + const leaveFullscreenHandler = windowEventCalls.find( + ([event]) => event === 'leave-full-screen' + )?.[1] as (() => void) | undefined + + enterFullscreenHandler?.() + expect(win.setTitle).toHaveBeenLastCalledWith('') + + vi.mocked(win.isFullScreen).mockReturnValue(true) + const event = { preventDefault: vi.fn() } + pageTitleHandler?.(event) + + expect(pageTitleHandler).toBeTypeOf('function') + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(win.setTitle).toHaveBeenLastCalledWith('') + + leaveFullscreenHandler?.() + expect(win.setTitle).toHaveBeenLastCalledWith('Sim') + }) + + it('lets the OS cascade secondary windows instead of reusing the saved position', () => { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => ({ x: 40, y: 60, width: 1200, height: 800 })), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + + createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + restorePosition: false, + }) + + const MockBrowserWindow = BrowserWindow as typeof BrowserWindow & { + lastOptions?: Record + } + expect(MockBrowserWindow.lastOptions).toMatchObject({ width: 1200, height: 800 }) + expect(MockBrowserWindow.lastOptions?.x).toBeUndefined() + expect(MockBrowserWindow.lastOptions?.y).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts new file mode 100644 index 00000000000..e5bdfb979b7 --- /dev/null +++ b/apps/desktop/src/main/window.ts @@ -0,0 +1,352 @@ +import { createLogger } from '@sim/logger' +import type { Session, WebPreferences } from 'electron' +import { app, BrowserWindow, dialog, nativeTheme } from 'electron' +import { type ConfigStore, isSafeInternalPath, type WindowBounds } from '@/main/config' +import { isAppOrigin, isAuthSurfacePath } from '@/main/navigation' +import type { EventRecorder } from '@/main/observability' + +const logger = createLogger('DesktopWindow') + +const DARK_BACKGROUND = '#0c0c0c' +const LIGHT_BACKGROUND = '#ffffff' +const DEFAULT_WIDTH = 1360 +const DEFAULT_HEIGHT = 860 +const MIN_WIDTH = 800 +const MIN_HEIGHT = 600 +const WINDOW_TITLE = 'Sim' +const BOUNDS_SAVE_DELAY_MS = 400 +const ROUTE_SAVE_DELAY_MS = 500 + +const THEME_PROBE_SCRIPT = `(() => { + try { + return document.documentElement.classList.contains('dark') + } catch { + return null + } +})()` + +/** + * The hardened webPreferences shared by the main window and any child window. + * The preload injects nothing into the page; it only exposes a whitelisted + * IPC bridge. The shell version rides in as a preload argv flag so the web + * app can enforce its minimum shell version without an IPC round-trip. + */ +export function createSecureWebPreferences( + partition: string, + preloadPath: string, + isPackaged: boolean +): WebPreferences { + return { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: !isPackaged, + spellcheck: true, + partition, + preload: preloadPath, + additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], + } +} + +/** + * The permission matrix: clipboard access for the trusted app origin, + * default-deny for everything else including unknown future permissions + * (media/camera/microphone stay denied). + * + * Clipboard reads are what the terminal's Paste action runs on — xterm has no + * native paste target to fall back to, so a denied read is a Paste that fails. + * The grant is scoped to the app's own origin, which already reaches far more + * sensitive surfaces through the preload bridge, so it widens nothing that a + * compromise of that origin would not already own. + */ +export function resolvePermission( + permission: string, + requestingOrigin: string, + appOrigin: string +): boolean { + if (!requestingOrigin || requestingOrigin !== appOrigin) { + return false + } + return permission === 'clipboard-sanitized-write' || permission === 'clipboard-read' +} + +function originOf(raw: string): string { + try { + return new URL(raw).origin + } catch { + return '' + } +} + +/** + * Installs both permission handlers (request + check) on a session from the + * shared permission matrix. + */ +export function setupPermissionHandlers(session: Session, getAppOrigin: () => string): void { + session.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = details.requestingUrl || webContents?.getURL() || '' + callback(resolvePermission(permission, originOf(requestingUrl), getAppOrigin())) + }) + + session.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { + return resolvePermission(permission, originOf(requestingOrigin), getAppOrigin()) + }) +} + +/** + * Picks the pre-paint window background from the persisted web-app theme so + * dark-mode users never see a white flash before the remote page paints. + */ +export function backgroundColorFor( + theme: 'dark' | 'light' | undefined, + systemPrefersDark: boolean +): string { + if (theme === 'dark') { + return DARK_BACKGROUND + } + if (theme === 'light') { + return LIGHT_BACKGROUND + } + return systemPrefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND +} + +/** + * Drops persisted bounds that are malformed or implausibly small so a bad + * settings file can never produce an unusable window. + */ +export function sanitizeBounds(bounds: WindowBounds | undefined): WindowBounds | undefined { + if (!bounds) { + return undefined + } + const { x, y, width, height } = bounds + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return undefined + } + if (width < MIN_WIDTH || height < MIN_HEIGHT) { + return undefined + } + if ((x !== undefined && !Number.isFinite(x)) || (y !== undefined && !Number.isFinite(y))) { + return { width, height } + } + return bounds +} + +export interface CreateMainWindowDeps { + config: ConfigStore + events: EventRecorder + appOrigin: () => string + partition: string + preloadPath: string + isPackaged: boolean + onClosed: () => void + onFullScreenChange?: (isFullScreen: boolean) => void + /** + * Restores the persisted screen position for the first window. Secondary + * windows omit it so the OS can cascade them instead of stacking every + * window at the exact same coordinates. + */ + restorePosition?: boolean + /** Injectable for platform-specific window behavior tests. */ + platform?: NodeJS.Platform +} + +/** + * Creates the hardened main window: persisted bounds and zoom, theme-matched + * background, beforeunload passthrough, renderer crash/hang recovery, and + * last-route tracking for relaunch restore. + */ +export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { + const bounds = sanitizeBounds(deps.config.get('windowBounds')) + const restorePosition = deps.restorePosition ?? true + const platform = deps.platform ?? process.platform + const win = new BrowserWindow({ + title: WINDOW_TITLE, + width: bounds?.width ?? DEFAULT_WIDTH, + height: bounds?.height ?? DEFAULT_HEIGHT, + x: restorePosition ? bounds?.x : undefined, + y: restorePosition ? bounds?.y : undefined, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + // No separate title bar: the page renders full-bleed to the window's top + // edge with the traffic lights inset over it (Codex-style). + ...(platform === 'darwin' + ? { + titleBarStyle: 'hiddenInset' as const, + // Explicit so the published `titlebar-area-*` geometry is stable. + trafficLightPosition: { x: 12, y: 12 }, + // Publishes the traffic lights' geometry (81x38 DIP) as the + // `titlebar-area-*` CSS env vars, which Chromium rescales under page + // zoom so the reserved lane holds its physical size. + titleBarOverlay: true, + } + : {}), + show: false, + backgroundColor: backgroundColorFor( + deps.config.get('themeBackground'), + nativeTheme.shouldUseDarkColors + ), + webPreferences: createSecureWebPreferences(deps.partition, deps.preloadPath, deps.isPackaged), + }) + + win.once('ready-to-show', () => { + win.show() + }) + + let boundsTimer: NodeJS.Timeout | undefined + const persistBounds = () => { + clearTimeout(boundsTimer) + boundsTimer = setTimeout(() => { + if (win.isDestroyed() || win.isFullScreen() || win.isMaximized()) { + return + } + deps.config.set('windowBounds', win.getNormalBounds()) + }, BOUNDS_SAVE_DELAY_MS) + } + win.on('resize', persistBounds) + win.on('move', persistBounds) + win.on('enter-full-screen', () => { + if (platform === 'darwin') { + win.setTitle('') + } + deps.onFullScreenChange?.(true) + }) + win.on('leave-full-screen', () => { + if (platform === 'darwin') { + win.setTitle(WINDOW_TITLE) + } + deps.onFullScreenChange?.(false) + }) + win.on('page-title-updated', (event) => { + if (platform === 'darwin' && win.isFullScreen()) { + event.preventDefault() + win.setTitle('') + } + }) + + win.webContents.on('will-prevent-unload', (event) => { + const choice = dialog.showMessageBoxSync(win, { + type: 'question', + buttons: ['Leave', 'Stay'], + defaultId: 0, + cancelId: 1, + message: 'Leave Sim?', + detail: 'Changes you made may not be saved.', + }) + if (choice === 0) { + event.preventDefault() + } + }) + + win.webContents.on('render-process-gone', (_event, details) => { + if (details.reason === 'clean-exit') { + return + } + deps.events.record('renderer_gone', { + reason: details.reason, + exitCode: details.exitCode, + crashDumpDir: app.getPath('crashDumps'), + }) + setTimeout(() => { + if (win.isDestroyed()) { + return + } + void dialog + .showMessageBox(win, { + type: 'error', + buttons: ['Reload', 'Quit Sim'], + defaultId: 0, + cancelId: 0, + message: 'Sim encountered a problem', + detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', + }) + .then(({ response }) => { + if (win.isDestroyed()) { + return + } + if (response === 0) { + win.webContents.reload() + } else { + app.quit() + } + }) + }, 0) + }) + + let hangDialogOpen = false + win.webContents.on('unresponsive', () => { + if (hangDialogOpen || win.isDestroyed()) { + return + } + hangDialogOpen = true + deps.events.record('renderer_unresponsive') + void dialog + .showMessageBox(win, { + type: 'warning', + buttons: ['Wait', 'Reload'], + defaultId: 0, + cancelId: 0, + message: 'Sim isn’t responding', + detail: 'You can wait for it to recover or reload the page.', + }) + .then(({ response }) => { + hangDialogOpen = false + if (!win.isDestroyed() && response === 1) { + win.webContents.reload() + } + }) + }) + win.webContents.on('responsive', () => { + hangDialogOpen = false + }) + + let zoomRestored = false + win.webContents.on('did-finish-load', () => { + if (!zoomRestored) { + zoomRestored = true + const zoomLevel = deps.config.get('zoomLevel') + if (typeof zoomLevel === 'number' && Number.isFinite(zoomLevel)) { + win.webContents.setZoomLevel(zoomLevel) + } + } + const url = win.webContents.getURL() + if (isAppOrigin(url, deps.appOrigin())) { + void win.webContents + .executeJavaScript(THEME_PROBE_SCRIPT, true) + .then((isDark) => { + if (typeof isDark === 'boolean') { + deps.config.set('themeBackground', isDark ? 'dark' : 'light') + } + }) + .catch(() => {}) + } + }) + + let routeTimer: NodeJS.Timeout | undefined + const recordRoute = (url: string) => { + const origin = deps.appOrigin() + if (!isAppOrigin(url, origin)) { + return + } + const path = url.slice(origin.length) || '/' + if (!isSafeInternalPath(path) || isAuthSurfacePath(path)) { + return + } + clearTimeout(routeTimer) + routeTimer = setTimeout(() => { + if (!win.isDestroyed()) { + deps.config.set('lastRoute', path) + } + }, ROUTE_SAVE_DELAY_MS) + } + win.webContents.on('did-navigate', (_event, url) => recordRoute(url)) + win.webContents.on('did-navigate-in-page', (_event, url) => recordRoute(url)) + + win.on('closed', () => { + clearTimeout(routeTimer) + deps.onClosed() + }) + + return win +} diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts new file mode 100644 index 00000000000..70c21ead0ce --- /dev/null +++ b/apps/desktop/src/main/windows.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { WebContents } from 'electron' +import { shell } from 'electron' +import { attachWindowOpenPolicy, isPopupContents, registerPopupContents } from '@/main/windows' + +const APP = 'https://sim.ai' + +interface FakeContents { + setWindowOpenHandler: ReturnType + on: ReturnType + handler?: (details: { url: string; frameName: string }) => { action: string } +} + +function makeContents(): FakeContents { + const contents: FakeContents = { + setWindowOpenHandler: vi.fn((handler) => { + contents.handler = handler + }), + on: vi.fn(), + } + return contents +} + +describe('attachWindowOpenPolicy', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear() + }) + + function setup() { + const contents = makeContents() + const openAppWindow = vi.fn() + attachWindowOpenPolicy(contents as unknown as WebContents, { + appOrigin: () => APP, + openAppWindow, + allowHttpLocalhost: false, + }) + return { contents, openAppWindow } + } + + it('allows the MCP OAuth popup', () => { + const { contents } = setup() + const result = contents.handler?.({ + url: 'https://mcp.example/authorize', + frameName: 'mcp-oauth-s1', + }) + expect(result).toEqual({ action: 'allow' }) + }) + + it('allows blank children for the blank-then-assign pattern', () => { + const { contents } = setup() + expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toEqual({ action: 'allow' }) + }) + + it('opens internal new-window requests as full Sim windows', () => { + const { contents, openAppWindow } = setup() + const result = contents.handler?.({ url: `${APP}/workspace/ws1/w/wf1`, frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(openAppWindow).toHaveBeenCalledWith(`${APP}/workspace/ws1/w/wf1`) + }) + + it('routes external opens to the system browser', () => { + const { contents } = setup() + const result = contents.handler?.({ url: 'https://docs.sim.ai/blocks', frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.sim.ai/blocks') + }) + + it('denies non-web schemes without opening anything', () => { + const { contents, openAppWindow } = setup() + const result = contents.handler?.({ url: 'javascript:alert(1)', frameName: '' }) + expect(result).toEqual({ action: 'deny' }) + expect(shell.openExternal).not.toHaveBeenCalled() + expect(openAppWindow).not.toHaveBeenCalled() + }) + + it('registers guards on created child windows', () => { + const { contents } = setup() + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + expect(didCreateWindow).toBeDefined() + }) +}) + +describe('popup registry', () => { + it('tracks popup contents identity', () => { + const contents = {} as WebContents + expect(isPopupContents(contents)).toBe(false) + registerPopupContents(contents) + expect(isPopupContents(contents)).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts new file mode 100644 index 00000000000..44a16332b86 --- /dev/null +++ b/apps/desktop/src/main/windows.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import type { BrowserWindow, WebContents } from 'electron' +import { + classifyBlankChildNavigation, + classifyWindowOpen, + openExternalSafe, +} from '@/main/navigation' +import { scrubUrl } from '@/main/observability' + +const logger = createLogger('DesktopWindows') + +const popupContents = new WeakSet() + +/** + * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) + * so the navigation classifier can apply the more permissive popup policy. + */ +export function registerPopupContents(contents: WebContents): void { + popupContents.add(contents) +} + +export function isPopupContents(contents: WebContents): boolean { + return popupContents.has(contents) +} + +export interface WindowPolicyDeps { + appOrigin: () => string + openAppWindow: (url: string) => void + allowHttpLocalhost: boolean +} + +/** + * Applies the window.open routing policy to a WebContents. Internal requests + * open as full, independently navigable Sim windows; MCP OAuth popups and + * blank-then-assign children are allowed in the same partition so + * window.opener/postMessage keep working; everything else goes to the system + * browser. + */ +export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicyDeps): void { + contents.setWindowOpenHandler((details) => { + const action = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) + switch (action) { + case 'popup-mcp': + case 'popup-blank': + return { action: 'allow' } + case 'popup-internal': { + deps.openAppWindow(details.url) + return { action: 'deny' } + } + case 'external': + void openExternalSafe(details.url, deps.allowHttpLocalhost) + return { action: 'deny' } + default: + logger.warn('Denied window.open', { url: scrubUrl(details.url) }) + return { action: 'deny' } + } + }) + + contents.on('did-create-window', (child, details) => { + registerPopupContents(child.webContents) + attachWindowOpenPolicy(child.webContents, deps) + const kind = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) + if (kind === 'popup-blank') { + attachBlankChildGuards(child, deps) + } + }) +} + +/** + * Routes the first real navigation of an about:blank child: same-origin URLs + * open in a full Sim window, external URLs open in the system browser, and + * the child closes either way. + */ +function attachBlankChildGuards(child: BrowserWindow, deps: WindowPolicyDeps): void { + child.webContents.on('will-navigate', (event, url) => { + const action = classifyBlankChildNavigation(url, deps.appOrigin()) + if (action === 'ignore') { + return + } + event.preventDefault() + if (action === 'internal') { + deps.openAppWindow(url) + } else if (action === 'external') { + void openExternalSafe(url, deps.allowHttpLocalhost) + } + if (!child.isDestroyed()) { + child.close() + } + }) +} diff --git a/apps/desktop/src/preload/browser/index.ts b/apps/desktop/src/preload/browser/index.ts new file mode 100644 index 00000000000..a968f917177 --- /dev/null +++ b/apps/desktop/src/preload/browser/index.ts @@ -0,0 +1,176 @@ +import { ipcRenderer } from 'electron' + +/** + * The preload for pages inside the built-in browser. + * + * It exists for exactly one job: notice that the page has a login form, tell + * the main process only that fact, and — when the main process says the user + * chose an account — put the credential into the two fields it already found. + * + * It deliberately exposes nothing. There is no `contextBridge` call here, so + * the page cannot see or call any of this, and the fill can only be initiated + * by the main process. What travels out is a page origin and two booleans; + * field names, values, and page content never do. + * + * Runs in the top-level frame only (subframe preloads are not enabled), so + * cross-origin iframe login flows are out of scope for now — filling those + * needs its own threat review. + */ + +const FORM_STATE_CHANNEL = 'browser-credentials:form-state' +const FILL_CHANNEL = 'browser-credentials:fill' +const RESCAN_DEBOUNCE_MS = 250 + +interface DetectedForm { + username: HTMLInputElement | null + /** Absent on an identifier-first step, which asks for the email alone. */ + password: HTMLInputElement | null +} + +/** Held only in this isolated world; never serialized to main or the page. */ +let detected: DetectedForm | null = null +let lastReported = '' +let rescanTimer: ReturnType | null = null + +function isFillable(field: HTMLInputElement): boolean { + if (field.disabled || field.readOnly) return false + const rect = field.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 +} + +/** + * Matches the same definition the agent guards use: a reveal toggle flips a + * password field to `type="text"` without making it any less secret, and the + * autocomplete token is the page's own declaration either way. + */ +function isPasswordField(field: HTMLInputElement): boolean { + if (String(field.type || '').toLowerCase() === 'password') return true + const hint = String(field.getAttribute('autocomplete') || '').toLowerCase() + return hint === 'current-password' || hint === 'new-password' +} + +function findPasswordField(): HTMLInputElement | null { + for (const field of document.querySelectorAll('input')) { + if (isPasswordField(field) && isFillable(field)) return field + } + return null +} + +/** + * The username field for a password field: the nearest preceding text-like + * input in the same form, which is how essentially every login form is built. + */ +function findUsernameField(password: HTMLInputElement): HTMLInputElement | null { + const scope: ParentNode = password.form ?? document + const candidates: HTMLInputElement[] = [] + for (const field of scope.querySelectorAll('input')) { + const type = String(field.type || 'text').toLowerCase() + if (['text', 'email', 'tel', 'username'].includes(type) && isFillable(field)) { + candidates.push(field) + } + } + const preceding = candidates.filter( + (field) => (password.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_PRECEDING) !== 0 + ) + return preceding.at(-1) ?? candidates[0] ?? null +} + +/** + * The identifier field of a sign-in step that has no password field yet. + * + * Two-step sign-in — email, Continue, then the password on the next screen — + * is now the norm at Google, Okta, and most workplace tools. Requiring a + * password field would mean the key icon never appears on the step where the + * user actually needs it. Only the page's own declaration counts here: an + * `autocomplete` token naming a username or email, or an email input. That is + * narrow enough to leave newsletter boxes and search fields alone. + */ +function findIdentifierField(): HTMLInputElement | null { + for (const field of document.querySelectorAll('input')) { + if (!isFillable(field)) continue + const hint = String(field.getAttribute('autocomplete') || '').toLowerCase() + if (hint === 'username' || hint === 'email') return field + if (String(field.type || '').toLowerCase() === 'email') return field + } + return null +} + +function detectForm(): DetectedForm | null { + const password = findPasswordField() + if (password) return { password, username: findUsernameField(password) } + const username = findIdentifierField() + return username ? { password: null, username } : null +} + +function reportFormState(): void { + detected = detectForm() + + const origin = window.location.origin + const hasPasswordField = detected?.password != null + const fingerprint = `${origin}|${detected !== null}|${hasPasswordField}` + if (fingerprint === lastReported) return + lastReported = fingerprint + ipcRenderer.send(FORM_STATE_CHANNEL, { + origin, + hasLoginForm: detected !== null, + hasPasswordField, + }) +} + +function scheduleRescan(): void { + if (rescanTimer !== null) clearTimeout(rescanTimer) + rescanTimer = setTimeout(() => { + rescanTimer = null + reportFormState() + }, RESCAN_DEBOUNCE_MS) +} + +/** + * Writes through the native value setter so frameworks that track their own + * input state (React and friends) see the change instead of reverting it on + * the next render. + */ +function setFieldValue(field: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (setter) { + setter.call(field, value) + } else { + field.value = value + } + field.dispatchEvent(new Event('input', { bubbles: true })) + field.dispatchEvent(new Event('change', { bubbles: true })) +} + +ipcRenderer.on( + FILL_CHANNEL, + (_event, payload: { origin: string; username: string; password?: string }) => { + // Last line of defence against a navigation between the user's choice and + // this message arriving: the main process binds the fill to an origin, and + // the live document has to still agree. + if (!payload || payload.origin !== window.location.origin || !detected) return + if (detected.username && payload.username) { + setFieldValue(detected.username, payload.username) + } + if (detected.password && payload.password) { + setFieldValue(detected.password, payload.password) + } + // Never submitted. Autofill and submission stay separate so the user can + // confirm the site and the account before anything is sent. Focus lands on + // whichever field the user still has to deal with. + ;(detected.password ?? detected.username)?.focus() + } +) + +document.addEventListener('DOMContentLoaded', reportFormState) +window.addEventListener('load', reportFormState) +window.addEventListener('pageshow', reportFormState) + +// Login forms are routinely rendered after first paint, behind a "Sign in" +// toggle, or swapped in by a single-page router — a one-shot scan would miss +// most of them. +new MutationObserver(scheduleRescan).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['type', 'autocomplete', 'disabled', 'readonly'], +}) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 00000000000..82bc2f79f55 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,352 @@ +import type { + BrowserDataKind, + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSessionsState, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserPanelSnapshot, + BrowserTabsState, + BrowserTheme, + BrowserToolName, + BrowserToolResponse, +} from '@sim/browser-protocol' +import type { + BrowserChromeImportResult, + BrowserCredentialConflictPolicy, + BrowserCredentialMetadata, + BrowserFillAvailability, + BrowserImportProfile, + BrowserImportResult, + BrowserPasswordImportResult, + BrowserSiteInfo, + DesktopCommand, + DesktopNotificationPayload, + DesktopOAuthConnectResult, + DesktopOAuthConnectScope, + DesktopPreferenceKey, + DesktopPreferences, + DesktopUpdateState, + DesktopWindowState, + LocalFilesystemRequest, + LocalFilesystemResponse, + SimDesktopApi, +} from '@sim/desktop-bridge' +import { + TERMINAL_TOOL_NAME, + type TerminalCommandEvent, + type TerminalOperation, + type TerminalStartOptions, + type TerminalTabsState, + type TerminalToolArgs, + type TerminalToolResponse, +} from '@sim/terminal-protocol' +import { contextBridge, ipcRenderer } from 'electron' + +const VERSION_ARG_PREFIX = '--sim-desktop-version=' + +/** + * The shell version injected by the main process as a preload argv flag (see + * createSecureWebPreferences). Read synchronously so the web app's minimum + * shell version gate has it at first paint. + */ +function shellVersion(): string | undefined { + const arg = process.argv.find((value) => value.startsWith(VERSION_ARG_PREFIX)) + const version = arg?.slice(VERSION_ARG_PREFIX.length) + return version || undefined +} + +/** + * The narrow bridge exposed to pages. Every channel is validated and gated in + * the main process — nothing here grants page code any privilege by itself. + */ +const api: SimDesktopApi = { + ...(shellVersion() ? { version: shellVersion() } : {}), + openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url), + beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise => + ipcRenderer.invoke('desktop:oauth-connect', providerId, scope), + onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => { + const listener = (_event: unknown, result: DesktopOAuthConnectResult) => callback(result) + ipcRenderer.on('desktop:oauth-connect-complete', listener) + return () => { + ipcRenderer.removeListener('desktop:oauth-connect-complete', listener) + } + }, + offlineRetry: (): void => { + ipcRenderer.send('offline:retry') + }, + localFilesystem: (request: LocalFilesystemRequest): Promise => + ipcRenderer.invoke('desktop:local-filesystem', request), + onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { + const listener = (_event: unknown, command: DesktopCommand) => callback(command) + ipcRenderer.on('desktop:command', listener) + return () => { + ipcRenderer.removeListener('desktop:command', listener) + } + }, + windowState: { + getState: (): Promise => ipcRenderer.invoke('desktop:window-state:get'), + onStateChange: (callback: (state: DesktopWindowState) => void): (() => void) => { + const listener = (_event: unknown, state: DesktopWindowState) => callback(state) + ipcRenderer.on('desktop:window-state:changed', listener) + return () => { + ipcRenderer.removeListener('desktop:window-state:changed', listener) + } + }, + }, + settings: { + getPreferences: (): Promise => ipcRenderer.invoke('desktop:settings:get'), + setPreference: (key: DesktopPreferenceKey, value: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', key, value), + notify: (payload: DesktopNotificationPayload): Promise => + ipcRenderer.invoke('desktop:settings:notify', payload), + setTrayEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'trayEnabled', enabled), + setBrowserEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'browserEnabled', enabled), + setTerminalEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set', 'terminalEnabled', enabled), + }, + updates: { + getState: (): Promise => ipcRenderer.invoke('desktop:updates:get-state'), + check: (): void => { + ipcRenderer.send('desktop:updates:check') + }, + install: (): void => { + ipcRenderer.send('desktop:updates:install') + }, + onState: (callback: (state: DesktopUpdateState) => void): (() => void) => { + const listener = (_event: unknown, state: DesktopUpdateState) => callback(state) + ipcRenderer.on('desktop:updates:state', listener) + return () => { + ipcRenderer.removeListener('desktop:updates:state', listener) + } + }, + }, + browserAgent: { + executeTool: ( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise => + ipcRenderer.invoke('browser-agent:execute-tool', toolCallId, tool, params), + panelAction: (action: BrowserPanelAction): void => { + ipcRenderer.send('browser-agent:panel-action', action) + }, + setTabPinned: (tabId: string, pinned: boolean): void => { + ipcRenderer.send('browser-agent:set-tab-pinned', tabId, pinned) + }, + reorderTab: (tabId: string, targetIndex: number): void => { + ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex) + }, + setPanelBounds: ( + bounds: BrowserPanelBounds | null, + anchor?: BrowserPanelAnchor | null + ): void => { + ipcRenderer.send('browser-agent:set-panel-bounds', bounds, anchor ?? null) + }, + setPanelFocused: (focused: boolean): void => { + ipcRenderer.send('browser-agent:set-panel-focused', focused) + }, + setPanelOccluded: (occluded: boolean): void => { + ipcRenderer.send('browser-agent:set-panel-occluded', occluded) + }, + setTheme: (theme: BrowserTheme): void => { + ipcRenderer.send('browser-agent:set-theme', theme) + }, + onFocusOmnibox: (callback: (mode: BrowserOmniboxFocusMode) => void): (() => void) => { + const listener = (_event: unknown, mode: BrowserOmniboxFocusMode) => callback(mode) + ipcRenderer.on('browser-agent:focus-omnibox', listener) + return () => { + ipcRenderer.removeListener('browser-agent:focus-omnibox', listener) + } + }, + find: (request: BrowserFindRequest): void => { + ipcRenderer.send('browser-agent:find', request) + }, + stopFind: (focusPage?: boolean): void => { + ipcRenderer.send('browser-agent:stop-find', focusPage === true) + }, + onOpenFind: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('browser-agent:open-find', listener) + return () => { + ipcRenderer.removeListener('browser-agent:open-find', listener) + } + }, + onCloseFind: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('browser-agent:close-find', listener) + return () => { + ipcRenderer.removeListener('browser-agent:close-find', listener) + } + }, + onFindResult: (callback: (result: BrowserFindResult) => void): (() => void) => { + const listener = (_event: unknown, result: BrowserFindResult) => callback(result) + ipcRenderer.on('browser-agent:find-result', listener) + return () => { + ipcRenderer.removeListener('browser-agent:find-result', listener) + } + }, + onPanelSnapshot: (callback: (snapshot: BrowserPanelSnapshot) => void): (() => void) => { + const listener = (_event: unknown, snapshot: BrowserPanelSnapshot) => callback(snapshot) + ipcRenderer.on('browser-agent:panel-snapshot', listener) + return () => { + ipcRenderer.removeListener('browser-agent:panel-snapshot', listener) + } + }, + onPageState: (callback: (state: BrowserPageState) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserPageState) => callback(state) + ipcRenderer.on('browser-agent:page-state', listener) + return () => { + ipcRenderer.removeListener('browser-agent:page-state', listener) + } + }, + getTabsState: (): Promise => + ipcRenderer.invoke('browser-agent:get-tabs-state'), + getKnownSessions: (): Promise => + ipcRenderer.invoke('browser-agent:get-known-sessions'), + clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise => + ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds), + onTabsState: (callback: (state: BrowserTabsState) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserTabsState) => callback(state) + ipcRenderer.on('browser-agent:tabs-state', listener) + return () => { + ipcRenderer.removeListener('browser-agent:tabs-state', listener) + } + }, + onSessionStatus: (callback: (alive: boolean) => void): (() => void) => { + const listener = (_event: unknown, alive: boolean) => callback(alive) + ipcRenderer.on('browser-agent:session-status', listener) + return () => { + ipcRenderer.removeListener('browser-agent:session-status', listener) + } + }, + }, + // Omitted entirely off macOS, so the web app's feature detection reflects + // whether an import can actually run rather than only whether the shell is + // new enough. Chrome's App-Bound Encryption on Windows is deliberately not + // worked around. + ...(process.platform === 'darwin' + ? { + browserImport: { + listChromeProfiles: (): Promise => + ipcRenderer.invoke('browser-import:list-profiles'), + listSites: (): Promise => ipcRenderer.invoke('browser-import:sites'), + importChromeCookies: (profileId?: string): Promise => + ipcRenderer.invoke('browser-import:cookies', profileId), + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-import:all', profileId, policy), + }, + } + : {}), + // Note what is absent: there is no method that returns a password, and none + // that names a credential to fill. Filling is completed by a native menu in + // the main process, so the strongest thing a compromised renderer can do + // here is ask for that menu to open. + browserCredentials: { + isAvailable: (): Promise => ipcRenderer.invoke('browser-credentials:available'), + list: (): Promise => + ipcRenderer.invoke('browser-credentials:list'), + forget: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:forget', id), + forgetAll: (): Promise => + ipcRenderer.invoke('browser-credentials:forget-all'), + reveal: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:reveal', id), + copy: (id: string): Promise => ipcRenderer.invoke('browser-credentials:copy', id), + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-credentials:import', profileId, policy), + showChooser: (anchor: { x: number; y: number }): Promise => + ipcRenderer.invoke('browser-credentials:show-chooser', anchor), + onFillAvailability: (callback: (state: BrowserFillAvailability) => void): (() => void) => { + const listener = (_event: unknown, state: BrowserFillAvailability) => callback(state) + ipcRenderer.on('browser-credentials:fill-availability', listener) + return () => { + ipcRenderer.removeListener('browser-credentials:fill-availability', listener) + } + }, + }, + terminal: { + start: async (options: TerminalStartOptions): Promise => { + const response = (await ipcRenderer.invoke('terminal:start', options)) as + | { ok: true; tabs: TerminalTabsState } + | { ok: false; code?: string; error?: string } + if (!response?.ok) { + const failure = new Error(response?.error ?? 'Could not open a terminal.') + failure.name = response?.code ?? 'SPAWN_FAILED' + throw failure + } + return response.tabs + }, + // The tool name rides alongside the call because the main process + // re-fetches the server's authorized arguments by tool call id and uses + // those, not these — what the renderer passes is only a request. + executeTool: ( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise => + ipcRenderer.invoke('terminal:execute-tool', toolCallId, TERMINAL_TOOL_NAME, { + operation, + args, + }), + write: (terminalId: string, data: string): void => { + ipcRenderer.send('terminal:write', terminalId, data) + }, + resize: (terminalId: string, cols: number, rows: number): void => { + ipcRenderer.send('terminal:resize', terminalId, cols, rows) + }, + openTerminal: (cwd?: string): Promise => + ipcRenderer.invoke('terminal:open', cwd), + switchTerminal: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:switch', terminalId), + closeTerminal: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:close', terminalId), + getTabs: (): Promise => ipcRenderer.invoke('terminal:get-tabs'), + dispose: (): void => { + ipcRenderer.send('terminal:dispose') + }, + onData: (callback: (terminalId: string, data: string) => void): (() => void) => { + const listener = (_event: unknown, terminalId: string, data: string) => + callback(terminalId, data) + ipcRenderer.on('terminal:data', listener) + return () => { + ipcRenderer.removeListener('terminal:data', listener) + } + }, + getScrollback: (terminalId: string): Promise => + ipcRenderer.invoke('terminal:scrollback', terminalId), + setFocused: (focused: boolean): void => { + ipcRenderer.send('terminal:focused', focused) + }, + finishHandoff: (terminalId: string): void => { + ipcRenderer.send('terminal:handoff-done', terminalId) + }, + onTabs: (callback: (state: TerminalTabsState) => void): (() => void) => { + const listener = (_event: unknown, state: TerminalTabsState) => callback(state) + ipcRenderer.on('terminal:tabs', listener) + return () => { + ipcRenderer.removeListener('terminal:tabs', listener) + } + }, + onCommand: (callback: (event: TerminalCommandEvent) => void): (() => void) => { + const listener = (_event: unknown, payload: TerminalCommandEvent) => callback(payload) + ipcRenderer.on('terminal:command', listener) + return () => { + ipcRenderer.removeListener('terminal:command', listener) + } + }, + }, +} + +contextBridge.exposeInMainWorld('simDesktop', api) diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts new file mode 100644 index 00000000000..385aba2910b --- /dev/null +++ b/apps/desktop/src/test/electron-mock.ts @@ -0,0 +1,242 @@ +import { vi } from 'vitest' + +/** + * Shared electron module mock for unit tests. The real electron package + * cannot be imported under Node (it resolves to a binary path), so every test + * file that touches an electron-importing module mocks it with: + * + * vi.mock('electron', () => import('@/test/electron-mock')) + */ + +export const app = { + name: 'Sim', + isPackaged: false, + getVersion: vi.fn(() => '1.0.0'), + getName: vi.fn(() => 'Sim'), + setName: vi.fn(), + getPath: vi.fn(() => '/tmp/sim-desktop-test'), + getAppPath: vi.fn(() => '/tmp/sim-desktop-test/app'), + isReady: vi.fn(() => true), + on: vi.fn(), + once: vi.fn(), + quit: vi.fn(), + focus: vi.fn(), + enableSandbox: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + whenReady: vi.fn(() => Promise.resolve()), + startAccessingSecurityScopedResource: vi.fn(() => vi.fn()), + getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), + setLoginItemSettings: vi.fn(), + dock: { downloadFinished: vi.fn() }, +} + +export const crashReporter = { + start: vi.fn(), +} + +export const shell = { + openExternal: vi.fn(() => Promise.resolve()), + showItemInFolder: vi.fn(), +} + +export const dialog = { + showMessageBox: vi.fn(() => Promise.resolve({ response: 0, checkboxChecked: false })), + showMessageBoxSync: vi.fn(() => 0), + showOpenDialog: vi.fn(() => Promise.resolve({ canceled: true, filePaths: [] })), +} + +export const safeStorage = { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')), +} + +export const clipboard = { + writeText: vi.fn(), +} + +export const nativeTheme = { + shouldUseDarkColors: false, + on: vi.fn(), +} + +export const Menu = { + buildFromTemplate: vi.fn((template: unknown[]) => ({ popup: vi.fn(), items: template })), + setApplicationMenu: vi.fn(), +} + +export const net = { + isOnline: vi.fn(() => true), + fetch: vi.fn(), +} + +export const session = { + fromPartition: vi.fn(), +} + +export const ipcMain = { + on: vi.fn(), + handle: vi.fn(), +} + +export const nativeImage = { + createFromPath: vi.fn(() => ({ + isEmpty: vi.fn(() => false), + setTemplateImage: vi.fn(), + getSize: vi.fn(() => ({ width: 32, height: 16 })), + toBitmap: vi.fn(() => Buffer.alloc(64 * 32 * 4)), + })), + createEmpty: vi.fn(() => ({ + isEmpty: vi.fn(() => true), + setTemplateImage: vi.fn(), + })), + createFromBitmap: vi.fn((_buffer: unknown, options: { width: number; height: number }) => ({ + isEmpty: vi.fn(() => false), + setTemplateImage: vi.fn(), + getSize: vi.fn(() => ({ width: options.width, height: options.height })), + })), +} + +export class Tray { + static instances: Tray[] = [] + constructor(public image: unknown) { + Tray.instances.push(this) + } + setToolTip = vi.fn() + setContextMenu = vi.fn() + popUpContextMenu = vi.fn() + on = vi.fn() + destroy = vi.fn() + isDestroyed = vi.fn(() => false) +} + +export class Notification { + static instances: Notification[] = [] + static isSupported = vi.fn(() => true) + constructor(public options: Record) { + Notification.instances.push(this) + } + on = vi.fn() + show = vi.fn() + close = vi.fn() +} + +function createWebContentsMock() { + return { + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + getURL: vi.fn(() => 'https://example.com/'), + getTitle: vi.fn(() => 'Example'), + loadURL: vi.fn(() => Promise.resolve()), + reload: vi.fn(), + focus: vi.fn(), + isFocused: vi.fn(() => false), + close: vi.fn(), + isDestroyed: vi.fn(() => false), + isLoading: vi.fn(() => false), + findInPage: vi.fn(() => 1), + stopFindInPage: vi.fn(), + setBackgroundThrottling: vi.fn(), + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + copy: vi.fn(), + paste: vi.fn(), + capturePage: vi.fn(() => { + const image = { + isEmpty: vi.fn(() => false), + toDataURL: vi.fn(() => 'data:image/png;base64,c2lt'), + getSize: vi.fn(() => ({ width: 1600, height: 1000 })), + resize: vi.fn(() => image), + toJPEG: vi.fn(() => Buffer.from('sim')), + } + return Promise.resolve(image) + }), + executeJavaScript: vi.fn(() => Promise.resolve(undefined)), + setWindowOpenHandler: vi.fn(), + navigationHistory: { + canGoBack: vi.fn(() => false), + canGoForward: vi.fn(() => false), + goBack: vi.fn(), + goForward: vi.fn(), + }, + debugger: { + attach: vi.fn(), + detach: vi.fn(), + isAttached: vi.fn(() => false), + sendCommand: vi.fn(() => Promise.resolve({})), + on: vi.fn(), + }, + session: { + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + webRequest: { onBeforeRequest: vi.fn() }, + on: vi.fn(), + }, + } +} + +export class WebContentsView { + webContents = createWebContentsMock() + setBackgroundColor = vi.fn() + setVisible = vi.fn() + setBounds = vi.fn() +} + +export class BrowserWindow { + static fromWebContents = vi.fn(() => null) + static getFocusedWindow = vi.fn(() => null) + /** Constructor tracking for tests (the class itself is not a vi.fn mock). */ + static instances: BrowserWindow[] = [] + static lastOptions: Record | undefined + constructor(options?: Record) { + BrowserWindow.instances.push(this) + BrowserWindow.lastOptions = options + } + webContents = { + on: vi.fn(), + getURL: vi.fn(() => ''), + loadURL: vi.fn(() => Promise.resolve()), + reload: vi.fn(), + setZoomLevel: vi.fn(), + getZoomLevel: vi.fn(() => 0), + getZoomFactor: vi.fn(() => 1), + executeJavaScript: vi.fn(() => Promise.resolve(true)), + focus: vi.fn(), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + isDevToolsOpened: vi.fn(() => false), + session: { addWordToSpellCheckerDictionary: vi.fn() }, + } + on = vi.fn() + once = vi.fn() + removeListener = vi.fn() + isDestroyed = vi.fn(() => false) + isMinimized = vi.fn(() => false) + isFullScreen = vi.fn(() => false) + isMaximized = vi.fn(() => false) + isVisible = vi.fn(() => false) + isFocused = vi.fn(() => false) + getNormalBounds = vi.fn(() => ({ x: 0, y: 0, width: 1360, height: 860 })) + getBounds = vi.fn(() => ({ x: 1292, y: 41, width: 420, height: 150 })) + setBounds = vi.fn() + loadURL = vi.fn(() => Promise.resolve()) + loadFile = vi.fn(() => Promise.resolve()) + focus = vi.fn() + show = vi.fn() + showInactive = vi.fn() + hide = vi.fn() + close = vi.fn() + destroy = vi.fn() + restore = vi.fn() + setPosition = vi.fn() + setTitle = vi.fn() + setVisibleOnAllWorkspaces = vi.fn() + setAlwaysOnTop = vi.fn() + getSize = vi.fn(() => [1180, 850]) + getContentSize = vi.fn(() => [1180, 850]) + contentView = { + addChildView: vi.fn(), + removeChildView: vi.fn(), + } +} diff --git a/apps/desktop/static/dock-icon-dev.png b/apps/desktop/static/dock-icon-dev.png new file mode 100644 index 00000000000..43d743599fd Binary files /dev/null and b/apps/desktop/static/dock-icon-dev.png differ diff --git a/apps/desktop/static/dock-icon-local.png b/apps/desktop/static/dock-icon-local.png new file mode 100644 index 00000000000..13bab7cbeee Binary files /dev/null and b/apps/desktop/static/dock-icon-local.png differ diff --git a/apps/desktop/static/dock-icon-staging.png b/apps/desktop/static/dock-icon-staging.png new file mode 100644 index 00000000000..e7f451fb34d Binary files /dev/null and b/apps/desktop/static/dock-icon-staging.png differ diff --git a/apps/desktop/static/dock-icon.png b/apps/desktop/static/dock-icon.png new file mode 100644 index 00000000000..fd20d6de3f2 Binary files /dev/null and b/apps/desktop/static/dock-icon.png differ diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html new file mode 100644 index 00000000000..c0a5a93be86 --- /dev/null +++ b/apps/desktop/static/offline.html @@ -0,0 +1,175 @@ + + + + + + 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/tray/simTemplate.png b/apps/desktop/static/tray/simTemplate.png new file mode 100644 index 00000000000..8793c33a081 Binary files /dev/null and b/apps/desktop/static/tray/simTemplate.png differ diff --git a/apps/desktop/static/tray/simTemplate@2x.png b/apps/desktop/static/tray/simTemplate@2x.png new file mode 100644 index 00000000000..545cf26a65e Binary files /dev/null and b/apps/desktop/static/tray/simTemplate@2x.png differ 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/docs/package.json b/apps/docs/package.json index da9850a4834..bf756cdcfce 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,7 +8,7 @@ "build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", "postinstall": "fumadocs-mdx", - "type-check": "tsc --noEmit", + "type-check": "fumadocs-mdx && tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx index 82dbf7ef7cc..57c83fa152b 100644 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -1,5 +1,16 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { DesktopTitleBarController } from '@/app/_shell/desktop-title-bar' import { AuthShell } from '@/app/(auth)/components' export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { - return {children} + const isLogin = usePathname() === '/login' + + return ( + <> + {isLogin && } + {children} + + ) } diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index 91f4d324d6c..d4dd4f06521 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' import Link from 'next/link' import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' @@ -7,6 +8,8 @@ interface AuthShellProps { children: ReactNode /** Optional element pinned to the bottom of the shell (e.g. the support footer). */ footer?: ReactNode + /** Reserve the native macOS title-bar lane for the desktop login route. */ + reserveDesktopTitleBar?: boolean } /** @@ -19,9 +22,17 @@ interface AuthShellProps { * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The * single content column is centered and capped for a calm single-form layout. */ -export function AuthShell({ children, footer }: AuthShellProps) { +export function AuthShell({ children, footer, reserveDesktopTitleBar = false }: AuthShellProps) { return ( -
+
+ {reserveDesktopTitleBar && ( +
+ )}
+ {children} + + ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ + children, + label, + action, + }: { + children: ReactNode + label: string + action?: ReactNode + }) => ( +
+ {action} + {children} +
+ ), + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view', + () => ({ + PasswordsView: ({ + credentials, + onBack, + }: { + credentials: BrowserCredentialMetadata[] + onBack: () => void + }) => ( +
+ {`${credentials.length} saved`} + +
+ ), + }) +) + +// The modal's own picker logic is covered by import-modal.test.tsx; here it is +// reduced to "open?" plus a way to confirm the chosen profile. +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal', + () => ({ + ImportModal: ({ + open, + profiles, + pending, + onImport, + }: { + open: boolean + profiles: BrowserImportProfile[] + pending: boolean + onImport: (profile: BrowserImportProfile) => void + }) => + open ? ( +
+ {`${profiles.length} profiles`} + +
+ ) : null, + }) +) + +import { Browser } from '@/app/workspace/[workspaceId]/settings/components/browser/browser' + +const PROFILES: BrowserImportProfile[] = [ + { + id: 'chrome:Default', + label: 'Chrome', + browserId: 'chrome', + browserLabel: 'Chrome', + profileLabel: 'Default', + }, + { + id: 'arc:Profile 2', + label: 'Arc · Microtrades', + browserId: 'arc', + browserLabel: 'Arc', + profileLabel: 'Microtrades', + }, +] + +const CREDENTIALS: BrowserCredentialMetadata[] = [ + { + id: 'c1', + origin: 'https://example.com', + username: 'ada@example.com', + createdAt: '', + updatedAt: '', + source: 'chrome', + }, +] + +const IMPORTED_BOTH: BrowserChromeImportResult = { + cookies: { cookiesImported: 12, cookiesSkipped: 3 }, + passwords: { passwordsAdded: 4, passwordsUpdated: 1, passwordsSkipped: 2 }, +} + +interface BridgeOverrides { + browserEnabled?: boolean + profiles?: BrowserImportProfile[] + importResult?: BrowserChromeImportResult + listProfilesFails?: boolean + vaultAvailable?: boolean +} + +function createBridge({ + browserEnabled = true, + profiles = PROFILES, + importResult = IMPORTED_BOTH, + listProfilesFails = false, + vaultAvailable = true, +}: BridgeOverrides = {}) { + return { + settings: { + getPreferences: vi.fn(async () => ({ + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled, + })), + setBrowserEnabled: vi.fn(), + }, + browserAgent: { + getKnownSessions: vi.fn(async () => ({ sessions: [] })), + clearBrowsingData: vi.fn(async () => ({ sessions: [{ hostname: 'left.test' }] })), + }, + browserImport: { + listChromeProfiles: vi.fn(async (): Promise => { + if (listProfilesFails) throw new Error('unreadable') + return profiles + }), + importFromChrome: vi.fn(async (): Promise => importResult), + }, + browserCredentials: { + isAvailable: vi.fn(async () => vaultAvailable), + list: vi.fn(async () => CREDENTIALS), + forget: vi.fn(async () => []), + }, + } +} + +let container: HTMLDivElement +let root: Root + +async function render() { + await act(async () => { + root.render() + }) +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +const importDialog = () => container.querySelector('[aria-label="Import from your browser"]') + +describe('Browser settings', () => { + it('keeps only the agent-browser toggle on the page itself', async () => { + // Passwords and browsing data each own a page; this one is just the switch. + await render() + + expect(container.querySelector('section[aria-label="Agent browser"]')).not.toBeNull() + expect(container.querySelector('section[aria-label="Saved passwords"]')).toBeNull() + }) + + it('reaches both sub-pages from the header', async () => { + await render() + + expect([...container.querySelectorAll('header button')].map((b) => b.textContent)).toEqual([ + 'Passwords', + 'Clear all', + ]) + }) + + it('lists each data type inline as a standard settings row', async () => { + await render() + + for (const label of ['Cookies', 'Site data', 'Cached images and files']) { + expect(container.querySelector(`section[aria-label="${label}"]`)).not.toBeNull() + } + }) + + it.each([ + ['Delete cookies', ['cookies']], + ['Delete site data', ['site-data']], + ['Delete cached images and files', ['cache']], + ])('%s clears only that kind, after confirmation', async (action, kinds) => { + const bridge = createBridge() + mockBridge.current = bridge + await render() + + await click(buttonLabelled(action)) + expect(bridge.browserAgent.clearBrowsingData).not.toHaveBeenCalled() + + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(bridge.browserAgent.clearBrowsingData).toHaveBeenCalledWith(kinds) + }) + + it('clears every kind from the header action', async () => { + const bridge = createBridge() + mockBridge.current = bridge + await render() + + await click(buttonLabelled('Clear all')) + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(bridge.browserAgent.clearBrowsingData).toHaveBeenCalledWith([ + 'cookies', + 'site-data', + 'cache', + ]) + }) + + it('spells out the consequence and spares saved passwords in the confirmation', async () => { + await render() + + await click(buttonLabelled('Delete cookies')) + + const dialog = container.querySelector('[role="dialog"]')?.textContent ?? '' + expect(dialog).toContain('sign the browser out of every site') + expect(dialog).toContain('saved passwords are not affected') + }) + + it('surfaces a failure instead of implying data was deleted', async () => { + const bridge = createBridge() + bridge.browserAgent.clearBrowsingData = vi.fn(async () => { + throw new Error('locked') + }) as typeof bridge.browserAgent.clearBrowsingData + mockBridge.current = bridge + await render() + + await click(buttonLabelled('Delete cookies')) + const confirm = [...container.querySelectorAll('[role="dialog"] button')].at(-1) + await click(confirm as HTMLButtonElement) + + expect(mockToast.error).toHaveBeenCalledWith('Could not delete browsing data') + }) + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockBridge.current = createBridge() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('hides the Passwords action on shells without the credential surface', async () => { + mockBridge.current = { ...createBridge(), browserCredentials: undefined } + await render() + + expect( + [...container.querySelectorAll('header button')].map((b) => b.textContent) + ).not.toContain('Passwords') + }) + + it('opens the manager from the header rather than inlining it', async () => { + await render() + expect(container.querySelector('[aria-label="Passwords view"]')).toBeNull() + + await click(buttonLabelled('Passwords')) + + expect(container.querySelector('[aria-label="Passwords view"]')?.textContent).toContain( + '1 saved' + ) + }) + + it('returns to the browser page from the manager', async () => { + await render() + await click(buttonLabelled('Passwords')) + + await click(buttonLabelled('Back')) + + expect(container.querySelector('[aria-label="Passwords view"]')).toBeNull() + expect(container.querySelector('section[aria-label="Agent browser"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx new file mode 100644 index 00000000000..1327c3fdc51 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { BROWSER_DATA_KINDS, type BrowserDataKind } from '@sim/browser-protocol' +import type { BrowserCredentialMetadata, DesktopPreferences } from '@sim/desktop-bridge' +import { Chip, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn' +import { useParams, useRouter } from 'next/navigation' +import { getDesktopBridge, setDesktopPreferencesSnapshot } from '@/lib/desktop' +import { PasswordsView } from '@/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +interface DataRow { + kind: BrowserDataKind + label: string + action: string + /** + * What the user actually loses. Not shown in the row — the action names + * itself — but spelled out in the confirmation, where it matters. + */ + consequence: string +} + +/** + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear. + */ +const DATA_ROWS: DataRow[] = [ + { + kind: 'cookies', + label: 'Cookies', + action: 'Delete cookies', + consequence: 'sign the browser out of every site it is currently signed into', + }, + { + kind: 'site-data', + label: 'Site data', + action: 'Delete site data', + consequence: 'erase the data sites have stored locally, such as drafts and preferences', + }, + { + kind: 'cache', + label: 'Cached images and files', + action: 'Delete cached images and files', + consequence: 'free up space and make sites load more slowly the first time', + }, +] + +export function Browser() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + const [preferences, setPreferences] = useState(null) + const [siteCount, setSiteCount] = useState(0) + const [togglePending, setTogglePending] = useState(false) + const [credentials, setCredentials] = useState([]) + const [showPasswords, setShowPasswords] = useState(false) + const [confirming, setConfirming] = useState(null) + const [clearPending, setClearPending] = useState(false) + + const refreshSiteCount = useCallback(async () => { + const known = await getDesktopBridge()?.browserAgent?.getKnownSessions?.() + setSiteCount(known?.sessions.length ?? 0) + }, []) + + const refreshCredentials = useCallback(async () => { + const bridge = getDesktopBridge()?.browserCredentials + if (!bridge) return + const available = await bridge.isAvailable().catch(() => false) + setCredentials(available ? await bridge.list().catch(() => []) : []) + }, []) + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.browserAgent || !bridge.settings) { + router.replace(`/workspace/${workspaceId}/settings/general`) + return + } + void Promise.all([bridge.settings.getPreferences(), refreshSiteCount(), refreshCredentials()]) + .then(([next]) => setPreferences(next)) + .catch(() => toast.error('Could not load browser settings')) + }, [refreshCredentials, refreshSiteCount, router, workspaceId]) + + const setEnabled = useCallback(async (enabled: boolean) => { + const setBrowserEnabled = getDesktopBridge()?.settings?.setBrowserEnabled + if (!setBrowserEnabled) return + setTogglePending(true) + try { + const next = await setBrowserEnabled(enabled) + setPreferences(next) + setDesktopPreferencesSnapshot(next) + } catch { + toast.error('Could not update browser settings') + } finally { + setTogglePending(false) + } + }, []) + + const clear = useCallback(async (kinds: readonly BrowserDataKind[]) => { + const clearBrowsingData = getDesktopBridge()?.browserAgent?.clearBrowsingData + if (!clearBrowsingData) return + setClearPending(true) + try { + setSiteCount((await clearBrowsingData(kinds)).sessions.length) + setConfirming(null) + } catch { + toast.error('Could not delete browsing data') + } finally { + setClearPending(false) + } + }, []) + + if (!preferences) { + return null + } + + if (showPasswords) { + return ( + setShowPasswords(false)} + onImported={() => Promise.all([refreshSiteCount(), refreshCredentials()])} + /> + ) + } + + const enabled = preferences.browserEnabled ?? true + const canClearData = typeof getDesktopBridge()?.browserAgent?.clearBrowsingData === 'function' + const canManagePasswords = Boolean(getDesktopBridge()?.browserCredentials) + const target = confirming === 'all' ? null : confirming + + return ( + <> + setShowPasswords(true) }] + : []), + ...(canClearData + ? [ + { + text: 'Clear all', + variant: 'destructive' as const, + onSelect: () => setConfirming('all'), + disabled: clearPending, + }, + ] + : []), + ]} + > + +
+
+ +

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

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

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

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

{children}

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

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

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

{children}

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

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

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

No pending invitations.

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

{invitationLabel(inv)}

+

+ {invitationSubLabel(inv)} +

+
+ void handleAccept(inv)} + className='flex-shrink-0' + > + Accept + + void handleDecline(inv)} + aria-label={`Decline invitation to ${invitationLabel(inv)}`} + className='flex-shrink-0' + > + Decline + +
+ )) + )} +
+ onOpenChange(false)} + primaryAction={{ label: 'Done', onClick: () => onOpenChange(false) }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index e648bc997df..574953a523f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -20,6 +20,7 @@ import { } from '@sim/emcn' import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { MoreHorizontal, Search } from 'lucide-react' import { useActiveOrganization } from '@/lib/auth/auth-client' import { isBillingEnabled } from '@/lib/core/config/env-flags' @@ -30,7 +31,14 @@ import { type CreateWorkspaceTarget, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' -import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { ViewInvitationsMenuItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item' +import { ViewInvitationsModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal' +import { invitationKeys } from '@/hooks/queries/invitations' +import { + type Workspace, + type WorkspaceCreationPolicy, + workspaceKeys, +} from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -136,6 +144,7 @@ function WorkspaceHeaderImpl({ }: WorkspaceHeaderProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) + const [isViewInvitationsOpen, setIsViewInvitationsOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false) @@ -227,6 +236,7 @@ function WorkspaceHeaderImpl({ const { data: viewerActiveOrganization } = useActiveOrganization() const { navigateToSettings } = useSettingsNavigation() + const queryClient = useQueryClient() const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null @@ -429,6 +439,14 @@ function WorkspaceHeaderImpl({ ) { return } + if (open) { + // Opening the switcher is the "user is looking" moment: refetch + // stale server state so a workspace the user was auto-added to, + // or a fresh pending invitation, appears without a page refresh + // (these are app-wide queries with no focus refetch on the web). + void queryClient.refetchQueries({ queryKey: workspaceKeys.lists(), stale: true }) + void queryClient.refetchQueries({ queryKey: invitationKeys.mine(), stale: true }) + } setIsWorkspaceMenuOpen(open) if (open && showSearch) { requestAnimationFrame(() => searchInputRef.current?.focus()) @@ -739,6 +757,12 @@ function WorkspaceHeaderImpl({ Invite teammates + { + setIsWorkspaceMenuOpen(false) + setIsViewInvitationsOpen(true) + }} + /> + setIsDeleteModalOpen(false)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index e7a22926be7..383851add3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1279,7 +1279,11 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { onClick={handleSidebarClick} >
-
+
+
- +
diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 65a8678ccfa..b71ed1cdc32 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -27,6 +27,7 @@ import type { } from '@sim/realtime-protocol/events' import { generateId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' +import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import type { Socket } from 'socket.io-client' import { getSocketUrl } from '@/lib/core/utils/urls' @@ -38,6 +39,7 @@ import { isSocketWorkflowVisible, resolveSocketWorkflowTarget, } from '@/app/workspace/providers/socket-join-target' +import { refreshSessionQuery } from '@/hooks/queries/session' import { useOperationQueueStore } from '@/stores/operation-queue/store' import type { SubblockUpdateEmit, @@ -171,6 +173,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const joinRetryTimeoutRef = useRef | null>(null) const authRetryAttemptsRef = useRef(0) const authRetryTimeoutRef = useRef | null>(null) + const sessionRejectedRef = useRef(false) + const queryClient = useQueryClient() const params = useParams() const urlWorkflowId = params?.workflowId as string | undefined @@ -386,6 +390,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { reconnectionDelayMax: 30000, timeout: 10000, auth: async (cb) => { + // Reset per attempt so the flag describes THIS handshake only — + // otherwise one early 401 would still be set when a later, unrelated + // denial exhausts the retry budget. + sessionRejectedRef.current = false try { const freshToken = await generateSocketToken() cb({ token: freshToken }) @@ -393,6 +401,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { logger.error('Failed to generate fresh token for connection:', error) if (error instanceof Error && error.message === 'Authentication required') { // True auth failure - pass null token, server will reject with "Authentication required" + sessionRejectedRef.current = true cb({ token: null }) } // For server errors, don't call cb - connection will timeout and Socket.IO will retry @@ -467,12 +476,28 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.connect() }, delayMs) } else { - logger.error( - 'Socket connection denied after max retries - stopping. User may need to refresh/re-login.', - { message: error.message } - ) + logger.error('Socket connection denied after max retries - stopping.', { + message: error.message, + }) setAuthFailed(true) setIsReconnecting(false) + + // The handshake that exhausted the budget was refused because the + // token mint reported no session, yet the app is still rendering as + // signed in — the classic symptom of Better Auth's session cookie + // cache vouching for a row that no longer exists. The mint reads the + // database directly, so it is the one caller that sees the divergence. + // Settle the canonical session query from server truth (that read + // bypasses the cookie cache too): a genuinely dead session resolves + // to null and hands off to SessionExpired, while a transient + // failure just refreshes the cache and leaves the user alone. + if (sessionRejectedRef.current) { + void refreshSessionQuery(queryClient).catch((refreshError) => { + logger.error('Failed to re-read the session after socket auth failure', { + error: refreshError, + }) + }) + } } }) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index d7f0aae3643..ed8dce6e25b 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -11,7 +11,7 @@ import { describeError, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task } from '@trigger.dev/sdk' import { Cron } from 'croner' -import { and, eq, isNull, type SQL, sql } from 'drizzle-orm' +import { and, eq, isNull, ne, type SQL, sql } from 'drizzle-orm' import { assertBillingAttributionSnapshot, BILLING_ATTRIBUTION_HEADER, @@ -141,7 +141,7 @@ async function applyScheduleUpdate( updates: WorkflowScheduleUpdate, requestId: string, context: string, - options: { expectedLastQueuedAt?: Date | null } = {} + options: { expectedLastQueuedAt?: Date | null; allowCompleted?: boolean } = {} ): Promise { try { const claimGuard = @@ -151,11 +151,27 @@ async function applyScheduleUpdate( ? isNull(workflowSchedule.lastQueuedAt) : eq(workflowSchedule.lastQueuedAt, options.expectedLastQueuedAt) + // A run that completes itself mid-execution (complete_scheduled_task, or a + // manage_scheduled_task update) sets status='completed'. The post-run + // bookkeeping that follows would otherwise write status='active' and a + // fresh nextRunAt straight back over it — the claim guard does not catch + // this, because completing the job does not touch lastQueuedAt. Terminal + // means terminal: only callers that explicitly opt in may move a completed + // row. + const notCompletedGuard = options.allowCompleted + ? undefined + : ne(workflowSchedule.status, 'completed') + const updatedRows = await db .update(workflowSchedule) .set(updates) .where( - and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt), claimGuard) + and( + eq(workflowSchedule.id, scheduleId), + isNull(workflowSchedule.archivedAt), + claimGuard, + notCompletedGuard + ) ) .returning({ id: workflowSchedule.id }) @@ -1365,7 +1381,9 @@ export async function executeJobInline(payload: JobExecutionPayload) { }, requestId, `Error updating job ${payload.scheduleId} after completion`, - { expectedLastQueuedAt: now } + // The tool already set status='completed'; this is bookkeeping on a + // deliberately terminal row, so it opts past the not-completed guard. + { expectedLastQueuedAt: now, allowCompleted: true } ) return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0846cab14da..0dc9a894ff9 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -27,6 +27,9 @@ describe('settings navigation boundaries', () => { it('preserves the order of all four settings catalogs', () => { expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([ 'general', + 'desktop', + 'browser', + 'terminal', 'access-control', 'audit-logs', 'forks', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index b5073b23ab0..08f12d531b5 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' import { ClipboardList, Clock, + Cursor, Database, HexSimple, Key, @@ -9,6 +10,7 @@ import { Lock, LogIn, Palette, + PanelLeft, Send, Server, Settings, @@ -89,6 +91,9 @@ export interface SettingsNavigationItem
{ export type UnifiedSettingsSection = | 'general' + | 'desktop' + | 'browser' + | 'terminal' | 'secrets' | 'access-control' | 'custom-blocks' @@ -117,9 +122,17 @@ export type UnifiedNavigationSection = | 'subscription' | 'tools' | 'system' + | 'desktop' | 'enterprise' | 'superuser' +/** + * A bridge surface the desktop shell must expose for a section to be worth + * showing. Gated on the surface, never on the user's device toggle — the + * Browser and Terminal pages are where that toggle is flipped back on. + */ +export type DesktopSettingsSurface = 'settings' | 'browser' | 'terminal' + export interface UnifiedSettingsNavigationItem { id: UnifiedSettingsSection label: string @@ -134,6 +147,7 @@ export interface UnifiedSettingsNavigationItem { selfHostedOverride?: boolean requiresSuperUser?: boolean requiresAdminRole?: boolean + requiresDesktopSurface?: DesktopSettingsSurface allowNonOrgAdmin?: boolean showWhenLocked?: boolean hideForEnterprise?: boolean @@ -353,6 +367,36 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfhost: { id: 'general', group: 'account', order: 0 }, }, }, + { + label: 'Desktop', + icon: PanelLeft, + unified: { + id: 'desktop', + description: 'Manage notifications, startup, local folders, and updates.', + group: 'desktop', + requiresDesktopSurface: 'settings', + }, + }, + { + label: 'Browser', + icon: Cursor, + unified: { + id: 'browser', + description: 'Control the browser Chat drives and the data it keeps.', + group: 'desktop', + requiresDesktopSurface: 'browser', + }, + }, + { + label: 'Terminal', + icon: TerminalWindow, + unified: { + id: 'terminal', + description: 'Control the shells Chat runs commands in.', + group: 'desktop', + requiresDesktopSurface: 'terminal', + }, + }, { label: 'Access control', icon: ShieldCheck, diff --git a/apps/sim/components/ui/select.tsx b/apps/sim/components/ui/select.tsx index a0c04317b14..3c22d4a2978 100644 --- a/apps/sim/components/ui/select.tsx +++ b/apps/sim/components/ui/select.tsx @@ -80,6 +80,7 @@ const SelectContent = React.forwardRef< )} position={position} {...props} + data-native-surface-overlay='' > ): boolean { return allowedExtensions.has(getExtension(key)) } -/** - * Returns true when the host is a loopback address for which plain `http://` is - * tolerated (local MinIO development on a self-hosted deployment). Any other - * host must use `https://`. This is only an early check — the SSRF boundary is - * enforced at request time by {@link secureFetchWithRetry}, which blocks - * loopback/private targets on hosted Sim regardless of what this parser accepts. - */ -function isLoopbackHost(host: string): boolean { - const bare = host.replace(/^\[|\]$/g, '') - return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1' -} - /** * Parses and validates a custom S3-compatible endpoint string. * @@ -173,7 +162,9 @@ function parseEndpoint(raw: string): S3Endpoint { const host = url.hostname if (!host) throw new Error('Endpoint is missing a host') - if (scheme === 'http' && !(isLoopbackHost(host) && !isHosted)) { + // Plain http is tolerated only for loopback on self-host (local MinIO); the + // real SSRF boundary is enforced at request time by secureFetchWithRetry. + if (scheme === 'http' && !(isLoopbackHostname(host) && !isHosted)) { throw new Error( 'Plain http:// endpoints are only allowed for localhost on self-hosted deployments — use https:// otherwise' ) diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 67146168fc1..0fee6c19a0a 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -92,6 +92,10 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { }, enabled: Boolean(credentialId) && enabled, staleTime: WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME, + // The credential-detail form seeds editable name/description fields from + // this data, so a background focus refetch during an edit could clobber + // an unsaved draft. Off the desktop focus-refetch default; no-op on web. + refetchOnWindowFocus: false, }) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 7c25a09ccde..29e74661ab1 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -33,6 +33,10 @@ export function usePersonalEnvironment() { queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, + // Pinned off (not inheriting the desktop QueryClient default): the secrets + // manager seeds an editable form from this data, so a background focus + // refetch during a concurrent edit would drop the user's unsaved rows. + refetchOnWindowFocus: false, }) } @@ -49,6 +53,9 @@ export function useWorkspaceEnvironment( enabled: !!workspaceId, staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, placeholderData: keepPreviousData, + // See usePersonalEnvironment: seeds an editable form, so a focus refetch + // during a concurrent workspace-env edit must not clobber unsaved rows. + refetchOnWindowFocus: false, ...options, }) } diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 26faf6ebfb7..d6eeb3a89f4 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -37,6 +37,8 @@ export interface GeneralSettings { errorNotificationsEnabled: boolean snapToGridSize: number showActionBar: boolean + /** Copilot tool ids the user picked "always allow" for. */ + copilotAutoAllowedTools: string[] /** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */ timezone: string | null } @@ -57,6 +59,7 @@ export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettin errorNotificationsEnabled: data.errorNotificationsEnabled, snapToGridSize: data.snapToGridSize, showActionBar: data.showActionBar, + copilotAutoAllowedTools: data.copilotAutoAllowedTools ?? [], timezone: data.timezone ?? null, } } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 410bf83ff04..472e82a6444 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -2,16 +2,22 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { + acceptInvitationContract, type BatchInvitationResult as BatchInvitationResultContract, batchWorkspaceInvitationsContract, cancelInvitationContract, + type InvitationDetails, + listMyInvitationsContract, listWorkspaceInvitationsContract, type PendingInvitationRow, + rejectInvitationContract, removeWorkspaceMemberContract, resendInvitationContract, } from '@/lib/api/contracts/invitations' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { organizationKeys } from '@/hooks/queries/organization' +import { refreshSessionQuery } from '@/hooks/queries/session' +import { subscriptionKeys } from '@/hooks/queries/subscription' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { workspaceKeys } from '@/hooks/queries/workspace' @@ -19,6 +25,7 @@ export const invitationKeys = { all: ['invitations'] as const, lists: () => [...invitationKeys.all, 'list'] as const, list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, + mine: () => [...invitationKeys.all, 'mine'] as const, } export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 @@ -68,6 +75,75 @@ export function usePendingInvitations(workspaceId: string | undefined) { }) } +export const MY_INVITATIONS_STALE_TIME = 30 * 1000 + +async function fetchMyPendingInvitations(signal?: AbortSignal): Promise { + const data = await requestJson(listMyInvitationsContract, { signal }) + return data.invitations +} + +/** + * Pending invitations addressed to the signed-in account, for the workspace + * switcher's Invitations section. The switcher menu-item mounts this on + * dropdown open (so it fetches then); the modal passes `enabled: open` so it + * does not fetch on every app load for the majority of users who have none. + */ +export function useMyPendingInvitations(enabled = true) { + return useQuery({ + queryKey: invitationKeys.mine(), + queryFn: ({ signal }) => fetchMyPendingInvitations(signal), + enabled, + staleTime: MY_INVITATIONS_STALE_TIME, + }) +} + +/** + * Accepts one of the session user's pending invitations in-app. No token — + * acceptance is bound to the session email, which is exactly what makes this + * path immune to the wrong-browser-account problem of the email link. + * + * Invalidations mirror the email-link accept path (`use-oauth-return` / + * `invite.tsx`): accepting an org invite can convert the plan, reconcile + * seats, sync usage, and set the active organization server-side, so the + * workspace list, org, credentials, subscription/usage, AND the session must + * all refresh — otherwise billing widgets and the create-workspace target + * (which reads the active org) stay stale until a reload. + */ +export function useAcceptMyInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ invitationId }: { invitationId: string }) => + requestJson(acceptInvitationContract, { params: { id: invitationId }, body: {} }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + queryClient.invalidateQueries({ queryKey: organizationKeys.all }) + queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + void refreshSessionQuery(queryClient) + }, + // Refresh the list on failure too, so a row that failed terminally + // (expired / already-processed since the list loaded) drops instead of + // lingering as a re-clickable dead row. + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + }, + }) +} + +/** Declines one of the session user's pending invitations in-app. */ +export function useDeclineMyInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ invitationId }: { invitationId: string }) => + requestJson(rejectInvitationContract, { params: { id: invitationId }, body: {} }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + }, + }) +} + type BatchSendInvitationsParams = ContractBodyInput & { organizationId?: string | null } diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index f7fbffe9176..f0be5614ba7 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/hostnames' import { getErrorMessage } from '@sim/utils/errors' import { keepPreviousData, @@ -26,7 +27,6 @@ import { testMcpServerConnectionContract, updateMcpServerContract, } from '@/lib/api/contracts/mcp' -import { isLoopbackHostname } from '@/lib/core/utils/urls' import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared' import type { McpAuthType, diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index 7ec177502f1..66124cf1e47 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -10,6 +10,7 @@ import { type OAuthConnection, } from '@/lib/api/contracts/oauth-connections' import { client } from '@/lib/auth/auth-client' +import { getDesktopBridge } from '@/lib/desktop' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsQuery') @@ -155,6 +156,21 @@ export function useConnectOAuthService() { return { success: true } } + // Desktop app: OAuth cannot run in the embedded window (Google/Microsoft + // block embedded user agents, and better-auth binds the flow's state to + // the initiating browser's cookies), so the whole flow is handed to the + // system browser and returns via the app's loopback. Completion arrives + // through onOAuthConnectComplete (see useDesktopOAuthConnectListener), + // which refreshes caches and shows the connected toast. + const desktopBridge = getDesktopBridge() + if (desktopBridge?.beginOAuthConnect) { + const opened = await desktopBridge.beginOAuthConnect(providerId) + if (!opened) { + throw new Error('Could not open your browser to connect this account.') + } + return { success: true } + } + await client.oauth2.link({ providerId, callbackURL, diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 58e6fbb2c47..def9e736bdd 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -91,6 +91,11 @@ export function useWorkspaceSchedules(workspaceId?: string, options?: { enabled? enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: SCHEDULE_LIST_STALE_TIME, placeholderData: keepPreviousData, + // Pinned off (not inheriting the QueryClient default, which is on in the + // desktop app): a background refetch regenerates occurrence ids, which + // would close an open scheduled-task modal and drop its draft. See the + // taskById note in scheduled-tasks/hooks/use-scheduled-tasks.ts. + refetchOnWindowFocus: false, }) } diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index 686a2447c7b..5cb0e57c232 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -53,15 +53,20 @@ export const IMPERSONATION_REFETCH_INTERVAL = 60 * 1000 * token, startup network partition) surfaces immediately rather than retrying a * request that won't succeed. * - * While the session is an impersonation session, the query polls and refetches - * on focus (overriding the global `refetchOnWindowFocus: false`) so an expiry — - * including one slept through with the laptop closed — settles the query to - * `null` and surfaces the impersonation-expired recovery screen. Those - * refetches also bypass Better Auth's cookie cache: it can otherwise keep - * vouching for a session that was expired or revoked server-side, and the - * expiry detection shouldn't depend on the cache's own TTL details. - * Impersonation sessions are short-lived and admin-only, so none of these - * overrides affect normal sessions. + * Every session refetches on focus (overriding the global + * `refetchOnWindowFocus: false`) so a session that expired or was revoked while + * the app sat mounted — the long-lived desktop window, a browser tab left open + * for weeks, a laptop slept through the session's 30-day lifetime — settles the + * query to `null` and surfaces {@link SessionExpired} instead of leaving an SPA + * that silently 401s every request. Returning to the window is exactly the + * moment that needs re-checking, and `SESSION_STALE_TIME` throttles it to at + * most one read per 5 minutes of focus changes. + * + * Impersonation sessions additionally poll, and bypass Better Auth's cookie + * cache: it can otherwise keep vouching for a session that was expired or + * revoked server-side, and these sessions are short-lived enough that the + * cache's own TTL would outlive them. Normal sessions accept that lag — a + * revocation surfaces once the cache expires. */ export function useSessionQuery() { const queryClient = useQueryClient() @@ -75,6 +80,6 @@ export function useSessionQuery() { retry: false, refetchInterval: (query) => query.state.data?.session?.impersonatedBy ? IMPERSONATION_REFETCH_INTERVAL : false, - refetchOnWindowFocus: (query) => Boolean(query.state.data?.session?.impersonatedBy), + refetchOnWindowFocus: true, }) } diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index db46900feba..3bf98b2dce6 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -102,6 +102,11 @@ export function useWorkflowStates( queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal), select: mapWorkflowState, staleTime: WORKFLOW_STATE_STALE_TIME, + // Read-only preview consumer that fans out one full workflow envelope + // per id. Left off the desktop focus-refetch default so returning to a + // table with many workflow columns doesn't fire N heavy envelope + // fetches at once. No-op on the web (default is already false). + refetchOnWindowFocus: false as const, })), }) const map = new Map() diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 08680c83ec6..ee0b1f6ff31 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { toast } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract } from '@/lib/api/contracts' @@ -11,6 +12,9 @@ import { type OAuthReturnContext, readOAuthReturnContext, } from '@/lib/credentials/client-state' +import { getDesktopBridge } from '@/lib/desktop' +import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' @@ -145,3 +149,49 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { })() }, [knowledgeBaseId]) } + +/** + * Desktop-app counterpart of the post-OAuth routers above. In the desktop + * app the whole OAuth flow runs in the system browser (see + * useConnectOAuthService), so the app never navigates: completion arrives as + * a bridge push when the browser bounces the desktop's loopback. The app is + * already refocused by then — this refreshes the credential caches and shows + * the same connected toast the web flow gets. Mounted once per workspace; a + * no-op outside the desktop app. + */ +export function useDesktopOAuthConnectListener() { + const queryClient = useQueryClient() + + useEffect(() => { + const bridge = getDesktopBridge() + if (!bridge?.onOAuthConnectComplete) return + + return bridge.onOAuthConnectComplete((result) => { + void queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) + void queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + + // The app stays open across interleaved connect flows, so an abandoned + // modal-connect can leave a stale context that would attach to a later + // (e.g. chip) completion and show the wrong provider's message. Discard + // anything older than the same window the web routers use, mirroring + // their freshness check. + const rawCtx = readOAuthReturnContext() + if (rawCtx) consumeOAuthReturnContext() + const ctx = rawCtx && Date.now() - rawCtx.requestedAt <= CONTEXT_MAX_AGE_MS ? rawCtx : null + + if (!result.ok) { + toast.error('The account connection didn’t finish. Try connecting again.') + return + } + if (ctx) { + void (async () => { + const message = await resolveOAuthMessage(ctx) + toast.success(message) + dispatchCredentialUpdate(ctx) + })() + return + } + toast.success('Credential connected successfully.') + }) + }, [queryClient]) +} diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index a8a675daff1..e7136c78dc7 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -12,6 +12,7 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, COPILOT_BILLING_PROTOCOL_VALUES, } from '@/lib/copilot/generated/billing-protocol-v1' +import { PERSISTED_RESOURCE_TYPES } from '@/lib/copilot/resources/types' export const copilotApiKeySchema = z.object({ id: z.string(), @@ -58,6 +59,30 @@ export const copilotConfirmBodySchema = z.object({ }) export type CopilotConfirmBody = z.input +export const copilotToolPermissionDecisionSchema = z.enum([ + 'allow', + 'allow_chat', + 'always_allow', + 'skip', +]) + +/** + * Decisions arrive as a batch so "Allow all" on a turn that gated several + * tools at once is a single round trip rather than one request per card. + */ +export const copilotToolPermissionBodySchema = z.object({ + decisions: z + .array( + z.object({ + toolCallId: z.string().min(1, 'Tool call ID is required'), + decision: copilotToolPermissionDecisionSchema, + }) + ) + .min(1, 'At least one decision is required') + .max(50, 'Too many decisions in one request'), +}) +export type CopilotToolPermissionBody = z.input + export const createWorkflowCopilotChatBodySchema = z.object({ workspaceId: z.string().min(1), workflowId: z.string().min(1), @@ -93,15 +118,7 @@ export const renameCopilotChatBodySchema = z.object({ }) export type RenameCopilotChatBody = z.input -const copilotResourceTypeSchema = z.enum([ - 'table', - 'file', - 'workflow', - 'knowledgebase', - 'folder', - 'scheduledtask', - 'log', -]) +const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES) export const addCopilotChatResourceBodySchema = z.object({ chatId: z.string(), @@ -258,7 +275,6 @@ const copilotPersistedMessageSchema = z export const updateCopilotMessagesBodySchema = z.object({ chatId: z.string(), messages: z.array(copilotPersistedMessageSchema), - planArtifact: z.string().nullable().optional(), config: z .object({ mode: z.string().optional(), @@ -411,7 +427,6 @@ const copilotChatGetChatSchema = z model: z.string().nullable(), messages: z.array(z.unknown()), messageCount: z.number(), - planArtifact: z.unknown().nullable(), config: z.unknown().nullable(), activeStreamId: z.string().nullable().optional(), resources: z.array(z.unknown()).optional(), @@ -622,6 +637,27 @@ export const copilotConfirmContract = defineRouteContract({ }, }) +export const copilotToolPermissionContract = defineRouteContract({ + method: 'POST', + path: '/api/copilot/tool-permission', + body: copilotToolPermissionBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + // Echoes the decision that actually stuck per tool call, which can differ + // from what was sent when another tab answered the same prompt first. + results: z.array( + z.object({ + toolCallId: z.string(), + decision: copilotToolPermissionDecisionSchema, + applied: z.boolean(), + }) + ), + }), + }, +}) + export const copilotModelsContract = defineRouteContract({ method: 'GET', path: '/api/copilot/models', diff --git a/apps/sim/lib/api/contracts/desktop-auth.ts b/apps/sim/lib/api/contracts/desktop-auth.ts new file mode 100644 index 00000000000..13c0e615202 --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-auth.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const desktopHandoffTokenResponseSchema = z.object({ + token: z.string().min(1, 'token cannot be empty'), +}) + +/** + * Mints the one-time token the desktop app redeems to sign in. Takes no input: + * the caller is identified by its session cookie, and the handoff state/port + * are validated by the `/desktop/auth` page that renders the confirm gesture. + */ +export const createDesktopHandoffTokenContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/auth/handoff', + response: { + mode: 'json', + schema: desktopHandoffTokenResponseSchema, + }, +}) + +export type DesktopHandoffTokenResponse = ContractJsonResponse< + typeof createDesktopHandoffTokenContract +> diff --git a/apps/sim/lib/api/contracts/desktop-tool-authorization.ts b/apps/sim/lib/api/contracts/desktop-tool-authorization.ts new file mode 100644 index 00000000000..1995db242cd --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-tool-authorization.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const authorizeDesktopToolBodySchema = z.object({ + toolCallId: z + .string() + .min(1, 'Tool call ID is required') + .max(256, 'Tool call ID is too long') + .regex(/^[^\x00-\x1f\x7f]+$/, 'Tool call ID contains invalid control characters'), +}) + +export type AuthorizeDesktopToolBody = z.input + +export const authorizeDesktopToolResponseSchema = z.object({ + toolName: z.string().min(1), + args: z.record(z.string(), z.unknown()), +}) + +export type AuthorizeDesktopToolResponse = z.output + +export const authorizeDesktopToolContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/tool/authorize', + body: authorizeDesktopToolBodySchema, + response: { + mode: 'json', + schema: authorizeDesktopToolResponseSchema, + }, + error: z.object({ error: z.string() }), +}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 5bdd2fb6fc6..958a4ed21f9 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -8,6 +8,8 @@ export * from './common' export * from './copilot' export * from './credentials' export * from './demo-requests' +export * from './desktop-auth' +export * from './desktop-tool-authorization' export * from './environment' export * from './execution-payloads' export * from './file-uploads' diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index b1fa8871523..d280fb0b5e3 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -155,6 +155,17 @@ export const getInvitationContract = defineRouteContract({ }, }) +export const listMyInvitationsContract = defineRouteContract({ + method: 'GET', + path: '/api/invitations', + response: { + mode: 'json', + schema: z.object({ + invitations: z.array(invitationDetailsSchema), + }), + }, +}) + export const acceptInvitationContract = defineRouteContract({ method: 'POST', path: '/api/invitations/[id]/accept', diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index bb50923b562..70a297a486d 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -248,6 +248,25 @@ export const removeMothershipChatResourceContract = defineRouteContract({ }, }) +export const stageLocalFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/mothership/local-files/stage', + body: z.object({ + workspaceId: z.string().min(1), + chatId: z.string().min(1), + key: z.string().min(1).max(2048), + }), + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + displayName: z.string(), + fileName: z.string(), + uploadPath: z.string(), + }), + }, +}) + export const mothershipChatSchema = z.object({ id: z.string(), title: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/tools/sap.ts b/apps/sim/lib/api/contracts/tools/sap.ts index 38b10cbbecc..dcfa27fcaab 100644 --- a/apps/sim/lib/api/contracts/tools/sap.ts +++ b/apps/sim/lib/api/contracts/tools/sap.ts @@ -1,3 +1,4 @@ +import { isPrivateIpHost } from '@sim/security/ssrf' import { z } from 'zod' import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -49,54 +50,6 @@ const FORBIDDEN_SAP_HOSTS = new Set([ '[fd00:ec2::254]', ]) -function isPrivateIPv4(host: string): boolean { - const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!match) return false - const octets = match.slice(1, 5).map(Number) as [number, number, number, number] - if (octets.some((octet) => octet < 0 || octet > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - -function extractIPv4MappedHost(host: string): string | null { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - for (const prefix of ['::ffff:', '::']) { - if (lower.startsWith(prefix)) { - const candidate = lower.slice(prefix.length) - if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate - } - } - const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) - if (hexMatch) { - const high = Number.parseInt(hexMatch[1] as string, 16) - const low = Number.parseInt(hexMatch[2] as string, 16) - if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) { - const a = (high >> 8) & 0xff - const b = high & 0xff - const c = (low >> 8) & 0xff - const d = low & 0xff - return `${a}.${b}.${c}.${d}` - } - } - return null -} - -function isPrivateOrLoopbackIPv6(host: string): boolean { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - if (lower === '::' || lower === '::1') return true - if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true - if (lower.startsWith('fe80:')) return true - return false -} - export function checkSapExternalUrlSafety( rawUrl: string, label: string @@ -114,16 +67,9 @@ export function checkSapExternalUrlSafety( if (FORBIDDEN_SAP_HOSTS.has(host) || FORBIDDEN_SAP_HOSTS.has(`[${host}]`)) { return { ok: false, message: `${label} host is not allowed` } } - if (isPrivateIPv4(host)) { + if (isPrivateIpHost(host)) { return { ok: false, message: `${label} host is not allowed (private/loopback range)` } } - const mapped = extractIPv4MappedHost(host) - if (mapped && isPrivateIPv4(mapped)) { - return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } - } - if (isPrivateOrLoopbackIPv6(host)) { - return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } - } return { ok: true, url: parsed } } diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 7da4c3597d0..c799745bf1d 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -90,6 +90,8 @@ export const userSettingsSchema = z.object({ errorNotificationsEnabled: z.boolean().default(true), snapToGridSize: z.number().min(0).max(50).default(0), showActionBar: z.boolean().default(true), + /** Copilot tool ids the user chose "always allow" for, so they are never prompted for them again. */ + copilotAutoAllowedTools: z.array(z.string()).default([]), /** IANA timezone for scheduling; `null` means the client falls back to the browser-detected zone. */ timezone: z.string().nullable().default(null), lastActiveWorkspaceId: z.string().nullable().optional(), @@ -109,6 +111,7 @@ export const updateUserSettingsBodySchema = z.object({ errorNotificationsEnabled: z.boolean().optional(), snapToGridSize: z.number().min(0).max(50).optional(), showActionBar: z.boolean().optional(), + copilotAutoAllowedTools: z.array(z.string()).optional(), /** IANA timezone; explicit `null` resets to the browser-detected zone. */ timezone: ianaTimezoneSchema.nullable().optional(), /** Mirrors `userSettingsSchema.lastActiveWorkspaceId` so explicit `null` is accepted to clear the active workspace. */ diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 39a0cf7da55..48d9097e109 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -209,6 +209,11 @@ const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '') export const auth = betterAuth({ baseURL: getBaseUrl(), + // Where Better Auth sends OAuth callbacks that fail before the flow state is + // parsed — most commonly a provider-side Cancel/Deny. Without this it + // defaults to a nonexistent `/error` (a 404 dead-end), which strands the + // desktop sign-in/connect handoffs since their loopback is never pinged. + onAPIError: { errorURL: `${getBaseUrl()}/oauth-error` }, trustedOrigins: [ getBaseUrl(), ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), @@ -224,12 +229,21 @@ export const auth = betterAuth({ session: { cookieCache: { enabled: true, - maxAge: 24 * 60 * 60, // 24 hours in seconds + // Better Auth's default, and deliberately short: the cached session is a + // signed cookie that `getSession` returns WITHOUT re-reading the database, + // so this is the window in which a revoked, expired, or signed-out session + // still authenticates. Anything longer is an un-revocable credential — at + // 24h a sign-out on one device left every other surface looking signed in + // for a day while every database-backed check (socket handshakes, the + // desktop handoff) failed against a row that no longer existed. The + // `version` below only covers org-wide invalidation, so this TTL remains + // the only bound on per-device sign-out latency. + maxAge: 5 * 60, // 5 minutes in seconds /** * Embeds the member org's security-policy version. Bumping the version * (policy change, org-wide revocation) invalidates every cached session * cookie in the org on its next request, forcing a DB session read — - * revocation latency becomes the policy cache TTL, not 24h. + * revocation latency becomes the policy cache TTL, not the full `maxAge`. */ version: async (session) => getSessionCookieCacheVersion(session as { userId?: string | null }), diff --git a/apps/sim/lib/auth/desktop-handoff.ts b/apps/sim/lib/auth/desktop-handoff.ts new file mode 100644 index 00000000000..059013bf0cc --- /dev/null +++ b/apps/sim/lib/auth/desktop-handoff.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { auth } from '@/lib/auth' + +const logger = createLogger('DesktopHandoff') + +/** + * Identifier namespace Better Auth's one-time-token plugin reads in + * `/one-time-token/verify`. The row is written here rather than through + * `generateOneTimeToken` so the token resolves to a session minted for the + * desktop app — see {@link createDesktopHandoffToken}. This constant is the + * only coupling to the plugin's storage layout, and it holds because the + * plugin's `storeToken` option is left at its `'plain'` default, so the token + * is stored verbatim rather than hashed. + */ +const ONE_TIME_TOKEN_IDENTIFIER_PREFIX = 'one-time-token:' + +/** Matches the token length Better Auth generates for its own one-time tokens. */ +const HANDOFF_TOKEN_LENGTH = 32 + +/** + * The browser navigates straight to the desktop app's loopback listener once + * the token is minted, so a redeem lands within seconds. Deliberately far + * shorter than the plugin-wide 24h `expiresIn`: this token is a bearer + * credential that grants a session, and `/one-time-token/verify` enforces the + * expiry stored on the row, not the plugin option. + */ +const HANDOFF_TOKEN_TTL_MS = 3 * 60 * 1000 + +/** + * Recorded as the session's user agent. The row is created while handling the + * *browser's* request, so the real user agent would misattribute the desktop's + * session to the browser in session listings and audit trails. + */ +const DESKTOP_SESSION_USER_AGENT = 'Sim Desktop' + +/** + * Mints a one-time token that signs the desktop app in as `userId` on a session + * of its own. + * + * Better Auth's `generateOneTimeToken` binds the token to the *calling* session + * row, and `/one-time-token/verify` then hands that same row's cookie to + * whoever redeems it. Used as-is, the desktop app and the browser that + * authorized it share a single session: signing out of either deletes the row + * the other is still presenting, leaving that client holding a cookie for a + * session that no longer exists — and, because the session cookie cache is not + * revalidated against the database, still looking signed in until the cache + * expires. Every request it authorizes off that cache then fails anywhere the + * session is resolved from the database, notably socket handshakes. + * + * A session per device is what Better Auth's own device authorization grant + * does, and what RFC 8252 assumes of a native app: sign-out, revocation, and + * expiry apply to one surface at a time. + */ +export async function createDesktopHandoffToken(userId: string): Promise { + const ctx = await auth.$context + const desktopSession = await ctx.internalAdapter.createSession(userId, false, { + userAgent: DESKTOP_SESSION_USER_AGENT, + }) + + const token = generateShortId(HANDOFF_TOKEN_LENGTH) + await ctx.internalAdapter.createVerificationValue({ + value: desktopSession.token, + identifier: `${ONE_TIME_TOKEN_IDENTIFIER_PREFIX}${token}`, + expiresAt: new Date(Date.now() + HANDOFF_TOKEN_TTL_MS), + }) + + logger.info('Minted desktop handoff token', { userId, sessionId: desktopSession.id }) + return token +} diff --git a/apps/sim/lib/auth/stale-session-recovery.ts b/apps/sim/lib/auth/stale-session-recovery.ts index a3b9b38f6a7..75f6d47f9f2 100644 --- a/apps/sim/lib/auth/stale-session-recovery.ts +++ b/apps/sim/lib/auth/stale-session-recovery.ts @@ -13,7 +13,7 @@ const logger = createLogger('StaleSessionRecovery') * would only get bounced back to /workspace by the middleware. * * Shared by the /workspace loader (stale-cookie 401s and clean-null sessions) - * and the impersonation-expired screen, so every identity-recovery path clears + * and the session-expired screen, so every identity-recovery path clears * cookies and persisted client state the same way. */ export async function recoverFromStaleSession(): Promise { diff --git a/apps/sim/lib/browser-agent/attachments.test.ts b/apps/sim/lib/browser-agent/attachments.test.ts new file mode 100644 index 00000000000..a115bb7261a --- /dev/null +++ b/apps/sim/lib/browser-agent/attachments.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { buildResourceAttachments } from '@/lib/browser-agent/attachments' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const BROWSER_RESOURCE: MothershipResource = { + type: 'browser', + id: 'browser-session', + title: 'Browser', +} + +describe('buildResourceAttachments', () => { + beforeEach(() => { + useBrowserSessionStore.setState({ + pageState: null, + tabs: [], + activeTabId: null, + tabsSupported: false, + panelSnapshot: null, + sessionAlive: true, + }) + }) + + it('adds every live browser tab and marks only the selected tab active', () => { + useBrowserSessionStore.setState({ + tabsSupported: true, + activeTabId: '2', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: false, + }, + { + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: false, + active: true, + }, + ], + }) + + expect(buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id)).toEqual([ + { + type: 'browser', + id: 'browser-session:1', + title: 'Docs', + active: false, + url: 'https://docs.sim.ai', + }, + { + type: 'browser', + id: 'browser-session:2', + title: 'Dashboard', + active: true, + url: 'https://sim.ai/workspace', + }, + ]) + }) + + it('keeps all browser tabs open rather than active when another resource is selected', () => { + useBrowserSessionStore.setState({ + tabsSupported: true, + activeTabId: '1', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }, + ], + }) + + const attachments = buildResourceAttachments([BROWSER_RESOURCE], 'workflow-1') + + expect(attachments?.[0]).toMatchObject({ id: 'browser-session:1', active: false }) + }) + + it('falls back to the active page for older single-tab desktop versions', () => { + useBrowserSessionStore.setState({ + pageState: { + url: 'https://sim.ai', + title: 'Sim', + loading: false, + canGoBack: false, + canGoForward: false, + }, + }) + + expect(buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id)).toEqual([ + { + type: 'browser', + id: 'browser-session', + title: 'Sim', + active: true, + url: 'https://sim.ai', + }, + ]) + }) +}) diff --git a/apps/sim/lib/browser-agent/attachments.ts b/apps/sim/lib/browser-agent/attachments.ts new file mode 100644 index 00000000000..37850b9412a --- /dev/null +++ b/apps/sim/lib/browser-agent/attachments.ts @@ -0,0 +1,73 @@ +/** + * Maps the chat's open resources to request attachments. + * + * This is deliberately the ONLY place shared chat code reads the + * browser-session store: the live browser panel's page state is client-held + * (the desktop app's embedded browser), so its attachment is enriched here + * with the current URL and title for the server to inject as + * `@active_tab`/`@open_tab` context. A browser panel with no page loaded has + * nothing to say and is dropped. + */ +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +export interface ResourceAttachment { + type: MothershipResource['type'] + id: string + title: string + active: boolean + /** Live page URL, only on `browser` attachments. */ + url?: string +} + +export function buildResourceAttachments( + resources: readonly MothershipResource[], + activeResourceId: string | null +): ResourceAttachment[] | undefined { + const { pageState, tabs, tabsSupported } = useBrowserSessionStore.getState() + const attachments = resources.flatMap((resource) => { + // The terminal panel is not addressable context: unlike a browser tab it + // carries no URL to reference, and the shell's state reaches the model + // through the terminal tools instead. + if (resource.type === 'terminal') return [] + + if (resource.type !== 'browser') { + return [ + { + type: resource.type, + id: resource.id, + title: resource.title, + active: resource.id === activeResourceId, + }, + ] + } + + if (tabsSupported) { + return tabs + .filter((tab) => Boolean(tab.url)) + .map((tab) => ({ + type: resource.type, + id: `${resource.id}:${tab.tabId}`, + title: tab.title.trim() || resource.title, + active: resource.id === activeResourceId && tab.active, + url: tab.url, + })) + } + + if (!pageState?.url) return [] + return [ + { + type: resource.type, + id: resource.id, + title: pageState.title.trim() || resource.title, + active: resource.id === activeResourceId, + url: pageState.url, + }, + ] + }) + + if (attachments.length === 0) { + return undefined + } + return attachments +} diff --git a/apps/sim/lib/browser-agent/open-in-panel.ts b/apps/sim/lib/browser-agent/open-in-panel.ts new file mode 100644 index 00000000000..651cfcb82df --- /dev/null +++ b/apps/sim/lib/browser-agent/open-in-panel.ts @@ -0,0 +1,46 @@ +/** + * "Open this URL in the Sim browser panel" — the desktop-only affordance that + * routes chat links into the embedded agent browser instead of a new browser + * tab. + * + * Rendering components (markdown links, chips) can't reach the chat's + * resource state directly, so the request travels as a window CustomEvent; + * the chat hook owns the panel resource and subscribes via + * {@link onOpenInBrowserPanel}. + * + * OAuth/credential links must NOT go through here: the panel's browser runs + * on its own partition (not signed in to Sim) and is an embedded user agent + * that Google/Microsoft refuse — connect chips use the system-browser + * handoff (`beginOAuthConnect`) instead. + */ +import { isBrowserAgentEnabled } from '@/lib/desktop' + +const OPEN_IN_BROWSER_PANEL_EVENT = 'sim:open-in-browser-panel' + +interface OpenInBrowserPanelDetail { + url: string +} + +/** True when a click on this href should divert into the embedded panel. */ +export function shouldOpenInBrowserPanel(href: string | undefined): href is string { + return Boolean(href) && /^https?:\/\//i.test(href as string) && isBrowserAgentEnabled() +} + +/** Requests the chat surface to open the panel on this URL (fire-and-forget). */ +export function openInBrowserPanel(url: string): void { + window.dispatchEvent( + new CustomEvent(OPEN_IN_BROWSER_PANEL_EVENT, { detail: { url } }) + ) +} + +/** Subscribes the chat surface to panel-open requests; returns an unsubscribe. */ +export function onOpenInBrowserPanel(callback: (url: string) => void): () => void { + const listener = (event: Event) => { + const url = (event as CustomEvent).detail?.url + if (typeof url === 'string' && /^https?:\/\//i.test(url)) { + callback(url) + } + } + window.addEventListener(OPEN_IN_BROWSER_PANEL_EVENT, listener) + return () => window.removeEventListener(OPEN_IN_BROWSER_PANEL_EVENT, listener) +} diff --git a/apps/sim/lib/browser-agent/transport.test.ts b/apps/sim/lib/browser-agent/transport.test.ts new file mode 100644 index 00000000000..94ac637bfac --- /dev/null +++ b/apps/sim/lib/browser-agent/transport.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + onFocusOmnibox, + onPanelSnapshot, + reorderTab, + setPageState, + setPanelBounds, + setPanelFocused, + setPanelOccluded, + setPanelSnapshot, + setSessionAlive, + setTabPinned, + setTheme, + setTabsState, + setTabsSupported, +} = vi.hoisted(() => ({ + onFocusOmnibox: vi.fn(), + onPanelSnapshot: vi.fn(), + reorderTab: vi.fn(), + setPageState: vi.fn(), + setPanelBounds: vi.fn(), + setPanelFocused: vi.fn(), + setPanelOccluded: vi.fn(), + setPanelSnapshot: vi.fn(), + setSessionAlive: vi.fn(), + setTabPinned: vi.fn(), + setTheme: vi.fn(), + setTabsState: vi.fn(), + setTabsSupported: vi.fn(), +})) + +vi.mock('@/lib/desktop', () => ({ + getDesktopBridge: () => ({ + browserAgent: { + executeTool: vi.fn(), + getTabsState: vi.fn(async () => ({ tabs: [], activeTabId: null })), + onFocusOmnibox, + onPageState: vi.fn(), + onPanelSnapshot, + onSessionStatus: vi.fn(), + onTabsState: vi.fn(), + panelAction: vi.fn(), + reorderTab, + setPanelBounds, + setPanelFocused, + setPanelOccluded, + setTabPinned, + setTheme, + }, + }), +})) + +vi.mock('@/stores/browser-session/store', () => ({ + useBrowserSessionStore: { + getState: () => ({ + setPageState, + setPanelSnapshot, + setSessionAlive, + setTabsState, + setTabsSupported, + }), + }, +})) + +import { + initBrowserAgentTransport, + isBrowserTabPinningAvailable, + isBrowserTabReorderingAvailable, + onBrowserOmniboxFocus, + reorderBrowserTab, + reportBrowserPanelBounds, + reportBrowserPanelFocused, + reportBrowserPanelOcclusion, + reportBrowserTheme, + resetBrowserPanelOcclusion, + setBrowserTabPinned, +} from '@/lib/browser-agent/transport' + +describe('browser panel transport', () => { + beforeEach(() => { + resetBrowserPanelOcclusion() + setPanelBounds.mockClear() + setPanelFocused.mockClear() + setPanelOccluded.mockClear() + reorderTab.mockClear() + setTabPinned.mockClear() + setTheme.mockClear() + }) + + it('forwards panel bounds independently from native-view occlusion', () => { + const initialBounds = { x: 10, y: 20, width: 300, height: 200 } + const updatedBounds = { x: 20, y: 30, width: 320, height: 220 } + + reportBrowserPanelBounds(initialBounds) + reportBrowserPanelOcclusion(true) + reportBrowserPanelBounds(updatedBounds) + reportBrowserPanelOcclusion(false) + + // A caller with no anchor to declare keeps whatever was last retained — + // here there was never one, so the shell is told null both times. + expect(setPanelBounds.mock.calls).toEqual([ + [initialBounds, null], + [updatedBounds, null], + ]) + expect(setPanelOccluded.mock.calls).toEqual([[true], [false]]) + }) + + it('forwards renderer-owned browser chrome focus', () => { + reportBrowserPanelFocused(true) + reportBrowserPanelFocused(false) + + expect(setPanelFocused.mock.calls).toEqual([[true], [false]]) + }) + + it('forwards tab pinning only through shells that advertise support', () => { + expect(isBrowserTabPinningAvailable()).toBe(true) + + setBrowserTabPinned('tab-2', true) + setBrowserTabPinned('tab-2', false) + + expect(setTabPinned.mock.calls).toEqual([ + ['tab-2', true], + ['tab-2', false], + ]) + }) + + it('forwards tab reordering only through shells that advertise support', () => { + expect(isBrowserTabReorderingAvailable()).toBe(true) + + reorderBrowserTab('tab-3', 1) + + expect(reorderTab).toHaveBeenCalledWith('tab-3', 1) + }) + + it('wires captured browser frames into the browser-session store', () => { + initBrowserAgentTransport() + const listener = onPanelSnapshot.mock.calls[0][0] as (snapshot: { + dataUrl: string + tabId: string + }) => void + const snapshot = { dataUrl: 'data:image/png;base64,c2lt', tabId: 'tab-1' } + + listener(snapshot) + + expect(setPanelSnapshot).toHaveBeenCalledWith(snapshot) + }) + + it('forwards Sim theme preferences to the desktop browser', () => { + reportBrowserTheme('dark') + reportBrowserTheme('light') + reportBrowserTheme('system') + + expect(setTheme.mock.calls).toEqual([['dark'], ['light'], ['system']]) + }) + + it('subscribes to native omnibox focus requests', () => { + const unsubscribe = vi.fn() + const callback = vi.fn() + onFocusOmnibox.mockReturnValue(unsubscribe) + + expect(onBrowserOmniboxFocus(callback)).toBe(unsubscribe) + expect(onFocusOmnibox).toHaveBeenCalledWith(callback) + }) +}) diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts new file mode 100644 index 00000000000..3eab6799b8f --- /dev/null +++ b/apps/sim/lib/browser-agent/transport.ts @@ -0,0 +1,319 @@ +/** + * Transport for the browser agent: the agent browser built into the Sim + * desktop app, reached through the preload bridge + * (`window.simDesktop.browserAgent`). + * + * Tools execute in the Electron main process against a persistent-profile + * browser view embedded in the main Sim window. The renderer's job is + * geometry and chrome: it reports where the browser panel sits (the main + * process glues the real page over that rect, so the panel is natively + * interactive) and receives page-state pushes for the panel header. + * Availability of this bridge, plus the device switch on the Browser settings + * page, is what gates advertising `browserCapable` to the copilot — in a + * regular web browser there is no bridge and the browser subagent is never + * offered. + */ +import type { + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSession, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserTabsState, + BrowserTheme, + BrowserToolName, +} from '@sim/browser-protocol' +import type { + BrowserCredentialMetadata, + BrowserSiteInfo, + SimDesktopBrowserAgentApi, +} from '@sim/desktop-bridge' +import { getDesktopBridge, isBrowserAgentEnabled } from '@/lib/desktop' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +let initialized = false +let latestPanelBounds: BrowserPanelBounds | null = null +let panelOccluded = false + +function bridge(): SimDesktopBrowserAgentApi | null { + return getDesktopBridge()?.browserAgent ?? null +} + +/** + * Idempotently wires page-state and session-status pushes into the + * browser-session store. Safe to call repeatedly (e.g. per chat mount). + */ +export function initBrowserAgentTransport(): void { + if (initialized) return + const agent = bridge() + if (!agent) return + initialized = true + agent.onPageState((state: BrowserPageState) => { + useBrowserSessionStore.getState().setPageState(state) + }) + agent.onPanelSnapshot?.((snapshot) => { + useBrowserSessionStore.getState().setPanelSnapshot(snapshot) + }) + if (agent.onTabsState) { + useBrowserSessionStore.getState().setTabsSupported(true) + agent.onTabsState((state: BrowserTabsState) => { + useBrowserSessionStore.getState().setTabsState(state) + }) + if (agent.getTabsState) { + void agent + .getTabsState() + .then((state) => useBrowserSessionStore.getState().setTabsState(state)) + .catch(() => {}) + } + } + agent.onSessionStatus((alive) => { + useBrowserSessionStore.getState().setSessionAlive(alive) + }) +} + +/** True when browser tools can run (gates the copilot's browserCapable flag). */ +export function isBrowserAgentAvailable(): boolean { + return isBrowserAgentEnabled() +} + +/** + * Executes one browser tool in the desktop main process. Rejects on transport + * failure, tool failure, or when `timeoutMs` elapses first (null = no + * timeout, e.g. takeovers). + */ +export async function executeBrowserTool( + toolCallId: string, + tool: BrowserToolName, + params: Record, + timeoutMs: number | null +): Promise { + const agent = bridge() + if (!agent) { + throw new Error('The Sim desktop browser agent is unavailable.') + } + const invocation = agent.executeTool(toolCallId, tool, params) + const response = + timeoutMs === null + ? await invocation + : await Promise.race([ + invocation, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`The browser did not respond within ${timeoutMs}ms`)), + timeoutMs + ) + }), + ]) + if (!response.ok) { + throw new Error(response.error || 'The browser agent reported an error') + } + return response.result +} + +/** Browser-chrome commands from the panel header; fire-and-forget. */ +export function sendBrowserPanelAction( + action: BrowserPanelAction['action'], + payload: Omit = {} +): void { + bridge()?.panelAction({ action, ...payload }) +} + +/** Whether the installed desktop shell supports durable browser-tab pinning. */ +export function isBrowserTabPinningAvailable(): boolean { + return typeof bridge()?.setTabPinned === 'function' +} + +/** Pins or unpins a live browser tab when supported by the desktop shell. */ +export function setBrowserTabPinned(tabId: string, pinned: boolean): void { + bridge()?.setTabPinned?.(tabId, pinned) +} + +/** Whether the installed desktop shell supports user-driven browser-tab ordering. */ +export function isBrowserTabReorderingAvailable(): boolean { + return typeof bridge()?.reorderTab === 'function' +} + +/** Moves a live browser tab to its final list index when supported by the shell. */ +export function reorderBrowserTab(tabId: string, targetIndex: number): void { + bridge()?.reorderTab?.(tabId, targetIndex) +} + +/** Mirrors Sim's raw light/dark/system preference into embedded pages. */ +export function reportBrowserTheme(theme: BrowserTheme): void { + bridge()?.setTheme?.(theme) +} + +/** + * Subscribes to whether the active page can be filled with a saved password. + * A bare boolean by design — the page learns nothing about which accounts + * exist, and the chooser itself is a native shell surface. + */ +export function onBrowserFillAvailability(callback: (available: boolean) => void): () => void { + return ( + getDesktopBridge()?.browserCredentials?.onFillAvailability?.(({ available }) => + callback(available) + ) ?? (() => {}) + ) +} + +/** + * Asks the shell to open its native account chooser at a point in the window. + * Must be called straight from a click: the shell requires a live user gesture, + * and it performs the fill itself rather than handing anything back here. + */ +export function showBrowserCredentialChooser(anchor: { x: number; y: number }): void { + void getDesktopBridge()?.browserCredentials?.showChooser?.(anchor) +} + +/** + * Reads what the omnibox suggests from: hosts the browser holds cookies for, + * and hosts with a saved password. + * + * Both already exist for other reasons, and neither is a record of where the + * user has been — this browser keeps no history. Password metadata never + * includes the password itself. Either source failing yields an empty list, so + * a broken lookup costs suggestions rather than the omnibox. + */ +export async function loadBrowserSuggestionSources(): Promise<{ + sessions: BrowserKnownSession[] + credentials: BrowserCredentialMetadata[] + sites: BrowserSiteInfo[] +}> { + const desktop = getDesktopBridge() + const [known, credentials, sites] = await Promise.all([ + desktop?.browserAgent?.getKnownSessions?.().catch(() => null) ?? null, + desktop?.browserCredentials?.list().catch(() => []) ?? [], + desktop?.browserImport?.listSites?.().catch(() => []) ?? [], + ]) + return { sessions: known?.sessions ?? [], credentials, sites } +} + +/** Subscribes to native browser shortcuts that target the renderer omnibox. */ +export function onBrowserOmniboxFocus( + callback: (mode: BrowserOmniboxFocusMode) => void +): () => void { + return bridge()?.onFocusOmnibox?.(callback) ?? (() => {}) +} + +/** Whether the installed desktop shell supports find-in-page. */ +export function isBrowserFindAvailable(): boolean { + return typeof bridge()?.find === 'function' +} + +/** + * Runs Chromium's find against the active page. Counts arrive separately via + * {@link onBrowserFindResult} — Chromium resolves them asynchronously and + * streams several updates per request. + */ +export function findInBrowserPage(request: BrowserFindRequest): void { + bridge()?.find?.(request) +} + +/** + * Stops the running find and clears its highlights. Pass `focusPage` when the + * user dismissed the bar, so focus lands back on the page instead of being + * stranded on the removed input; leave it off when the bar is unmounting + * because the panel is going away. + */ +export function stopBrowserFind(focusPage = false): void { + bridge()?.stopFind?.(focusPage) +} + +/** Subscribes to Mod+F pressed while the embedded page held focus. */ +export function onBrowserFindOpen(callback: () => void): () => void { + return bridge()?.onOpenFind?.(callback) ?? (() => {}) +} + +/** Subscribes to the shell dismissing find (navigation, tab switch). */ +export function onBrowserFindClose(callback: () => void): () => void { + return bridge()?.onCloseFind?.(callback) ?? (() => {}) +} + +/** Subscribes to match counts for the running find. */ +export function onBrowserFindResult(callback: (result: BrowserFindResult) => void): () => void { + return bridge()?.onFindResult?.(callback) ?? (() => {}) +} + +/** + * Reports the panel's current rect (viewport CSS pixels), or null when the + * panel is hidden/unmounted. The embedded view tracks this rect. + */ +export function reportBrowserPanelBounds( + bounds: BrowserPanelBounds | null, + anchor: BrowserPanelAnchor | null = null +): void { + latestPanelBounds = bounds + const agent = bridge() + if (!agent?.setPanelOccluded && panelOccluded && bounds !== null) return + agent?.setPanelBounds(bounds, anchor) +} + +/** + * Fast path for live divider drags. The measured pipeline (renderer layout → + * ResizeObserver → report) can only start after the new panel width has laid + * out, so the native view learns each rect milliseconds after the divider + * moved and can miss the frame's composite deadline — the page visibly swims + * against the divider. During a divider drag only the panel's left edge + * moves, so the next rect is pure arithmetic: the rect measured at drag start + * with its left edge shifted by the divider's travel. Call at drag start with + * the divider position (the panel's left edge in viewport CSS pixels); the + * returned predictor reports a rect per pointer move, before layout runs. + * Measured reports remain authoritative and correct any drift. + * + * Both `startDividerX` and every `dividerX` must be the panel's REAL viewport + * left edge, clamps applied — never a width subtracted from `window.innerWidth`. + * The panel is inset from the viewport by the workspace chrome's padding and + * border, so that substitution reads as a constant offset in `dx`, which lands + * wholly on the predicted rect's left edge: the native view then composites + * beside the panel rather than on it, and alternates with the measured report + * every frame. Derive `dividerX` from the same clamped width the caller writes + * to the DOM and the two cannot disagree. + */ +export function beginBrowserPanelDividerDrag( + startDividerX: number +): ((dividerX: number) => void) | null { + const base = latestPanelBounds + if (!bridge() || !base) return null + return (dividerX: number) => { + const dx = Math.round(dividerX - startDividerX) + const width = base.width - dx + if (width <= 0) return + // The drag has pinned an inline px width, so the rate is flat and the + // viewport is whatever it already was — statable exactly, which keeps the + // rect and its anchor from ever describing different states. + reportBrowserPanelBounds( + { x: base.x + dx, y: base.y, width, height: base.height }, + { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, widthRatio: 0 } + ) + } +} + +/** Reports whether renderer-owned browser chrome owns the interaction context. */ +export function reportBrowserPanelFocused(focused: boolean): void { + bridge()?.setPanelFocused?.(focused) +} + +/** + * Reports whether renderer-owned UI currently overlaps the native browser + * surface. New desktop builds hide the still-attached view directly; older + * builds fall back to temporarily clearing and restoring panel bounds. + */ +export function reportBrowserPanelOcclusion(occluded: boolean): void { + if (panelOccluded === occluded) return + panelOccluded = occluded + const agent = bridge() + if (agent?.setPanelOccluded) { + agent.setPanelOccluded(occluded) + return + } + agent?.setPanelBounds(occluded ? null : latestPanelBounds) +} + +/** Resets occlusion before the panel unmounts or its host document changes. */ +export function resetBrowserPanelOcclusion(): void { + panelOccluded = false + bridge()?.setPanelOccluded?.(false) +} diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index a66fab12218..50e36eaecd1 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -6,6 +6,7 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, + claimPendingAsyncToolCall, completeAsyncToolCall, markAsyncToolDelivered, } from './repository' @@ -77,4 +78,29 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('atomically marks one pending native tool claim as running', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'browser-tool', + status: 'running', + claimedBy: 'desktop-browser', + }, + ]) + + const result = await claimPendingAsyncToolCall('browser-tool', 'desktop-browser') + + expect(result).toMatchObject({ + toolCallId: 'browser-tool', + status: 'running', + claimedBy: 'desktop-browser', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + claimedBy: 'desktop-browser', + claimedAt: expect.any(Date), + }) + ) + }) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 4e8c3d236b6..257bfdaec2d 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -3,12 +3,14 @@ import { db } from '@sim/db' import { type CopilotAsyncToolStatus, type CopilotRunStatus, + type CopilotToolPermissionDecision, copilotAsyncToolCalls, copilotRunCheckpoints, copilotRuns, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' +import { sanitizeValueForJsonb } from '@sim/utils/string' import { and, desc, eq, inArray, isNull } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -187,7 +189,13 @@ export async function getRunSegment(runId: string) { { [TraceAttr.RunId]: runId }, async () => { const [run] = await db - .select({ id: copilotRuns.id, userId: copilotRuns.userId }) + .select({ + id: copilotRuns.id, + userId: copilotRuns.userId, + status: copilotRuns.status, + // Needed to scope an "allow for this chat" decision to its chat. + chatId: copilotRuns.chatId, + }) .from(copilotRuns) .where(eq(copilotRuns.id, runId)) .limit(1) @@ -273,6 +281,7 @@ export async function upsertAsyncToolCall(input: { } const now = new Date() + const args = sanitizeValueForJsonb(input.args ?? {}) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -280,7 +289,7 @@ export async function upsertAsyncToolCall(input: { checkpointId: input.checkpointId ?? null, toolCallId: input.toolCallId, toolName: input.toolName, - args: input.args ?? {}, + args, status: incomingStatus, updatedAt: now, }) @@ -290,7 +299,7 @@ export async function upsertAsyncToolCall(input: { runId: effectiveRunId, checkpointId: input.checkpointId ?? null, toolName: input.toolName, - args: input.args ?? {}, + args, status: incomingStatus, updatedAt: now, }, @@ -354,7 +363,9 @@ async function markAsyncToolStatus( status, claimedBy: updates.claimedBy, claimedAt, - result: updates.result, + // Results carry client/page-derived text; lone UTF-16 surrogates or + // NULs in it would make the jsonb write throw (invalid JSON input). + result: sanitizeValueForJsonb(updates.result), error: updates.error, completedAt: updates.completedAt, updatedAt: new Date(), @@ -371,6 +382,43 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +/** + * Atomically claims a pending client tool exactly once. Native browser actions + * use this before crossing the Electron boundary so a replayed renderer event + * cannot click, type, submit, or navigate twice. + */ +export async function claimPendingAsyncToolCall(toolCallId: string, claimedBy: string) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: ASYNC_TOOL_STATUS.running, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: ASYNC_TOOL_STATUS.running, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) + ) + ) + .returning() + return row ?? null + } + ) +} + export async function completeAsyncToolCall(input: { toolCallId: string status: Extract @@ -400,6 +448,47 @@ export async function completeAsyncToolCall(input: { }) } +/** + * Records the user's answer to a tool permission prompt, exactly once. + * + * The `IS NULL` guard is what makes a decision final: two tabs (or a click + * plus an "allow all") racing on the same prompt resolve to whichever write + * lands first, and the loser gets `null` back rather than overwriting an + * answer the orchestrator may already have acted on. + */ +export async function recordToolPermissionDecision( + toolCallId: string, + decision: CopilotToolPermissionDecision +) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolPermissionDecision]: decision, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + permissionDecision: decision, + permissionDecidedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.permissionDecision) + ) + ) + .returning() + return row ?? null + } + ) +} + export async function markAsyncToolDelivered(toolCallId: string) { return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { claimedBy: null, diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index c7c4fcea852..e4fc38e8e3f 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -30,6 +30,7 @@ const STATE_TO_STATUS: Record = { interrupted: ToolCallStatus.interrupted, pending: ToolCallStatus.executing, executing: ToolCallStatus.executing, + awaiting_approval: ToolCallStatus.awaiting_approval, } function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined { diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 4e214ef22fa..46e5c63dc31 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -127,9 +127,7 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { }) it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([ - { ...chatRow, model: 'm', planArtifact: null, config: null }, - ]) + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, model: 'm', config: null }]) dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index f6584f731d5..69b577a31e9 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -23,8 +23,8 @@ export interface ChatLoadResult { /** * Minimal column set needed to perform workflow/workspace authorization for a - * copilot chat. Heavy TOAST-able columns (messages, planArtifact, previewYaml, - * config, resources) are intentionally excluded — callers that only need to + * copilot chat. Heavy TOAST-able columns (messages, previewYaml, config, + * resources) are intentionally excluded — callers that only need to * verify ownership should not pay the detoast cost for those fields. */ const copilotChatAuthColumns = { @@ -40,9 +40,8 @@ const copilotChatAuthColumns = { * transcript is no longer selected from `copilot_chats.messages` (JSONB) — * reads now source it from the normalized `copilot_messages` table via * `loadCopilotChatMessages`, which avoids detoasting the large messages blob on - * every load. The copilot-only TOAST-able fields (`previewYaml`, - * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, - * `lastSeenAt`) remain excluded. + * every load. The copilot-only TOAST-able fields (`previewYaml`, `config`) + * and unused metadata (`model`, `pinned`, `lastSeenAt`) remain excluded. */ const copilotChatDetailColumns = { ...copilotChatAuthColumns, @@ -55,14 +54,13 @@ const copilotChatDetailColumns = { /** * Column set for the legacy copilot chat detail endpoint. Extends - * `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the + * `copilotChatDetailColumns` with `model` and `config` — the * fields the legacy `transformChat` response shape includes. Still drops * `previewYaml` (JSONB), `pinned`, and `lastSeenAt`. */ const copilotChatLegacyDetailColumns = { ...copilotChatDetailColumns, model: copilotChats.model, - planArtifact: copilotChats.planArtifact, config: copilotChats.config, } as const @@ -123,7 +121,7 @@ export type CopilotChatDetailRow = Pick< } export type CopilotChatLegacyDetailRow = CopilotChatDetailRow & - Pick + Pick async function authorizeCopilotChatRow( chat: T | undefined, @@ -185,7 +183,7 @@ export async function getAccessibleCopilotChatAuth( /** * Load a copilot chat row for the legacy chat detail endpoint, including the - * transcript plus `model`, `planArtifact`, and `config`. Drops `previewYaml` + * transcript plus `model` and `config`. Drops `previewYaml` * (JSONB), `pinned`, and `lastSeenAt` — none of which the endpoint returns. */ export async function getAccessibleCopilotChat( @@ -208,8 +206,7 @@ export async function getAccessibleCopilotChat( /** * Load a copilot chat with the conversation transcript and resources after * authorization, omitting copilot-only TOAST-able fields (`previewYaml`, - * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, - * `lastSeenAt`). Use this for the mothership chat detail endpoint and the + * `config`) and unused metadata (`model`, `pinned`, `lastSeenAt`). Use this for the mothership chat detail endpoint and the * shared `resolveOrCreateChat` path — every column read here is consumed * downstream, and dropping the others avoids per-request detoast overhead. */ diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 00cdaa0ff19..d0a23aa9706 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -233,6 +233,39 @@ describe('buildCopilotRequestPayload', () => { ) }) + it('advertises desktop capabilities without adding parallel local_* tool schemas', async () => { + const capablePayload = await buildCopilotRequestPayload( + { + message: 'inspect my local project', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: '', + workspaceId: 'ws-1', + desktopLocalFilesystem: true, + }, + { selectedModel: '' } + ) + expect(capablePayload).toMatchObject({ + desktopCapabilities: { localFilesystem: true }, + }) + expect(capablePayload).not.toHaveProperty('mothershipTools') + + const browserPayload = await buildCopilotRequestPayload( + { + message: 'inspect my local project', + userId: 'user-1', + userMessageId: 'msg-2', + mode: 'agent', + model: '', + workspaceId: 'ws-1', + }, + { selectedModel: '' } + ) + expect(browserPayload).not.toHaveProperty('mothershipTools') + expect(browserPayload).not.toHaveProperty('desktopCapabilities') + }) + it('passes user metadata through to the Go request payload', async () => { const payload = await buildCopilotRequestPayload( { diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 6893af3d850..cac3f97f28a 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -1,3 +1,4 @@ +import type { BrowserKnownSession } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' @@ -55,6 +56,17 @@ interface BuildPayloadParams { email?: string timezone?: string } + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Array<{ + id: string + cwd?: string + running?: string + interactive?: boolean + active?: boolean + }> + browserSessions?: BrowserKnownSession[] } export interface ToolSchema { @@ -435,6 +447,24 @@ export async function buildCopilotRequestPayload( // Tell the copilot file subagent which document toolchain to write. Emitted // only in Python mode so the JS path sends no new field (Go defaults to js). ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), + ...(params.desktopLocalFilesystem || params.browserCapable || params.terminalCapable + ? { + desktopCapabilities: { + ...(params.desktopLocalFilesystem ? { localFilesystem: true } : {}), + ...(params.browserCapable ? { browser: true } : {}), + ...(params.terminalCapable ? { terminal: true } : {}), + ...(params.terminalCapable && params.terminals?.length + ? { terminals: params.terminals } + : {}), + ...(params.browserCapable && params.browserSessions?.length + ? { browserSessions: params.browserSessions } + : {}), + }, + } + : {}), + // Compatibility with mothership deployments that predate the unified + // desktop capability object. + ...(params.browserCapable ? { browserCapable: true } : {}), isHosted, } } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 41fe97ea31b..43529a9f19e 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -424,6 +424,7 @@ const OUTCOME_NORMALIZATION: Record = { interrupted: 'interrupted', pending: 'pending', executing: 'executing', + awaiting_approval: 'awaiting_approval', } function normalizeToolState(state: string | undefined): PersistedToolState { diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 660b0941ce6..1de5ea17dcb 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -244,6 +244,26 @@ describe('handleUnifiedChatPost', () => { ) }) + it('forwards the desktop local filesystem capability into payload construction', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Inspect my local project', + workspaceId: 'ws-1', + createNewChat: true, + desktopCapabilities: { localFilesystem: true }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ desktopLocalFilesystem: true }), + { selectedModel: '' } + ) + }) + it('accepts tagged skill contexts and forwards them to context resolution', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0a92012e76b..96878919b87 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -45,6 +45,7 @@ import { } from '@/lib/copilot/request/session' import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types' import { persistChatResources } from '@/lib/copilot/resources/persistence' +import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' @@ -82,10 +83,27 @@ const ResourceAttachmentSchema = z.object({ 'log', 'scheduledtask', 'generic', + 'browser', + // Filtered out client-side rather than sent, but accepted here so a stray + // terminal attachment degrades to a no-op instead of rejecting the whole + // chat request. + 'terminal', ]), id: z.string().min(1), title: z.string().optional(), active: z.boolean().optional(), + /** + * Live page URL for `browser` attachments. The agent browser lives in the + * desktop app, so the client supplies its state — the server has nothing + * to resolve it from. Web-only: this string is interpolated into LLM + * context, and rejecting other schemes (file://, chrome://…) keeps local + * host paths from ever entering the copilot payload. + */ + url: z + .string() + .max(2048) + .regex(/^https?:\/\//, 'Must be an http(s) URL') + .optional(), }) const GENERIC_RESOURCE_TITLE: Record['type'], string> = { @@ -99,6 +117,21 @@ const GENERIC_RESOURCE_TITLE: Record['t log: 'Log', scheduledtask: 'Scheduled Task', generic: 'Resource', + browser: 'Browser', + terminal: 'Terminal', +} + +/** + * Synthetic client-side panels are context-only: never persisted to the chat. + * Browser tab metadata is persistable even though its live page is client-held. + * Shares the client's rule so the two layers cannot drift. + */ +function isPersistableAttachment(resource: z.infer): boolean { + return !isEphemeralResource({ + type: resource.type, + id: resource.id, + title: resource.title ?? '', + }) } const ChatContextSchema = z.object({ @@ -119,6 +152,8 @@ const ChatContextSchema = z.object({ 'integration', 'skill', 'mcp', + 'browser_tab', + 'terminal_tab', ]), label: z.string(), chatId: z.string().optional(), @@ -134,6 +169,8 @@ const ChatContextSchema = z.object({ skillId: z.string().optional(), serverId: z.string().optional(), scheduleId: z.string().optional(), + tabId: z.string().optional(), + terminalId: z.string().optional(), }) const ChatMessageSchema = z.object({ @@ -154,9 +191,44 @@ const ChatMessageSchema = z.object({ contexts: z.array(ChatContextSchema).optional(), commands: z.array(z.string()).optional(), userTimezone: z.string().optional(), + desktopCapabilities: z + .object({ + localFilesystem: z.boolean().optional(), + browser: z.boolean().optional(), + terminal: z.boolean().optional(), + terminals: z + .array( + z.object({ + id: z.string().max(64), + cwd: z.string().max(1024).optional(), + running: z.string().max(1024).optional(), + interactive: z.boolean().optional(), + active: z.boolean().optional(), + }) + ) + .max(8) + .optional(), + browserSessions: z + .array( + z.object({ + hostname: z + .string() + .max(253) + .regex(/^[a-z0-9.-]+$/), + evidence: z.enum(['sign-in-completed', 'cookies']), + lastObservedAt: z.string().datetime(), + }) + ) + .max(20) + .optional(), + }) + .optional(), + browserCapable: z.boolean().optional(), }) type UnifiedChatRequest = z.infer +type BrowserSessions = NonNullable['browserSessions'] +type Terminals = NonNullable['terminals'] type UnifiedChatBranch = | { kind: 'workflow' @@ -193,6 +265,11 @@ type UnifiedChatBranch = implicitFeedback?: string workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Terminals + browserSessions?: BrowserSessions }) => Promise> buildExecutionContext: (params: { userId: string @@ -224,6 +301,11 @@ type UnifiedChatBranch = userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string vfs?: VfsSnapshotV1 + desktopLocalFilesystem?: boolean + browserCapable?: boolean + terminalCapable?: boolean + terminals?: Terminals + browserSessions?: BrowserSessions }) => Promise> buildExecutionContext: (params: { userId: string @@ -314,6 +396,22 @@ async function resolveAgentContexts(params: { if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0 && workspaceId) { const results = await Promise.allSettled( resourceAttachments.map(async (resource) => { + // The live browser panel resolves from the attachment itself: its + // page state is client-held (the desktop app's embedded browser), + // not a workspace entity the server could look up. + if (resource.type === 'browser') { + if (!resource.url) return null + const title = resource.title?.trim() + return { + type: 'active_resource', + tag: resource.active ? '@active_tab' : '@open_tab', + content: `The user's ${ + resource.active ? 'currently visible browser tab' : 'other open browser tab' + } (driven by the browser subagent) is open on: ${ + title ? `"${title}" — ` : '' + }${resource.url}`, + } + } const ctx = await resolveActiveResourceContext( resource.type, resource.id, @@ -680,6 +778,11 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, + terminalCapable: payloadParams.terminalCapable, + terminals: payloadParams.terminals, + browserSessions: payloadParams.browserSessions, }, { selectedModel } ), @@ -737,6 +840,11 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, + browserCapable: payloadParams.browserCapable, + terminalCapable: payloadParams.terminalCapable, + terminals: payloadParams.terminals, + browserSessions: payloadParams.browserSessions, }, { selectedModel: '' } ), @@ -883,14 +991,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { } if (chatIsNew && actualChatId && body.resourceAttachments?.length) { - await persistChatResources( - actualChatId, - body.resourceAttachments.map((r) => ({ - type: r.type, - id: r.id, - title: r.title ?? GENERIC_RESOURCE_TITLE[r.type], - })) - ) + const persistable = body.resourceAttachments.filter(isPersistableAttachment) + if (persistable.length > 0) { + await persistChatResources( + actualChatId, + persistable.map((r) => ({ + type: r.type, + id: r.id, + title: r.title ?? GENERIC_RESOURCE_TITLE[r.type], + })) + ) + } } let pendingStreamWaitMs = 0 @@ -1065,6 +1176,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { implicitFeedback: body.implicitFeedback, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: + body.desktopCapabilities?.browser === true || body.browserCapable === true, + terminalCapable: body.desktopCapabilities?.terminal === true, + terminals: body.desktopCapabilities?.terminals, + browserSessions: body.desktopCapabilities?.browserSessions, }) : branch.buildPayload({ message: body.message, @@ -1080,6 +1197,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMetadata, workspaceContext, vfs, + desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, + browserCapable: + body.desktopCapabilities?.browser === true || body.browserCapable === true, + terminalCapable: body.desktopCapabilities?.terminal === true, + terminals: body.desktopCapabilities?.terminals, + browserSessions: body.desktopCapabilities?.browserSessions, }) }, activeOtelRoot.context diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 17c9aa98c14..54fce5d321c 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -143,6 +143,24 @@ export async function processContextsServer( currentWorkspaceId ) } + // Tabs resolve to a pointer, not their contents. The agent has tools + // that read a live tab, and by the time it acts the page may have + // navigated or the shell scrolled on — so naming the tab it should look + // at beats pasting a snapshot that was true when the message was sent. + if (ctx.kind === 'browser_tab' && ctx.tabId) { + return { + type: 'browser_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). Act on THIS tab — switch to it with browser_switch_tab and read it with browser_snapshot rather than assuming which tab they meant.`, + } + } + if (ctx.kind === 'terminal_tab' && ctx.terminalId) { + return { + type: 'terminal_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). Act on THIS terminal — pass that terminalId to the terminal tool, and read its screen before assuming what is in it.`, + } + } if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { return await processWorkflowBlockFromDb( ctx.workflowId, diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 3051019eda2..c6e0da22b0e 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -298,10 +298,12 @@ describe('custom blocks', () => { expect(buildWorkspaceMd(baseData())).not.toContain('## Custom Blocks') }) - it('never leaks custom blocks into the typed snapshot Go diffs (diff-safety)', () => { + it('carries custom blocks in the typed snapshot keyed by type (Go diffs the customBlocks kind)', () => { const withBlocks = buildVfsSnapshot(baseData({ customBlocks })) + expect(withBlocks.customBlocks).toEqual([ + { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, + ]) const without = buildVfsSnapshot(baseData()) - expect('customBlocks' in withBlocks).toBe(false) - expect(JSON.stringify(withBlocks)).toBe(JSON.stringify(without)) + expect(without.customBlocks).toEqual([]) }) }) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index f06cc1ec17a..faf2b3a2170 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -78,7 +78,6 @@ export interface WorkspaceMdData { role?: string | null }> envVariables: string[] - tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> customBlocks?: Array<{ type: string; name: string; description?: string }> mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> @@ -607,7 +606,9 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { .map((j) => ({ id: j.id, ...(j.title ? { title: j.title } : {}), - ...(j.prompt ? { prompt: j.prompt } : {}), + // Match WORKSPACE.md's preview truncation — full prompts are large, + // volatile-ish, and readable on demand at jobs/{title}/meta.json. + ...(j.prompt ? { prompt: j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt } : {}), ...(j.cronExpression ? { cronExpression: j.cronExpression } : {}), ...(j.status ? { status: j.status } : {}), ...(j.lifecycle ? { lifecycle: j.lifecycle } : {}), @@ -658,6 +659,11 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { })), envVars: data.envVariables, customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })), + customBlocks: (data.customBlocks ?? []).map((b) => ({ + type: b.type, + name: b.name, + ...(b.description ? { description: b.description } : {}), + })), mcpServers: (data.mcpServers ?? []).map((s) => ({ id: s.id, name: s.name, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 6081954a204..76322f7b64c 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -1292,7 +1292,16 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { type: 'object', }, MothershipStreamV1ToolStatus: { - enum: ['generating', 'executing', 'success', 'error', 'cancelled', 'skipped', 'rejected'], + enum: [ + 'generating', + 'awaiting_approval', + 'executing', + 'success', + 'error', + 'cancelled', + 'skipped', + 'rejected', + ], type: 'string', }, MothershipStreamV1ToolUI: { diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 81e98257ddd..5cdf7801bf8 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -30,6 +30,7 @@ export type MothershipStreamV1ToolExecutor = 'go' | 'sim' | 'client' export type MothershipStreamV1ToolMode = 'sync' | 'async' export type MothershipStreamV1ToolStatus = | 'generating' + | 'awaiting_approval' | 'executing' | 'success' | 'error' @@ -546,6 +547,7 @@ export const MothershipStreamV1ToolPhase = { export const MothershipStreamV1ToolStatus = { generating: 'generating', + awaiting_approval: 'awaiting_approval', executing: 'executing', success: 'success', error: 'error', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index ab599c3cf6e..88159f0d49b 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,6 +9,28 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' @@ -17,9 +39,6 @@ export interface ToolCatalogEntry { | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -56,9 +75,9 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -76,6 +95,7 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' @@ -96,10 +116,12 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' internal?: boolean @@ -107,6 +129,28 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' @@ -115,9 +159,6 @@ export interface ToolCatalogEntry { | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -154,9 +195,9 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -174,6 +215,7 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' @@ -194,19 +236,23 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' + requiresApproval?: boolean resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: | 'agent' | 'auth' + | 'browser' | 'deploy' | 'file' | 'knowledge' @@ -251,6 +297,364 @@ export const Auth: ToolCatalogEntry = { internal: true, } +export const Browser: ToolCatalogEntry = { + id: 'browser', + name: 'browser', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'browser', + internal: true, +} + +export const BrowserClick: ToolCatalogEntry = { + id: 'browser_click', + name: 'browser_click', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + clientExecutable: true, +} + +export const BrowserCloseTab: ToolCatalogEntry = { + id: 'browser_close_tab', + name: 'browser_close_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserExtract: ToolCatalogEntry = { + id: 'browser_extract', + name: 'browser_extract', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + clientExecutable: true, +} + +export const BrowserGoBack: ToolCatalogEntry = { + id: 'browser_go_back', + name: 'browser_go_back', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserGoForward: ToolCatalogEntry = { + id: 'browser_go_forward', + name: 'browser_go_forward', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserHover: ToolCatalogEntry = { + id: 'browser_hover', + name: 'browser_hover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + clientExecutable: true, +} + +export const BrowserListSessions: ToolCatalogEntry = { + id: 'browser_list_sessions', + name: 'browser_list_sessions', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserListTabs: ToolCatalogEntry = { + id: 'browser_list_tabs', + name: 'browser_list_tabs', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserNavigate: ToolCatalogEntry = { + id: 'browser_navigate', + name: 'browser_navigate', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserOpenTab: ToolCatalogEntry = { + id: 'browser_open_tab', + name: 'browser_open_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { url: { type: 'string', description: 'Optional URL to open the new tab at.' } }, + }, + clientExecutable: true, +} + +export const BrowserOpenUrl: ToolCatalogEntry = { + id: 'browser_open_url', + name: 'browser_open_url', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserPressKey: ToolCatalogEntry = { + id: 'browser_press_key', + name: 'browser_press_key', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + }, + }, + required: ['key'], + }, + clientExecutable: true, +} + +export const BrowserReadText: ToolCatalogEntry = { + id: 'browser_read_text', + name: 'browser_read_text', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + }, + }, + }, + clientExecutable: true, +} + +export const BrowserRequestTakeover: ToolCatalogEntry = { + id: 'browser_request_takeover', + name: 'browser_request_takeover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + clientExecutable: true, +} + +export const BrowserScreenshot: ToolCatalogEntry = { + id: 'browser_screenshot', + name: 'browser_screenshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserScroll: ToolCatalogEntry = { + id: 'browser_scroll', + name: 'browser_scroll', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { type: 'string', description: 'Scroll direction.', enum: ['up', 'down'] }, + }, + required: ['direction'], + }, + clientExecutable: true, +} + +export const BrowserSelectOption: ToolCatalogEntry = { + id: 'browser_select_option', + name: 'browser_select_option', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + value: { type: 'string', description: "The option's visible label or its value." }, + }, + required: ['elementId', 'value'], + }, + clientExecutable: true, +} + +export const BrowserSnapshot: ToolCatalogEntry = { + id: 'browser_snapshot', + name: 'browser_snapshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserSwitchTab: ToolCatalogEntry = { + id: 'browser_switch_tab', + name: 'browser_switch_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserType: ToolCatalogEntry = { + id: 'browser_type', + name: 'browser_type', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + submit: { type: 'boolean', description: 'Press Enter after typing. Default false.' }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + clientExecutable: true, +} + +export const BrowserWaitFor: ToolCatalogEntry = { + id: 'browser_wait_for', + name: 'browser_wait_for', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Optional visible text to wait for.' }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + clientExecutable: true, +} + export const CallIntegrationTool: ToolCatalogEntry = { id: 'call_integration_tool', name: 'call_integration_tool', @@ -278,6 +682,7 @@ export const CallIntegrationTool: ToolCatalogEntry = { required: ['toolId', 'description', 'arguments'], type: 'object', }, + requiresApproval: true, } export const CheckDeploymentStatus: ToolCatalogEntry = { @@ -484,72 +889,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const DeleteFile: ToolCatalogEntry = { - id: 'delete_file', - name: 'delete_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the delete succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const DeleteFileFolder: ToolCatalogEntry = { - id: 'delete_file_folder', - name: 'delete_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const DeleteWorkflow: ToolCatalogEntry = { - id: 'delete_workflow', - name: 'delete_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { type: 'string' }, - }, - }, - required: ['workflowIds'], - }, - requiredPermission: 'write', -} - export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { id: 'delete_workspace_mcp_server', name: 'delete_workspace_mcp_server', @@ -563,6 +902,7 @@ export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { required: ['serverId'], }, requiredPermission: 'admin', + requiresApproval: true, } export const Deploy: ToolCatalogEntry = { @@ -663,6 +1003,7 @@ export const DeployApi: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployChat: ToolCatalogEntry = { @@ -808,6 +1149,7 @@ export const DeployChat: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployCustomBlock: ToolCatalogEntry = { @@ -870,11 +1212,10 @@ export const DeployCustomBlock: ToolCatalogEntry = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -931,6 +1272,12 @@ export const DeployMcp: ToolCatalogEntry = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1013,6 +1360,7 @@ export const DeployMcp: ToolCatalogEntry = { required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', + requiresApproval: true, } export const DiffWorkflows: ToolCatalogEntry = { @@ -1209,7 +1557,7 @@ export const EnrichmentRun: ToolCatalogEntry = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1539,7 +1887,7 @@ export const FunctionExecute: ToolCatalogEntry = { timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -1551,6 +1899,7 @@ export const FunctionExecute: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } @@ -2209,7 +2558,7 @@ export const Glob: ToolCatalogEntry = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2227,7 +2576,7 @@ export const Grep: ToolCatalogEntry = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', description: 'Case insensitive search (default false).' }, lineNumbers: { @@ -2253,12 +2602,12 @@ export const Grep: ToolCatalogEntry = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2430,7 +2779,6 @@ export const KnowledgeBase: ToolCatalogEntry = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2450,7 +2798,11 @@ export const KnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -2461,8 +2813,8 @@ export const KnowledgeBase: ToolCatalogEntry = { export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { integration: { @@ -2527,8 +2879,8 @@ export const LoadDeployment: ToolCatalogEntry = { export const LoadIntegrationTool: ToolCatalogEntry = { id: 'load_integration_tool', name: 'load_integration_tool', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { tool_ids: { @@ -2543,6 +2895,24 @@ export const LoadIntegrationTool: ToolCatalogEntry = { }, } +export const LoadSkill: ToolCatalogEntry = { + id: 'load_skill', + name: 'load_skill', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", + }, + }, + required: ['name'], + }, +} + export const ManageCredential: ToolCatalogEntry = { id: 'manage_credential', name: 'manage_credential', @@ -2638,31 +3008,6 @@ export const ManageCustomTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageFolder: ToolCatalogEntry = { - id: 'manage_folder', - name: 'manage_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - requiredPermission: 'write', -} - export const ManageMcpTool: ToolCatalogEntry = { id: 'manage_mcp_tool', name: 'manage_mcp_tool', @@ -2730,7 +3075,7 @@ export const ManageScheduledTask: ToolCatalogEntry = { cron: { type: 'string', description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", }, jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, jobIds: { @@ -3024,6 +3369,7 @@ export const PromoteToLive: ToolCatalogEntry = { required: ['version'], }, requiredPermission: 'admin', + requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -3278,6 +3624,7 @@ export const Redeploy: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const Respond: ToolCatalogEntry = { @@ -3329,6 +3676,31 @@ export const RestoreResource: ToolCatalogEntry = { requiredPermission: 'admin', } +export const Rm: ToolCatalogEntry = { + id: 'rm', + name: 'rm', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const Run: ToolCatalogEntry = { id: 'run', name: 'run', @@ -3473,6 +3845,7 @@ export const RunCode: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'table_input'], } @@ -3551,6 +3924,7 @@ export const RunWorkflow: ToolCatalogEntry = { }, }, clientExecutable: true, + requiresApproval: true, } export const RunWorkflowUntilBlock: ToolCatalogEntry = { @@ -3599,6 +3973,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { required: ['stopAfterBlockId'], }, clientExecutable: true, + requiresApproval: true, } export const ScheduledTask: ToolCatalogEntry = { @@ -3668,7 +4043,12 @@ export const SearchDocumentation: ToolCatalogEntry = { type: 'object', properties: { query: { type: 'string', description: 'The search query' }, - topK: { type: 'number', description: 'Number of results (max 10)' }, + topK: { + type: 'number', + description: + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, + }, }, required: ['query'], }, @@ -3737,7 +4117,11 @@ export const SearchKnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -3761,7 +4145,11 @@ export const SearchLibraryDocs: ToolCatalogEntry = { type: 'string', description: 'The question or topic to find documentation for - be specific', }, - version: { type: 'string', description: "Specific version (optional, e.g., '14', 'v2')" }, + version: { + type: 'string', + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", + }, }, required: ['library_name', 'query'], }, @@ -3812,7 +4200,7 @@ export const SearchPatterns: ToolCatalogEntry = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3906,12 +4294,14 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { operation: { type: 'string', enum: ['add', 'delete', 'edit'] }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3995,6 +4385,126 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const Terminal: ToolCatalogEntry = { + id: 'terminal', + name: 'terminal', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + clientExecutable: true, + requiresApproval: true, +} + export const UpdateDeploymentVersion: ToolCatalogEntry = { id: 'update_deployment_version', name: 'update_deployment_version', @@ -4116,6 +4626,12 @@ export const UserTable: ToolCatalogEntry = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')" }, enrichmentId: { type: 'string', @@ -4243,13 +4759,13 @@ export const UserTable: ToolCatalogEntry = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4283,12 +4799,6 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { type: 'integer' }, - }, rowId: { type: 'string', description: @@ -4366,7 +4876,6 @@ export const UserTable: ToolCatalogEntry = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4408,6 +4917,25 @@ export const UserTable: ToolCatalogEntry = { }, } +export const Wait: ToolCatalogEntry = { + id: 'wait', + name: 'wait', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, + }, + required: ['seconds'], + }, +} + export const Workflow: ToolCatalogEntry = { id: 'workflow', name: 'workflow', @@ -4601,7 +5129,6 @@ export const KnowledgeBaseOperation = { query: 'query', addFile: 'add_file', update: 'update', - delete: 'delete', deleteDocument: 'delete_document', updateDocument: 'update_document', listTags: 'list_tags', @@ -4624,7 +5151,6 @@ export const KnowledgeBaseOperationValues = [ KnowledgeBaseOperation.query, KnowledgeBaseOperation.addFile, KnowledgeBaseOperation.update, - KnowledgeBaseOperation.delete, KnowledgeBaseOperation.deleteDocument, KnowledgeBaseOperation.updateDocument, KnowledgeBaseOperation.listTags, @@ -4668,15 +5194,6 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageFolderOperation = { - delete: 'delete', -} as const - -export type ManageFolderOperation = - (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] - -export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const - export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -4776,13 +5293,42 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TerminalOperation = { + run: 'run', + read: 'read', + input: 'input', + kill: 'kill', + cwd: 'cwd', + list: 'list', + new: 'new', + switch: 'switch', + close: 'close', + panes: 'panes', + handoff: 'handoff', +} as const + +export type TerminalOperation = (typeof TerminalOperation)[keyof typeof TerminalOperation] + +export const TerminalOperationValues = [ + TerminalOperation.run, + TerminalOperation.read, + TerminalOperation.input, + TerminalOperation.kill, + TerminalOperation.cwd, + TerminalOperation.list, + TerminalOperation.new, + TerminalOperation.switch, + TerminalOperation.close, + TerminalOperation.panes, + TerminalOperation.handoff, +] as const + export const UserTableOperation = { create: 'create', createFromFile: 'create_from_file', importFile: 'import_file', get: 'get', getSchema: 'get_schema', - delete: 'delete', rename: 'rename', insertRow: 'insert_row', batchInsertRows: 'batch_insert_rows', @@ -4818,7 +5364,6 @@ export const UserTableOperationValues = [ UserTableOperation.importFile, UserTableOperation.get, UserTableOperation.getSchema, - UserTableOperation.delete, UserTableOperation.rename, UserTableOperation.insertRow, UserTableOperation.batchInsertRows, @@ -4864,6 +5409,28 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, + [Browser.id]: Browser, + [BrowserClick.id]: BrowserClick, + [BrowserCloseTab.id]: BrowserCloseTab, + [BrowserExtract.id]: BrowserExtract, + [BrowserGoBack.id]: BrowserGoBack, + [BrowserGoForward.id]: BrowserGoForward, + [BrowserHover.id]: BrowserHover, + [BrowserListSessions.id]: BrowserListSessions, + [BrowserListTabs.id]: BrowserListTabs, + [BrowserNavigate.id]: BrowserNavigate, + [BrowserOpenTab.id]: BrowserOpenTab, + [BrowserOpenUrl.id]: BrowserOpenUrl, + [BrowserPressKey.id]: BrowserPressKey, + [BrowserReadText.id]: BrowserReadText, + [BrowserRequestTakeover.id]: BrowserRequestTakeover, + [BrowserScreenshot.id]: BrowserScreenshot, + [BrowserScroll.id]: BrowserScroll, + [BrowserSelectOption.id]: BrowserSelectOption, + [BrowserSnapshot.id]: BrowserSnapshot, + [BrowserSwitchTab.id]: BrowserSwitchTab, + [BrowserType.id]: BrowserType, + [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteScheduledTask.id]: CompleteScheduledTask, @@ -4872,9 +5439,6 @@ export const TOOL_CATALOG: Record = { [CreateFile.id]: CreateFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, - [DeleteFile.id]: DeleteFile, - [DeleteFileFolder.id]: DeleteFileFolder, - [DeleteWorkflow.id]: DeleteWorkflow, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, @@ -4911,9 +5475,9 @@ export const TOOL_CATALOG: Record = { [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, + [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageFolder.id]: ManageFolder, [ManageMcpTool.id]: ManageMcpTool, [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, @@ -4931,6 +5495,7 @@ export const TOOL_CATALOG: Record = { [Redeploy.id]: Redeploy, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, + [Rm.id]: Rm, [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, @@ -4951,10 +5516,12 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, + [Wait.id]: Wait, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c7a5d93aac0..e527c57b544 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,6 +36,289 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + browser: { + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + browser_click: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_close_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_extract: { + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + resultSchema: undefined, + }, + browser_go_back: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_go_forward: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_hover: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_list_sessions: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_list_tabs: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_navigate: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_open_tab: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Optional URL to open the new tab at.', + }, + }, + }, + resultSchema: undefined, + }, + browser_open_url: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_press_key: { + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + }, + }, + required: ['key'], + }, + resultSchema: undefined, + }, + browser_read_text: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + }, + }, + }, + resultSchema: undefined, + }, + browser_request_takeover: { + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + resultSchema: undefined, + }, + browser_screenshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_scroll: { + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { + type: 'string', + description: 'Scroll direction.', + enum: ['up', 'down'], + }, + }, + required: ['direction'], + }, + resultSchema: undefined, + }, + browser_select_option: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + value: { + type: 'string', + description: "The option's visible label or its value.", + }, + }, + required: ['elementId', 'value'], + }, + resultSchema: undefined, + }, + browser_snapshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_switch_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_type: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + submit: { + type: 'boolean', + description: 'Press Enter after typing. Default false.', + }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + resultSchema: undefined, + }, + browser_wait_for: { + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'Optional visible text to wait for.', + }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + resultSchema: undefined, + }, call_integration_tool: { parameters: { properties: { @@ -272,68 +555,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the delete succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - delete_file_folder: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - delete_workflow: { - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { - type: 'string', - }, - }, - }, - required: ['workflowIds'], - }, - resultSchema: undefined, - }, delete_workspace_mcp_server: { parameters: { type: 'object', @@ -669,14 +890,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)', }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -736,6 +956,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1024,7 +1250,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1363,7 +1589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -2017,7 +2243,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2031,7 +2257,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', @@ -2060,12 +2286,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2242,7 +2468,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2263,8 +2488,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', }, message: { type: 'string', @@ -2348,6 +2574,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + load_skill: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", + }, + }, + required: ['name'], + }, + resultSchema: undefined, + }, manage_credential: { parameters: { type: 'object', @@ -2457,30 +2697,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['delete'], - }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, manage_mcp_tool: { parameters: { type: 'object', @@ -2545,7 +2761,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { cron: { type: 'string', description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", }, jobId: { type: 'string', @@ -3141,6 +3357,28 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + rm: { + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + resultSchema: undefined, + }, run: { parameters: { properties: { @@ -3454,7 +3692,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, topK: { type: 'number', - description: 'Number of results (max 10)', + description: + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, required: ['query'], @@ -3520,8 +3760,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', }, message: { type: 'string', @@ -3549,7 +3790,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, version: { type: 'string', - description: "Specific version (optional, e.g., '14', 'v2')", + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", }, }, required: ['library_name', 'query'], @@ -3602,7 +3844,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3694,12 +3936,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3784,6 +4028,120 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + terminal: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, update_deployment_version: { parameters: { type: 'object', @@ -3908,6 +4266,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')", @@ -4048,13 +4412,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4094,14 +4458,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { - type: 'integer', - }, - }, rowId: { type: 'string', description: @@ -4183,7 +4539,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4233,6 +4588,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + wait: { + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { + type: 'number', + description: 'How long to pause, in seconds. Capped at 120.', + }, + }, + required: ['seconds'], + }, + resultSchema: undefined, + }, workflow: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 430921c1f77..6db17a6329d 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -180,6 +180,7 @@ export const TraceAttr = { CopilotAsyncToolClaimedBy: 'copilot.async_tool.claimed_by', CopilotAsyncToolHasError: 'copilot.async_tool.has_error', CopilotAsyncToolIdsCount: 'copilot.async_tool.ids_count', + CopilotAsyncToolPermissionDecision: 'copilot.async_tool.permission_decision', CopilotAsyncToolStatus: 'copilot.async_tool.status', CopilotAsyncToolWorkerId: 'copilot.async_tool.worker_id', CopilotBranchKind: 'copilot.branch.kind', @@ -823,6 +824,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.async_tool.claimed_by', 'copilot.async_tool.has_error', 'copilot.async_tool.ids_count', + 'copilot.async_tool.permission_decision', 'copilot.async_tool.status', 'copilot.async_tool.worker_id', 'copilot.branch.kind', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index 9ade1eabbf4..5048cb2fbf7 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -9,7 +9,6 @@ // single source of truth and typos become compile errors. export const TraceSpan = { - AnthropicCountTokens: 'anthropic.count_tokens', AsyncToolStoreSet: 'async_tool_store.set', AuthRateLimitRecord: 'auth.rate_limit.record', AuthValidateKey: 'auth.validate_key', @@ -62,6 +61,8 @@ export const TraceSpan = { CopilotSseReadLoop: 'copilot.sse.read_loop', CopilotSubagentExecute: 'copilot.subagent.execute', CopilotToolWaitForClientResult: 'copilot.tool.wait_for_client_result', + CopilotToolWaitForPermission: 'copilot.tool.wait_for_permission', + CopilotToolPermissionDecide: 'copilot.tool_permission.decide', CopilotToolsHandleResourceSideEffects: 'copilot.tools.handle_resource_side_effects', CopilotToolsWriteCsvToTable: 'copilot.tools.write_csv_to_table', CopilotToolsWriteOutputFile: 'copilot.tools.write_output_file', @@ -71,7 +72,6 @@ export const TraceSpan = { CopilotVfsReadFile: 'copilot.vfs.read_file', GenAiAgentExecute: 'gen_ai.agent.execute', LlmStream: 'llm.stream', - ProviderRouterCountTokens: 'provider.router.count_tokens', ProviderRouterRoute: 'provider.router.route', SimUpdateCost: 'sim.update_cost', SimValidateApiKey: 'sim.validate_api_key', @@ -84,7 +84,6 @@ export type TraceSpanValue = (typeof TraceSpan)[TraceSpanKey] /** Readonly sorted list of every canonical span name. */ export const TraceSpanValues: readonly TraceSpanValue[] = [ - 'anthropic.count_tokens', 'async_tool_store.set', 'auth.rate_limit.record', 'auth.validate_key', @@ -137,6 +136,8 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.sse.read_loop', 'copilot.subagent.execute', 'copilot.tool.wait_for_client_result', + 'copilot.tool.wait_for_permission', + 'copilot.tool_permission.decide', 'copilot.tools.handle_resource_side_effects', 'copilot.tools.write_csv_to_table', 'copilot.tools.write_output_file', @@ -146,7 +147,6 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.vfs.read_file', 'gen_ai.agent.execute', 'llm.stream', - 'provider.router.count_tokens', 'provider.router.route', 'sim.update_cost', 'sim.validate_api_key', diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index 57cb552726e..37486a75078 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -5,6 +5,7 @@ * Structured workspace inventory snapshot Sim sends to Go; Go diffs successive snapshots into baseline+delta messages. */ export interface VfsSnapshotV1 { + customBlocks?: VfsSnapshotV1CustomBlock[] customTools?: VfsSnapshotV1NamedResource[] envVars?: string[] files?: VfsSnapshotV1File[] @@ -18,6 +19,15 @@ export interface VfsSnapshotV1 { workflows?: VfsSnapshotV1Workflow[] workspace?: VfsSnapshotV1Workspace } +/** + * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema + * via the `definition` "VfsSnapshotV1CustomBlock". + */ +export interface VfsSnapshotV1CustomBlock { + description?: string + name: string + type: string +} /** * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema * via the `definition` "VfsSnapshotV1NamedResource". diff --git a/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts b/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts new file mode 100644 index 00000000000..5aeff194a0e --- /dev/null +++ b/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts @@ -0,0 +1,93 @@ +import { db } from '@sim/db' +import { copilotChats, settings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' + +const logger = createLogger('CopilotToolAutoAllow') + +function toToolNameSet(value: unknown): Set { + if (!Array.isArray(value)) return new Set() + return new Set(value.filter((entry): entry is string => typeof entry === 'string')) +} + +/** + * The tool ids this request may run unprompted: the user's account-wide + * always-allow list, plus anything allowed for the rest of this chat. + * + * Read once per chat request and carried on the streaming context, so a turn + * that calls twenty gated tools does not issue twenty settings lookups. + */ +export async function getAutoAllowedTools( + userId: string | null | undefined, + chatId?: string | null +): Promise> { + if (!userId) return new Set() + try { + const [userRow, chatRow] = await Promise.all([ + db + .select({ tools: settings.copilotAutoAllowedTools }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) + .then((rows) => rows[0]), + chatId + ? db + .select({ tools: copilotChats.autoAllowedTools }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) + .then((rows) => rows[0]) + : Promise.resolve(undefined), + ]) + return new Set([...toToolNameSet(userRow?.tools), ...toToolNameSet(chatRow?.tools)]) + } catch (error) { + // Fail closed: an unreadable preference list means we prompt, never that we + // silently run a gated tool. + logger.warn('Failed to read auto-allowed tools; treating as empty', { + userId, + chatId, + error: toError(error).message, + }) + return new Set() + } +} + +/** + * Adds a tool to the user's account-wide always-allow list. + * + * Written as a single containment-guarded append so two prompts answered with + * "always allow" at the same moment cannot clobber each other the way a + * read-modify-write would. + */ +export async function addAutoAllowedTool(userId: string, toolName: string): Promise { + const entry = JSON.stringify([toolName]) + await db + .insert(settings) + .values({ + id: generateShortId(), + userId, + copilotAutoAllowedTools: [toolName], + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: settings.userId, + set: { + copilotAutoAllowedTools: sql`CASE WHEN ${settings.copilotAutoAllowedTools} @> ${entry}::jsonb THEN ${settings.copilotAutoAllowedTools} ELSE ${settings.copilotAutoAllowedTools} || ${entry}::jsonb END`, + updatedAt: new Date(), + }, + }) +} + +/** Adds a tool to one chat's allow list, leaving the user's other chats alone. */ +export async function addChatAutoAllowedTool(chatId: string, toolName: string): Promise { + const entry = JSON.stringify([toolName]) + await db + .update(copilotChats) + .set({ + autoAllowedTools: sql`CASE WHEN ${copilotChats.autoAllowedTools} @> ${entry}::jsonb THEN ${copilotChats.autoAllowedTools} ELSE ${copilotChats.autoAllowedTools} || ${entry}::jsonb END`, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) +} diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts new file mode 100644 index 00000000000..718b6e9e3e6 --- /dev/null +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -0,0 +1,156 @@ +import type { CopilotToolPermissionDecision } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' + +const logger = createLogger('CopilotToolPermission') + +export const TOOL_PERMISSION_DECISION = { + allow: 'allow', + /** Allowed for the rest of this chat only. */ + allow_chat: 'allow_chat', + /** Allowed in every chat, from now on. */ + always_allow: 'always_allow', + skip: 'skip', +} as const satisfies Record + +export type ToolPermissionDecision = CopilotToolPermissionDecision + +export interface ToolPermissionEnvelope { + toolCallId: string + decision: ToolPermissionDecision + toolName?: string + decidedAt?: string +} + +/** Every allow variant runs the tool; they differ only in what gets remembered. */ +export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { + return decision !== TOOL_PERMISSION_DECISION.skip +} + +/** True for the decisions that suppress future prompts for the same tool. */ +export function decisionSuppressesFuturePrompts(decision: ToolPermissionDecision): boolean { + return ( + decision === TOOL_PERMISSION_DECISION.allow_chat || + decision === TOOL_PERMISSION_DECISION.always_allow + ) +} + +export function isToolPermissionDecision(value: unknown): value is ToolPermissionDecision { + return ( + value === TOOL_PERMISSION_DECISION.allow || + value === TOOL_PERMISSION_DECISION.allow_chat || + value === TOOL_PERMISSION_DECISION.always_allow || + value === TOOL_PERMISSION_DECISION.skip + ) +} + +type ToolPermissionGlobal = typeof globalThis & { + _toolPermissionChannel?: PubSubChannel +} + +const _g = globalThis as ToolPermissionGlobal +if (!_g._toolPermissionChannel) { + _g._toolPermissionChannel = createPubSubChannel({ + channel: 'copilot:tool-permission', + label: 'CopilotToolPermission', + }) +} +const toolPermissionChannel = _g._toolPermissionChannel + +export function publishToolPermissionDecision(event: ToolPermissionEnvelope): void { + logger.info('Publishing tool permission decision', { + toolCallId: event.toolCallId, + decision: event.decision, + }) + toolPermissionChannel.publish(event) +} + +/** + * Read a decision straight from the durable async tool row. + * + * This is the reload path: the prompt outlives the browser tab, so the answer + * has to be recoverable from Postgres rather than only from a live pubsub + * message. + */ +export async function getToolPermissionDecision( + toolCallId: string +): Promise { + const row = await getAsyncToolCall(toolCallId).catch((err) => { + logger.warn('Failed to read tool permission decision', { + toolCallId, + error: toError(err).message, + }) + return null + }) + if (!row?.permissionDecision) return null + return { + toolCallId, + decision: row.permissionDecision, + toolName: row.toolName, + decidedAt: row.permissionDecidedAt?.toISOString(), + } +} + +/** + * Block until the user answers a permission prompt. + * + * Mirrors `waitForToolConfirmation`: subscribe first, then read the durable + * row, so a decision that lands between those two steps — or that was made + * against a different server instance, or before this waiter existed at all — + * is still picked up. Resolves `null` on timeout or abort; callers treat that + * as "no decision" and fail the tool rather than running it. + */ +export async function waitForToolPermissionDecision( + toolCallId: string, + timeoutMs: number, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve) => { + let settled = false + let timeoutId: ReturnType | null = null + let unsubscribe: (() => void) | null = null + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId) + if (unsubscribe) unsubscribe() + abortSignal?.removeEventListener('abort', onAbort) + } + + const settle = (value: ToolPermissionEnvelope | null) => { + if (settled) return + settled = true + cleanup() + resolve(value) + } + + const onAbort = () => settle(null) + + unsubscribe = toolPermissionChannel.subscribe((event) => { + if (event.toolCallId !== toolCallId) return + if (!isToolPermissionDecision(event.decision)) return + logger.info('Resolved tool permission from pubsub', { + toolCallId, + decision: event.decision, + }) + settle(event) + }) + + timeoutId = setTimeout(() => settle(null), timeoutMs) + if (abortSignal?.aborted) { + settle(null) + return + } + abortSignal?.addEventListener('abort', onAbort, { once: true }) + + void getToolPermissionDecision(toolCallId).then((existing) => { + if (!existing) return + logger.info('Resolved tool permission from durable row', { + toolCallId, + decision: existing.decision, + }) + settle(existing) + }) + }) +} diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index a38e68a35d1..1fd556a76bf 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,6 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 3ea2b984ad2..ebc2ce9f1be 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,6 +32,7 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 396615728c2..eecd40cde88 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -43,6 +43,8 @@ import { decodeJsonStringPrefix, extractEditContent, runStreamLoop, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' import { AbortReason, createEvent, hasAbortMarker } from '@/lib/copilot/request/session' import { RequestTraceV1Outcome, TraceCollector } from '@/lib/copilot/request/trace' @@ -104,6 +106,7 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), + toolPermissions: { enabled: false, autoAllowed: new Set() }, } } @@ -567,16 +570,30 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', } - await expect( - runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { - timeout: 1000, - }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') - expect( - context.errors.some((message) => - message.includes('Copilot backend stream ended before a terminal event') - ) - ).toBe(true) + const failure = await runStreamLoop( + 'https://example.com/mothership/stream', + {}, + context, + execContext, + { timeout: 1000 } + ).then( + () => undefined, + (error: unknown) => error + ) + + // The backend answered 200 and ran the leg, so the failure must not + // masquerade as an HTTP status the resume loop treats as transient. + expect(failure).toBeInstanceOf(StreamEndedWithoutTerminalError) + expect(failure).not.toHaveProperty('status') + expect(failure).toMatchObject({ path: '/mothership/stream' }) + expect((failure as Error).message).toBe(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + expect(context.errors).toEqual([STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE]) + }) + + it('tells the user what happened without promising that a retry helps', () => { + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).not.toMatch(/try again/i) + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).not.toMatch(/\/api\//) + expect(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE).toMatch(/saved/i) }) it('reclassifies as aborted when the body closes without terminal but the abort marker is set', async () => { @@ -607,11 +624,7 @@ describe('copilot go stream helpers', () => { expect(hasAbortMarker).toHaveBeenCalledWith(context.messageId) expect(context.wasAborted).toBe(true) - expect( - context.errors.some((message) => - message.includes('Copilot backend stream ended before a terminal event') - ) - ).toBe(false) + expect(context.errors).toEqual([]) }) it('invokes onAbortObserved with MarkerObservedAtBodyClose when reclassifying via the abort marker', async () => { @@ -675,7 +688,7 @@ describe('copilot go stream helpers', () => { timeout: 1000, onAbortObserved, }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') + ).rejects.toThrow(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) expect(onAbortObserved).not.toHaveBeenCalled() }) @@ -706,7 +719,7 @@ describe('copilot go stream helpers', () => { runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { timeout: 1000, }) - ).rejects.toThrow('Copilot backend stream ended before a terminal event') + ).rejects.toThrow(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) expect(context.wasAborted).toBe(false) }) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 880e6483aa8..5ebe3be2c4b 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -96,6 +96,31 @@ export class BillingLimitError extends Error { } } +/** + * Shown to the user when a leg ends early. It must not promise that retrying + * helps: the backend has already produced its outcome for this leg, and the + * turn's completed work is persisted by the finalizer. + */ +export const STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE = + 'The assistant stopped before finishing this turn. The work it already completed has been saved — send a message to continue from there.' + +/** + * The SSE body closed after a `200` response without a terminal event: the + * backend accepted the leg, ran it, and ended it on whatever outcome it reached + * in-band. Distinct from {@link CopilotBackendError} because there is no HTTP + * failure here — the leg is already claimed on the backend, so the outcome is + * deterministic and re-posting it cannot change anything. + */ +export class StreamEndedWithoutTerminalError extends Error { + readonly path: string + + constructor(path: string) { + super(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + this.name = 'StreamEndedWithoutTerminalError' + this.path = path + } +} + /** * Options for the shared stream processing loop. */ @@ -352,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context) + await prePersistClientExecutableToolCall(streamEvent, context, options) try { await options.onEvent?.(streamEvent) @@ -486,15 +511,14 @@ export async function runStreamLoop( endedOn = CopilotSseCloseReason.Aborted } else { const streamPath = new URL(fetchUrl).pathname - const message = `Copilot backend stream ended before a terminal event on ${streamPath}` - context.errors.push(message) + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) logger.error('Copilot backend stream ended before a terminal event', { path: streamPath, requestId: context.requestId, messageId: context.messageId, }) endedOn = CopilotSseCloseReason.ClosedNoTerminal - throw new CopilotBackendError(message, { status: 503 }) + throw new StreamEndedWithoutTerminalError(streamPath) } } } catch (error) { diff --git a/apps/sim/lib/copilot/request/handlers/complete.ts b/apps/sim/lib/copilot/request/handlers/complete.ts index c2ea991ea1e..ff826aaadf0 100644 --- a/apps/sim/lib/copilot/request/handlers/complete.ts +++ b/apps/sim/lib/copilot/request/handlers/complete.ts @@ -24,5 +24,6 @@ export const handleCompleteEvent: StreamHandler = (event, context) => { } } + context.completionStatus = event.payload.status context.streamComplete = true } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index da5991703eb..fb595791464 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -6,11 +6,14 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TraceCollector } from '@/lib/copilot/request/trace' -const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() => ({ - isSimExecuted: vi.fn().mockReturnValue(true), - executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }), - ensureHandlersRegistered: vi.fn(), -})) +const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( + () => ({ + isSimExecuted: vi.fn().mockReturnValue(true), + executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }), + ensureHandlersRegistered: vi.fn(), + toolRequiresApproval: vi.fn().mockReturnValue(false), + }) +) const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = vi.hoisted(() => ({ @@ -29,6 +32,7 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ executeTool, ensureHandlersRegistered, getToolEntry: vi.fn().mockReturnValue(undefined), + toolRequiresApproval, })) vi.mock('@/lib/copilot/async-runs/repository', () => ({ @@ -56,6 +60,7 @@ vi.mock('@/lib/copilot/request/tools/client', () => ({ import { MothershipStreamV1AsyncToolRecordStatus, + MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1ResourceOp, MothershipStreamV1RunKind, @@ -66,7 +71,11 @@ import { MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { sseHandlers, subAgentHandlers } from '@/lib/copilot/request/handlers' +import { + prePersistClientExecutableToolCall, + sseHandlers, + subAgentHandlers, +} from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' describe('sse-handlers tool lifecycle', () => { @@ -100,6 +109,7 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], + toolPermissions: { enabled: false, autoAllowed: new Set() }, } execContext = { userId: 'user-1', @@ -107,6 +117,169 @@ describe('sse-handlers tool lifecycle', () => { } }) + it('pre-persists browser tools as pending for the desktop authorization claim', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + + await prePersistClientExecutableToolCall( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'browser-tool-1', + toolName: 'browser_list_tabs', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context + ) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'browser-tool-1', + toolName: 'browser_list_tabs', + args: {}, + status: MothershipStreamV1AsyncToolRecordStatus.pending, + }) + }) + + it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'deploy-1', + toolName: 'deploy_api', + arguments: { versionName: 'v2' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + // A sim-routed tool normally gets no durable row at all; a gated one must, + // because the decision is posted against it after a reload. + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'deploy-1', + toolName: 'deploy_api', + args: { versionName: 'v2' }, + status: MothershipStreamV1AsyncToolRecordStatus.pending, + }) + expect(event.payload.status).toBe('awaiting_approval') + }) + + it('clears a Go-stamped approval frame when the gate is off', async () => { + // Go stamps integration calls regardless of Sim's feature flag. Forwarding + // that stamp with nothing gating behind it would draw a card whose buttons + // answer into a disabled endpoint. + toolRequiresApproval.mockReturnValue(false) + context.runId = 'run-1' + context.toolPermissions = { enabled: false, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gmail-1', + toolName: 'gmail_read_v2', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'awaiting_approval', + }, + } as unknown as StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + expect((event.payload as { status?: string }).status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('clears a Go-stamped approval frame on an internal tool', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'internal-1', + toolName: 'deploy', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'awaiting_approval', + ui: { internal: true }, + }, + } as unknown as StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + // An internal tool draws no row at all, so it can never host a prompt. + expect((event.payload as { status?: string }).status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('leaves an already always-allowed tool ungated', async () => { + toolRequiresApproval.mockReturnValue(true) + context.runId = 'run-1' + context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'deploy-2', + toolName: 'deploy_api', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + expect(event.payload.status).toBeUndefined() + expect(upsertAsyncToolCall).not.toHaveBeenCalled() + }) + + it('keeps non-browser client tools in the established running state', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + + await prePersistClientExecutableToolCall( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'client-tool-1', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context + ) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'client-tool-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: MothershipStreamV1AsyncToolRecordStatus.running, + }) + }) + it('keeps only the latest post-tool assistant text for headless final content', async () => { await sseHandlers.text( { @@ -303,6 +476,59 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { + waitForToolCompletion.mockResolvedValueOnce({ + status: 'success', + data: { content: 'hello', totalLines: 1 }, + }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-user-local-read', + toolName: 'read', + arguments: { path: 'user-local/Project--mount/README.md' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(executeTool).not.toHaveBeenCalled() + }) + + it('keeps an ordinary static VFS read on the Sim executor', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-workspace-read', + toolName: 'read', + arguments: { path: 'WORKSPACE.md' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(executeTool).toHaveBeenCalled() + expect(waitForToolCompletion).not.toHaveBeenCalled() + }) + it('does not add hidden tool calls to content blocks', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } }) @@ -1175,6 +1401,24 @@ describe('sse-handlers tool lifecycle', () => { expect(context.streamComplete).toBe(false) }) + it('records the terminal completion status so a finished turn can outrank an in-band failure', async () => { + context.errors.push('subagent build failed') + + await sseHandlers.complete( + { + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) + expect(context.streamComplete).toBe(true) + expect(context.errors).toEqual(['subagent build failed']) + }) + it('routes resource events through an explicit main-lane handler', async () => { expect(() => sseHandlers.resource( diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 0e30aaee0b2..880edae7433 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -1,11 +1,17 @@ +import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncCompletionSignal, +} from '@/lib/copilot/async-runs/lifecycle' import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, type MothershipStreamV1ToolCallDescriptor, + MothershipStreamV1ToolExecutor, MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' @@ -21,6 +27,11 @@ import { import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + runGatedToolExecution, + TOOL_AWAITING_APPROVAL_STATUS, + toolCallNeedsApproval, +} from '@/lib/copilot/request/tools/permission' import type { ExecutionContext, OrchestratorOptions, @@ -30,6 +41,7 @@ import type { } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' @@ -142,14 +154,22 @@ function rebindResolvedIntegrationCall( /** * Upsert the durable `async_tool_calls` row before the authoritative tool-call - * SSE frame is forwarded to the client, so `/api/copilot/confirm` can never - * race ahead of the row that identifies the call. This is the sole - * persistence point for client-executable tools; gating mirrors the - * client-wait branch in `dispatchToolExecution`. + * SSE frame is forwarded to the client, so `/api/copilot/confirm` and + * `/api/copilot/tool-permission` can never race ahead of the row that + * identifies the call. This is the sole persistence point for client-executable + * tools; gating mirrors the client-wait branch in `dispatchToolExecution`. + * + * A tool awaiting user approval is also persisted here whatever its route, + * because the prompt has to outlive the page: the row is what a reloaded tab's + * decision posts against. + * + * Also stamps `awaiting_approval` onto the outgoing frame so the browser and + * the persisted content block both record that the call is gated. */ export async function prePersistClientExecutableToolCall( event: StreamEvent, - context: StreamingContext + context: StreamingContext, + options?: OrchestratorOptions ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -160,14 +180,44 @@ export async function prePersistClientExecutableToolCall( if (isPartial) return const ui = getToolCallUI(data) - if (!ui.clientExecutable) return - const catalogEntry = getToolEntry(data.toolName) const isInternal = ui.internal === true || catalogEntry?.internal === true + + // Go stamps this for resolved integration operations; Sim stamps it below for + // catalog-declared tools. Normalizing here means the dispatch path only ever + // has to read the frame. + // + // Resolved before the internal short-circuit on purpose: a stamp that + // survives to the client with nothing gating it behind renders a card whose + // buttons answer into the void. + const frameRequestsApproval = data.status === TOOL_AWAITING_APPROVAL_STATUS + const gated = + !isInternal && + toolCallNeedsApproval( + data.toolName, + context, + options ?? {}, + frameRequestsApproval, + data.arguments + ) + if (gated) { + data.status = TOOL_AWAITING_APPROVAL_STATUS + } else if (frameRequestsApproval) { + // Go asked for a prompt this surface will not hold — the feature is off, + // the tool is internal, or the user already allowed it for good. Clear the + // stamp so the row renders as an ordinary call. + data.status = undefined + } + if (isInternal) return - const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName) - if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient) return + if (!gated) { + if (!ui.clientExecutable) return + + const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName) + const userLocalVfsCall = isUserLocalVfsToolCall(data.toolName, data.arguments) + if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient && !userLocalVfsCall) return + } if (!context.runId) return @@ -176,7 +226,16 @@ export async function prePersistClientExecutableToolCall( toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, - status: MothershipStreamV1AsyncToolRecordStatus.running, + // Browser and terminal actions cross a second, native authorization + // boundary. Leave those rows pending until Electron atomically claims + // them — the authorize endpoint only hands over a pending call, so a row + // that arrives already running can never be executed natively. All other + // client tools retain the established "already dispatched" running state. + // A gated tool is likewise pending: nothing has been dispatched yet. + status: + gated || isBrowserToolName(data.toolName) || isTerminalToolName(data.toolName) + ? MothershipStreamV1AsyncToolRecordStatus.pending + : MothershipStreamV1AsyncToolRecordStatus.running, }).catch((err) => { logger.warn('Failed to pre-persist async tool row before forwarding call frame', { toolCallId: data.toolCallId, @@ -426,7 +485,9 @@ async function handleCallPhase( execContext, options, clientExecutable, - scope + scope, + isToolHiddenInUi(toolName) || ui.hidden === true, + data.status === TOOL_AWAITING_APPROVAL_STATUS ) } @@ -551,12 +612,14 @@ async function dispatchToolExecution( execContext: ExecutionContext, options: OrchestratorOptions, clientExecutable: boolean, - scope: ToolScope + scope: ToolScope, + hiddenInUi = false, + frameRequestsApproval = false ): Promise { const scopeLabel = scope === 'subagent' ? 'subagent ' : '' - const fireToolExecution = () => { - const pendingPromise = (async () => { + const fireToolExecution = (): Promise => { + return (async () => { return executeToolAndReport(toolCallId, context, execContext, options) })().catch((err) => { logger.error(`Parallel ${scopeLabel}tool execution failed`, { @@ -570,83 +633,111 @@ async function dispatchToolExecution( data: { error: 'Tool execution failed' }, } }) - registerPendingToolPromise(context, toolCallId, pendingPromise) } - if (options.interactive === false) { - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() + // Returns the promise instead of registering it, so the permission gate can + // wrap the whole thing in one pending promise that stays unsettled until the + // tool has actually run. Null means nothing was dispatched. + const startExecution = (): Promise | null => { + if (options.interactive === false) { + if (options.autoExecuteTools === false) return null + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() + } + + if (clientExecutable) { + const delegateWorkflowRunToClient = isWorkflowToolName(toolName) + const userLocalVfsCall = isUserLocalVfsToolCall(toolName, args) + if (isSimExecuted(toolName) && !delegateWorkflowRunToClient && !userLocalVfsCall) { + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() } + return waitForClientExecution() } + + if (options.autoExecuteTools === false) return null + if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null + return fireToolExecution() + } + + if (toolCallNeedsApproval(toolName, context, options, frameRequestsApproval, args)) { + registerPendingToolPromise( + context, + toolCallId, + runGatedToolExecution( + toolCall, + toolCallId, + toolName, + args, + clientExecutable + ? MothershipStreamV1ToolExecutor.client + : MothershipStreamV1ToolExecutor.sim, + context, + options, + startExecution, + !hiddenInUi + ) + ) return } - if (clientExecutable) { - const delegateWorkflowRunToClient = isWorkflowToolName(toolName) - if (isSimExecuted(toolName) && !delegateWorkflowRunToClient) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } else { - toolCall.status = 'executing' - const pendingPromise = withCopilotSpan( - TraceSpan.CopilotToolWaitForClientResult, - { - [TraceAttr.ToolName]: toolName, - [TraceAttr.ToolCallId]: toolCallId, - [TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS, - ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), - }, - async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) - span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) - if (completion) { - span.setAttribute(TraceAttr.ToolOutcome, completion.status) - } - handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } - await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) - return ( - completion ?? { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool completion missing', - data: { error: 'Tool completion missing' }, - } - ) - } - ).catch((err) => { - logger.error(`Client-executable ${scopeLabel}tool wait failed`, { + const pending = startExecution() + if (pending) registerPendingToolPromise(context, toolCallId, pending) + + /** + * A client-executed tool runs in the browser or desktop app; this side only + * waits for it to report back through `/api/copilot/confirm`. + */ + function waitForClientExecution(): Promise { + toolCall.status = 'executing' + return withCopilotSpan( + TraceSpan.CopilotToolWaitForClientResult, + { + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS, + ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), + }, + async (span) => { + const completion = await waitForToolCompletion( toolCallId, - toolName, - error: toError(err).message, - }) - return { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool wait failed', - data: { error: 'Tool wait failed' }, + options.timeout || STREAM_TIMEOUT_MS, + options.abortSignal + ) + span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) + if (completion) { + span.setAttribute(TraceAttr.ToolOutcome, completion.status) + } + handleClientCompletion(toolCall, toolCallId, completion) + if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + await markAsyncToolDelivered(toolCallId).catch((err) => { + logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { + toolCallId, + toolName, + error: toError(err).message, + }) + }) } + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) + return ( + completion ?? { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool completion missing', + data: { error: 'Tool completion missing' }, + } + ) + } + ).catch((err) => { + logger.error(`Client-executable ${scopeLabel}tool wait failed`, { + toolCallId, + toolName, + error: toError(err).message, }) - registerPendingToolPromise(context, toolCallId, pendingPromise) - } - return - } - - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } + return { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool wait failed', + data: { error: 'Tool wait failed' }, + } + }) } } diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 4ffc7bcdbf9..68fb457f076 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run' @@ -16,6 +17,7 @@ describe('resume leg context isolate/merge contract', () => { usage: { prompt: 10, completion: 5 }, cost: { input: 1, output: 2, total: 3 }, errors: ['pre-existing'], + completionStatus: MothershipStreamV1CompletionStatus.complete, }) const leg = makeResumeLegContext(base) @@ -28,6 +30,7 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.errors).toEqual([]) expect(leg.streamComplete).toBe(false) expect(leg.awaitingAsyncContinuation).toBeUndefined() + expect(leg.completionStatus).toBeUndefined() // A leg's own errors array is a fresh array (not the shared one) so a leg's // retry rollback can't truncate a sibling's errors. @@ -49,6 +52,7 @@ describe('resume leg context isolate/merge contract', () => { leg.usage = { prompt: 100, completion: 50 } leg.cost = { input: 4, output: 5, total: 9 } leg.errors.push('leg-err') + leg.completionStatus = MothershipStreamV1CompletionStatus.complete mergeResumeLegOutputs(base, leg) @@ -58,6 +62,19 @@ describe('resume leg context isolate/merge contract', () => { expect(base.usage).toEqual({ prompt: 100, completion: 50 }) expect(base.cost).toEqual({ input: 4, output: 5, total: 9 }) expect(base.errors).toEqual(['pre', 'leg-err']) + expect(base.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) + }) + + it('leaves the turn unfinished when only child legs fold back', () => { + const base = createStreamingContext() + + // A child leg that folds with a terminal pause never carries the turn's + // terminal event, so it must not report the turn as finished. + const childLeg = makeResumeLegContext(base) + childLeg.errors.push('subagent failed') + mergeResumeLegOutputs(base, childLeg) + + expect(base.completionStatus).toBeUndefined() }) it('does not multiply pre-fanout content across many legs (N children + one join leg)', () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 89d63bfb61a..859ae7a7d44 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -22,7 +22,8 @@ const { mockGetMothershipSourceEnvHeaders, mockPrepareExecutionContext, mockRunStreamLoop, - mockToolWatchdogTimeoutMs, + mockPendingToolWaitBudgetMs, + mockGetAutoAllowedTools, mockUpdateRunStatus, mockEnv, } = vi.hoisted(() => ({ @@ -32,7 +33,8 @@ const { mockGetMothershipSourceEnvHeaders: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), - mockToolWatchdogTimeoutMs: vi.fn(() => 60_000), + mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), + mockGetAutoAllowedTools: vi.fn(async () => new Set()), mockUpdateRunStatus: vi.fn(), mockEnv: { COPILOT_API_KEY: undefined as string | undefined, @@ -65,9 +67,24 @@ vi.mock('@/lib/copilot/request/go/stream', () => { } } + const STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE = + 'The assistant stopped before finishing this turn. The work it already completed has been saved — send a message to continue from there.' + + class StreamEndedWithoutTerminalError extends Error { + path: string + + constructor(path: string) { + super(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + this.name = 'StreamEndedWithoutTerminalError' + this.path = path + } + } + return { BillingLimitError, CopilotBackendError, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, runStreamLoop: mockRunStreamLoop, } }) @@ -84,6 +101,12 @@ vi.mock('@/lib/core/config/env', () => ({ isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + getAutoAllowedTools: mockGetAutoAllowedTools, + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -95,11 +118,18 @@ vi.mock('@/lib/copilot/request/tools/billing', () => ({ vi.mock('@/lib/copilot/request/tools/executor', () => ({ executeToolAndReport: vi.fn(), forceFailHungToolCall: mockForceFailHungToolCall, - toolWatchdogTimeoutMs: mockToolWatchdogTimeoutMs, + pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, })) -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotBackendError } from '@/lib/copilot/request/go/stream' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { + CopilotBackendError, + STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, + StreamEndedWithoutTerminalError, +} from '@/lib/copilot/request/go/stream' import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' afterAll(resetEnvFlagsMock) @@ -108,12 +138,96 @@ describe('runCopilotLifecycle', () => { beforeEach(() => { vi.clearAllMocks() mockEnv.COPILOT_API_KEY = undefined - setEnvFlags({ isHosted: false }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) + setEnvFlags({ + isHosted: false, + isCopilotBillingAttributionV1Enabled: false, + isCopilotToolPermissionsEnabled: false, + }) + mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) }) + describe('tool permission feature flag', () => { + const runMothershipTurn = () => + runCopilotLifecycle( + { message: 'hello', messageId: 'stream-flag' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + goRoute: '/api/mothership', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + }, + } + ) + + it('stays entirely inert while the flag is off', async () => { + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(false) + // Never even reads the preference tables when disabled. + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + + it('arms the gate and loads the allow list once the flag is on', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowed.has('terminal_run')).toBe(true) + expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') + }) + + it('stays off for the workflow-scoped copilot even with the flag on', async () => { + // That panel has no permission card, so gating there would hang the turn + // on a prompt nothing draws. + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-flag-2' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + goRoute: '/api/copilot', + executionContext: { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + }, + } + ) + + expect(captured?.toolPermissions.enabled).toBe(false) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + }) + it('runs cancelled completion persistence when a stream throws after abort', async () => { const abortController = new AbortController() abortController.abort('stop') @@ -646,18 +760,16 @@ describe('runCopilotLifecycle', () => { } ) - // 2) First resume leg dies mid-stream like a transient provider error: - // it records an error AND throws a retryable 5xx. + // 2) First resume leg is refused before the backend takes it: it records an + // error AND throws a retryable 5xx, which releases the claim in Go. mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, _fetchOptions: RequestInit, context: StreamingContext ): Promise => { - context.errors.push( - 'Copilot backend stream ended before a terminal event on /api/tools/resume' - ) - throw new CopilotBackendError('backend stream ended before a terminal event', { + context.errors.push('Copilot backend error (503): service unavailable') + throw new CopilotBackendError('Copilot backend error (503): service unavailable', { status: 503, }) } @@ -699,7 +811,7 @@ describe('runCopilotLifecycle', () => { ) }) - it('marks resume legs willRetryOnStreamError except the final attempt', async () => { + it('does not promise Go a transparent stream-error retry it will not perform', async () => { const bodies: Record[] = [] const executionContext: ExecutionContext = { userId: 'user-1', @@ -740,8 +852,8 @@ describe('runCopilotLifecycle', () => { context: StreamingContext ): Promise => { bodies.push(JSON.parse(String(fetchOptions.body))) - context.errors.push('Copilot backend stream ended before a terminal event') - throw new CopilotBackendError('backend stream ended before a terminal event', { + context.errors.push('Copilot backend error (503): service unavailable') + throw new CopilotBackendError('Copilot backend error (503): service unavailable', { status: 503, }) } @@ -760,15 +872,175 @@ describe('runCopilotLifecycle', () => { } ) - // Initial + 3 resume attempts. + // Initial + 3 resume attempts: a 5xx is still transient, so the bounded + // retry budget is unchanged. expect(mockRunStreamLoop).toHaveBeenCalledTimes(4) - // Initial leg is never retried by this loop → no flag. - expect(bodies[0].willRetryOnStreamError).toBeUndefined() - // Resume attempts 0 and 1 will be retried on a stream error → flagged. - expect(bodies[1].willRetryOnStreamError).toBe(true) - expect(bodies[2].willRetryOnStreamError).toBe(true) - // Final attempt (2) is terminal → not flagged, so Go bills + surfaces it. - expect(bodies[3].willRetryOnStreamError).toBeUndefined() + // No leg claims a transparent retry. The flag made Go swallow the error tag + // that explains the failure, and a stream error is never retried now. + for (const body of bodies) { + expect(body.willRetryOnStreamError).toBeUndefined() + } + }) + + it('does not retry a resume leg the backend already claimed and ended early', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + // The resume leg is answered with 200 and then ends without a terminal + // event — the backend has claimed the checkpoint and reported its outcome, + // so re-posting it would only reproduce and re-bill the same failure. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'Moved the files and updated the workflow.' + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + throw new StreamEndedWithoutTerminalError('/api/tools/resume') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(result.success).toBe(false) + expect(result.cancelled).toBe(false) + expect(result.error).toBe(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + // Everything that streamed before the leg died is still the user's answer. + expect(result.content).toBe('Moved the files and updated the workflow.') + }) + + it('resolves the turn normally when the backend completes it after an in-band failure', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.errors.push('Subagent build failed: workflow validation error') + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'The build step failed; here is what I changed anyway.' + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(result).toEqual( + expect.objectContaining({ + success: true, + cancelled: false, + content: 'The build step failed; here is what I changed anyway.', + errors: undefined, + }) + ) + }) + + it('keeps the request failed when the backend terminates the turn as an error', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.errors.push('The provider is overloaded') + context.completionStatus = MothershipStreamV1CompletionStatus.error + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(result.success).toBe(false) + expect(result.errors).toEqual(['The provider is overloaded']) }) it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index cd862e8b088..40d100148a9 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -17,16 +17,19 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, } from '@/lib/copilot/generated/billing-protocol-v1' import { + MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' import { BillingLimitError, CopilotBackendError, runStreamLoop, + StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' import { getToolCallTerminalData, @@ -37,7 +40,7 @@ import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' import { executeToolAndReport, forceFailHungToolCall, - toolWatchdogTimeoutMs, + pendingToolWaitBudgetMs, } from '@/lib/copilot/request/tools/executor' import type { TraceCollector } from '@/lib/copilot/request/trace' import { RequestTraceV1SpanStatus } from '@/lib/copilot/request/trace' @@ -53,7 +56,11 @@ import type { import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' -import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags' +import { + isCopilotBillingAttributionV1Enabled, + isCopilotToolPermissionsEnabled, + isHosted, +} from '@/lib/core/config/env-flags' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' const logger = createLogger('CopilotLifecycle') @@ -90,6 +97,29 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { billingAttribution?: BillingAttributionSnapshot } +/** + * Seed the per-request tool permission state. + * + * This is the feature's single on-switch: everything downstream — stamping the + * wire frame, holding the tool, drawing the card, persisting a decision — keys + * off `enabled`, so a disabled request behaves exactly as it did before the + * feature existed and never touches the preference tables. + * + * Beyond the flag, gating is limited to interactive mothership chats: that is + * the only surface with a UI that can answer a prompt, so enabling it anywhere + * else would hang the turn until the orchestration timeout with nothing to click. + */ +async function resolveToolPermissions( + options: CopilotLifecycleOptions +): Promise { + const enabled = + isCopilotToolPermissionsEnabled && + options.interactive !== false && + (options.goRoute ?? '').startsWith('/api/mothership') + if (!enabled) return { enabled: false, autoAllowed: new Set() } + return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } +} + export async function runCopilotLifecycle( requestPayload: Record, options: CopilotLifecycleOptions @@ -177,6 +207,7 @@ export async function runCopilotLifecycle( executionId: resolvedExecutionId, runId: resolvedRunId, messageId: payloadMsgId, + toolPermissions: await resolveToolPermissions(lifecycleOptions), ...(lifecycleOptions.trace ? { trace: lifecycleOptions.trace } : {}), }) let onCompleteStarted = false @@ -191,8 +222,17 @@ export async function runCopilotLifecycle( hostedBillingRequest ) + // The backend's terminal `complete` is the turn's verdict. A failure it + // reported in-band on the way there — a tool or a subagent that failed and + // was handed back to the model as data — belongs to a turn that still + // finished, so it must not turn the whole request into an error and discard + // the work the user watched succeed. + const backendFinishedTurn = + context.completionStatus === MothershipStreamV1CompletionStatus.complete + const succeeded = !context.wasAborted && (backendFinishedTurn || context.errors.length === 0) + const result: OrchestratorResult = { - success: context.errors.length === 0 && !context.wasAborted, + success: succeeded, // `cancelled` is an explicit discriminator so callers can tell // "user hit Stop" (persist partial assistant content through the // cancelled completion path) from "backend errored" (do clear the @@ -208,7 +248,7 @@ export async function runCopilotLifecycle( toolCalls: buildToolCallSummaries(context), chatId: context.chatId, requestId: context.requestId, - errors: context.errors.length ? context.errors : undefined, + errors: !succeeded && context.errors.length ? context.errors : undefined, usage: context.usage, cost: context.cost, } @@ -338,6 +378,9 @@ function mothershipRequestHeaders( // - errors: a leg's transient retryable error (rolled back inside // runResumeLegWithRetry) must not truncate a concurrent sibling's shared // error array by index; each leg collects its own and merges the survivors. +// - completionStatus: the backend's terminal verdict, set only on the leg that +// carries the turn to its end; a stale one from a sibling would speak for a +// turn that leg never finished. // When adding a per-leg field, update BOTH functions (and the contract test in // resume-leg-context.test.ts). Exported only for that test. export function makeResumeLegContext(base: StreamingContext): StreamingContext { @@ -350,6 +393,7 @@ export function makeResumeLegContext(base: StreamingContext): StreamingContext { usage: undefined, cost: undefined, errors: [], + completionStatus: undefined, } } @@ -364,6 +408,7 @@ export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingC if (leg.sawMainToolCall) context.sawMainToolCall = true if (leg.wasAborted) context.wasAborted = true if (leg.errors.length > 0) context.errors.push(...leg.errors) + if (leg.completionStatus) context.completionStatus = leg.completionStatus } async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { @@ -415,15 +460,13 @@ async function runResumeLegWithRetry( let attempt = 0 for (;;) { const errorsBeforeAttempt = leg.errors.length - const willRetryOnStreamError = attempt < MAX_RESUME_ATTEMPTS - 1 - const legBody = willRetryOnStreamError ? { ...body, willRetryOnStreamError: true } : body try { await runStreamLoop( url, { method: 'POST', headers: mothershipRequestHeaders(hostedBillingRequest), - body: JSON.stringify(legBody), + body: JSON.stringify(body), }, leg, execContext, @@ -680,30 +723,17 @@ async function runCheckpointLoop( // Snapshot recorded errors before this attempt. If the attempt fails with // a retryable resume error, we roll back to this baseline before retrying // so a subsequent successful retry doesn't inherit the failed attempt's - // errors (e.g. "backend stream ended before a terminal event") and get + // errors (e.g. the 5xx the backend refused the leg with) and get // mis-finalized as `error`. const errorsBeforeAttempt = context.errors.length - // A resume leg that is not the last allowed attempt will be retried below - // on a retryable stream error. Tell Go so it treats a mid-flight provider - // error as non-terminal for the UI and suppresses the user-facing error tag - // that a recovered retry should not show. Billing is still flushed for - // every leg; /api/billing/update-cost records cumulative cost as a - // monotonic top-up, so the partial retry leg and the recovered terminal leg - // reconcile to the maximum cumulative total. Recomputed per attempt because - // the same payload is reused across retries. - const willRetryOnStreamError = isResume && resumeAttempt < MAX_RESUME_ATTEMPTS - 1 - const legPayload = willRetryOnStreamError - ? { ...payload, willRetryOnStreamError: true } - : payload - try { await runStreamLoop( `${mothershipBaseURL}${route}`, { method: 'POST', headers: mothershipRequestHeaders(hostedBillingRequest), - body: JSON.stringify(legPayload), + body: JSON.stringify(payload), }, context, execContext, @@ -805,7 +835,7 @@ async function runCheckpointLoop( const waitBudgetMs = Array.from(context.pendingToolPromises.keys()).reduce( (max, toolCallId) => - Math.max(max, toolWatchdogTimeoutMs(context.toolCalls.get(toolCallId)?.name)), + Math.max(max, pendingToolWaitBudgetMs(context.toolCalls.get(toolCallId))), 0 ) + TOOL_WATCHDOG_RESUME_GRACE_MS const waitSpan = context.trace.startSpan('Wait for Tools', 'lifecycle.wait_tools', { @@ -1098,7 +1128,11 @@ function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): function cancelPendingTools(context: StreamingContext): void { for (const [, toolCall] of context.toolCalls) { - if (toolCall.status === 'pending' || toolCall.status === 'executing') { + if ( + toolCall.status === 'pending' || + toolCall.status === 'executing' || + toolCall.status === 'awaiting_approval' + ) { setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.cancelled, error: 'Stopped by user', @@ -1107,10 +1141,25 @@ function cancelPendingTools(context: StreamingContext): void { } } +/** + * Only a leg the backend never took is worth re-posting: a network failure with + * no response at all, or a 5xx it answered with — Go releases the checkpoint + * claim on those, expecting the retry. + * + * Once the backend answers `200` the checkpoint is claimed and the leg runs to + * whatever outcome it reaches, so a leg that ends early is reporting a result, + * not a transport fault. Re-posting it reproduces the same result and bills the + * leg again — which is why the resume payload no longer claims + * `willRetryOnStreamError`: promising Go a transparent retry makes it suppress + * the error tag that explains the failure, and nothing here would retry it. + */ function isRetryableStreamError(error: unknown): boolean { if (error instanceof DOMException && error.name === 'AbortError') { return false } + if (error instanceof StreamEndedWithoutTerminalError) { + return false + } if (error instanceof CopilotBackendError) { return error.status !== undefined && error.status >= 500 } diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 789f8165fd9..656feb72663 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -2,7 +2,10 @@ import '@sim/testing/mocks/executor' import { describe, expect, it } from 'vitest' import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' -import { toolWatchdogTimeoutMs } from '@/lib/copilot/request/tools/executor' +import { + pendingToolWaitBudgetMs, + toolWatchdogTimeoutMs, +} from '@/lib/copilot/request/tools/executor' describe('toolWatchdogTimeoutMs', () => { it('gives request-scoped MCP tools the long-running watchdog', () => { @@ -13,3 +16,19 @@ describe('toolWatchdogTimeoutMs', () => { expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) }) }) + +describe('pendingToolWaitBudgetMs', () => { + it('waits on a person for as long as the whole turn allows', () => { + // The 60s default would force-fail a permission prompt while the user was + // still reading it, resuming Go before they ever answered. + expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'awaiting_approval' })).toBe( + TOOL_WATCHDOG_LONG_RUNNING_MS + ) + }) + + it('falls back to the tool\u2019s own watchdog once it is actually executing', () => { + expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'executing' })).toBe( + TOOL_WATCHDOG_DEFAULT_MS + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index bdb90c3cf48..fd59ba2fa8b 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -230,6 +230,21 @@ export function toolWatchdogTimeoutMs(toolName: string | undefined): number { : TOOL_WATCHDOG_DEFAULT_MS } +/** + * How long the resume gate may wait on one pending tool call. + * + * A call sitting on a permission prompt is waiting on a person, not on the + * executor, so the tool's own watchdog is the wrong bound — the 60s default + * would force-fail the prompt while the user was still reading it. Such a call + * gets the long-running budget, which matches the gate's own wait timeout. + */ +export function pendingToolWaitBudgetMs( + toolCall: Pick | undefined +): number { + if (toolCall?.status === 'awaiting_approval') return TOOL_WATCHDOG_LONG_RUNNING_MS + return toolWatchdogTimeoutMs(toolCall?.name) +} + class ToolExecutionTimeoutError extends Error { constructor(toolName: string, timeoutMs: number) { super( diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 139757524c5..cbad14c58e4 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -93,13 +93,11 @@ describe('serializeOutputForFile (json / txt / md)', () => { }) describe('normalizeOutputWorkspaceFileName', () => { - it('derives the leaf file name from workflow alias output paths', () => { - expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe( - 'changelog.md' + it('derives the leaf file name from nested, percent-encoded output paths', () => { + expect(normalizeOutputWorkspaceFileName('files/My%20Folder/notes.md')).toBe('notes.md') + expect(normalizeOutputWorkspaceFileName('files/My%20Folder/phase%201/implementation.md')).toBe( + 'implementation.md' ) - expect( - normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md') - ).toBe('implementation.md') }) it('still handles normal workspace file output paths', () => { diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts new file mode 100644 index 00000000000..78096cdc61b --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -0,0 +1,358 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TraceCollector } from '@/lib/copilot/request/trace' + +const { toolRequiresApproval, waitForToolPermissionDecision } = vi.hoisted(() => ({ + toolRequiresApproval: vi.fn().mockReturnValue(true), + waitForToolPermissionDecision: vi.fn(), +})) + +vi.mock('@/lib/copilot/tool-executor', () => ({ + toolRequiresApproval, + isSimExecuted: vi.fn().mockReturnValue(true), + getToolEntry: vi.fn().mockReturnValue(undefined), + executeTool: vi.fn(), + ensureHandlersRegistered: vi.fn(), +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, waitForToolPermissionDecision } +}) + +import { MothershipStreamV1ToolExecutor } from '@/lib/copilot/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { + runGatedToolExecution, + toolCallNeedsApproval, +} from '@/lib/copilot/request/tools/permission' +import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' + +function makeContext() { + const context = createStreamingContext({ runId: 'run-1' }) + context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.trace = new TraceCollector() + return context +} + +function makeToolCall(): ToolCallState { + return { + id: 'call-1', + name: 'terminal', + status: 'pending', + params: { operation: 'run', args: { command: 'ls' } }, + } +} + +function gate( + context: ReturnType, + toolCall: ToolCallState, + execute: () => Promise<{ status: string }> | null, + events: StreamEvent[] +) { + return runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + { + onEvent: (event) => { + events.push(event) + }, + }, + execute as () => Promise + ) +} + +describe('toolCallNeedsApproval', () => { + const runCall = { operation: 'run', args: { command: 'ls' } } + + it('gates a tool the catalog marks as requiring approval', () => { + expect(toolCallNeedsApproval('terminal', makeContext(), {}, false, runCall)).toBe(true) + }) + + it('does not gate terminal operations that only look at the screen', () => { + // The catalog flag is per tool, but a card for every `read` would train + // the user to click through the ones that matter. + const context = makeContext() + for (const operation of ['read', 'list', 'cwd', 'panes']) { + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation })).toBe(false) + } + }) + + it('does not gate a tool the user already always-allowed', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('terminal') + expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) + }) + + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { + expect( + toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) + ).toBe(false) + }) + + it('does not gate when the surface has the gate turned off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) + }) + + it('gates a resolved integration operation off the frame Go stamped', () => { + // gmail_read_v2 is request-local: it is not in the catalog at all, so the + // only thing marking it is the awaiting_approval status on the frame. + toolRequiresApproval.mockReturnValue(false) + expect(toolCallNeedsApproval('gmail_read_v2', makeContext(), {}, true)).toBe(true) + }) + + it('still honors always-allow for a frame-stamped integration operation', () => { + toolRequiresApproval.mockReturnValue(false) + const context = makeContext() + context.toolPermissions.autoAllowed.add('gmail_read_v2') + expect(toolCallNeedsApproval('gmail_read_v2', context, {}, true)).toBe(false) + }) +}) + +describe('gated tools are askable', () => { + it('every tool requiring approval renders a row the user can answer on', async () => { + // A gated tool with no visible row cannot be consented to. The runtime + // refuses to run such a call, so shipping one would silently disable the + // tool; this keeps the catalog honest instead. + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + const { getHiddenToolNames } = await vi.importActual< + typeof import('@/lib/copilot/tools/client/hidden-tools') + >('@/lib/copilot/tools/client/hidden-tools') + + const hidden = getHiddenToolNames() + const unaskable = Object.entries(TOOL_CATALOG) + .filter(([name, entry]) => entry.requiresApproval && (entry.internal || hidden.has(name))) + .map(([name]) => name) + + expect(unaskable).toEqual([]) + }) + + it('never marks a go-routed tool as requiring approval', async () => { + // Go executes these in-process and streams back a result; the call never + // reaches Sim's dispatch, so this gate has nothing to hold. Marking one + // would draw a permission card for work that already ran. + // + // call_integration_tool is the one exception, and only nominally: the + // catalog describes the gateway as go/sync, but at runtime Go resolves the + // operation and hands it to Sim on a checkpoint, stamping the frame with + // awaiting_approval. It is gated on that stamp, under the resolved + // operation's name, never under this one. + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + + const ungatable = Object.entries(TOOL_CATALOG) + .filter( + ([name, entry]) => + entry.requiresApproval && entry.route === 'go' && name !== 'call_integration_tool' + ) + .map(([name]) => name) + + expect(ungatable).toEqual([]) + }) + + it('gates the tools we intend to gate', async () => { + const { TOOL_CATALOG } = await vi.importActual< + typeof import('@/lib/copilot/generated/tool-catalog-v1') + >('@/lib/copilot/generated/tool-catalog-v1') + + expect( + Object.entries(TOOL_CATALOG) + .filter(([, entry]) => entry.requiresApproval) + .map(([name]) => name) + .sort() + ).toEqual([ + 'call_integration_tool', + 'delete_workspace_mcp_server', + 'deploy_api', + 'deploy_chat', + 'deploy_mcp', + 'function_execute', + 'promote_to_live', + 'redeploy', + 'run_code', + 'run_workflow', + 'run_workflow_until_block', + 'terminal', + ]) + }) +}) + +describe('runGatedToolExecution', () => { + beforeEach(() => { + vi.clearAllMocks() + toolRequiresApproval.mockReturnValue(true) + }) + + it('does not execute the tool until the user has answered', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + + let answer: (value: unknown) => void = () => {} + waitForToolPermissionDecision.mockReturnValue( + new Promise((resolve) => { + answer = resolve + }) + ) + + const pending = gate(context, toolCall, execute, []) + + // The decision is outstanding: nothing ran, and the promise the resume gate + // waits on has not settled. + await Promise.resolve() + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('awaiting_approval') + + answer({ toolCallId: 'call-1', decision: 'allow' }) + await pending + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('stays unsettled across the execution so the resume cannot run early', async () => { + const context = makeContext() + const toolCall = makeToolCall() + let finishExecution: (value: { status: string }) => void = () => {} + const execute = vi.fn().mockReturnValue( + new Promise((resolve) => { + finishExecution = resolve + }) + ) + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + const pending = gate(context, toolCall, execute, []) + let settled = false + void pending.then(() => { + settled = true + }) + + await vi.waitFor(() => expect(execute).toHaveBeenCalled()) + expect(settled).toBe(false) + + finishExecution({ status: 'success' }) + await pending + expect(settled).toBe(true) + }) + + it('reports a skip as a successful skipped result so the model adapts', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + const events: StreamEvent[] = [] + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'skip' }) + + const signal = await gate(context, toolCall, execute, events) + + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('skipped') + // Success keeps Go's consecutive-tool-failure breaker from counting a + // deliberate user decision as a malfunction. + expect(signal.status).toBe('success') + expect(toolCall.result).toMatchObject({ success: true }) + + const result = events.find((event) => event.payload?.phase === 'result') + expect(result?.payload).toMatchObject({ status: 'skipped', success: true }) + expect(result?.payload?.output).toMatchObject({ skipped: true, reason: 'user_declined' }) + }) + + it('treats allow-for-this-chat as an allow that also stops re-prompting', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) + }) + + it('does not suppress later prompts for a one-off allow', async () => { + const context = makeContext() + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(false) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) + + it('remembers always-allow for the rest of the turn', async () => { + const context = makeContext() + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'always_allow', + }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(false) + }) + + it('cancels rather than runs when the prompt is never answered', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + waitForToolPermissionDecision.mockResolvedValue(null) + + const signal = await gate(context, toolCall, execute, []) + + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('cancelled') + expect(signal.status).toBe('error') + }) + + it('refuses to run a gated tool whose row is hidden, rather than hanging the turn', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const execute = vi.fn() + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + const signal = await runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + {}, + execute as () => Promise, + false + ) + + expect(waitForToolPermissionDecision).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(toolCall.status).toBe('skipped') + expect(signal.status).toBe('success') + expect(toolCall.result?.output).toMatchObject({ reason: 'no_prompt_surface' }) + }) + + it('turns the row back into a running one once allowed', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const events: StreamEvent[] = [] + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision: 'allow' }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), events) + + const call = events.find((event) => event.payload?.phase === 'call') + expect(call?.payload).toMatchObject({ status: 'executing', toolCallId: 'call-1' }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts new file mode 100644 index 00000000000..a9829c8c155 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -0,0 +1,312 @@ +import { createLogger } from '@sim/logger' +import { TERMINAL_TOOL_NAME } from '@sim/terminal-protocol' +import { getErrorMessage } from '@sim/utils/errors' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + type MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { + decisionAllowsExecution, + decisionSuppressesFuturePrompts, + waitForToolPermissionDecision, +} from '@/lib/copilot/persistence/tool-permission' +import { withCopilotSpan } from '@/lib/copilot/request/otel' +import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' +import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' +import type { + OrchestratorOptions, + StreamingContext, + ToolCallState, +} from '@/lib/copilot/request/types' +import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor' + +const logger = createLogger('CopilotToolPermissionGate') + +/** + * Whether a `terminal` call is one worth stopping for. + * + * The catalog's approval flag is per tool, but the terminal tool covers both + * running commands and merely looking at the screen. Only running one is + * consequential; gating `read` or `list` would put a card in front of the user + * every time the agent glanced at a terminal, which trains them to click + * through the ones that matter. + */ +function terminalOperationNeedsApproval(args: Record | undefined): boolean { + return args?.operation === 'run' +} + +/** + * A human can take as long as they like to answer, so the wait is bounded only + * by the overall orchestration budget rather than a per-tool watchdog. + */ +const PERMISSION_WAIT_TIMEOUT_MS = ORCHESTRATION_TIMEOUT_MS + +export const TOOL_AWAITING_APPROVAL_STATUS = MothershipStreamV1ToolStatus.awaiting_approval + +/** + * Whether this call must be held for an explicit user decision. + * + * Headless and non-interactive runs (scheduled tasks, one-shot execute) are + * never gated: nobody is there to answer, and blocking them would hang the run + * until the orchestration timeout. + */ +export function toolCallNeedsApproval( + toolName: string, + context: StreamingContext, + options: OrchestratorOptions, + /** + * True when the incoming call frame already carries `awaiting_approval`. + * Go stamps this for resolved integration operations, whose definitions are + * request-local and so never appear in the generated catalog. + */ + frameRequestsApproval = false, + /** The call's arguments, for a tool whose gate depends on what it is doing. */ + args?: Record +): boolean { + if (!context.toolPermissions.enabled) return false + if (options.interactive === false) return false + + if (!frameRequestsApproval) { + if (!toolRequiresApproval(toolName)) return false + if (toolName === TERMINAL_TOOL_NAME && !terminalOperationNeedsApproval(args)) return false + // A go-routed tool executes inside mothership and never reaches Sim's + // dispatch, so there is nothing here to hold. Stamping the frame anyway + // would draw a card for work that already happened, with no waiter behind + // it. Go asks for those gates explicitly via the frame instead. + if (getToolEntry(toolName)?.route === 'go') { + logger.error('Tool declares requiresApproval but runs in Go, where Sim cannot gate it', { + toolName, + }) + return false + } + } + + return !context.toolPermissions.autoAllowed.has(toolName) +} + +function skipOutput(toolName: string) { + return { + skipped: true, + reason: 'user_declined', + message: `The user declined to run ${toolName}. Nothing was executed.`, + } +} + +function noPromptOutput(toolName: string) { + return { + skipped: true, + reason: 'no_prompt_surface', + message: `${toolName} requires the user's permission, but it has no visible row to ask on, so it was not run.`, + } +} + +/** + * Tell the client how a gated call ended without executing. + * + * Go's own tool_result for this call is suppressed (`markToolResultSeen`), so + * this synthetic frame is the only thing that moves the row out of its + * awaiting-approval state in the UI. + */ +async function emitGateResult( + toolCallId: string, + toolName: string, + executor: MothershipStreamV1ToolExecutor, + outcome: MothershipStreamV1ToolOutcome, + output: unknown, + options: OrchestratorOptions, + error?: string +): Promise { + try { + await options.onEvent?.({ + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: outcome === MothershipStreamV1ToolOutcome.skipped, + output, + status: outcome, + ...(error ? { error } : {}), + }, + }) + } catch (err) { + logger.warn('Failed to emit permission gate tool result', { + toolCallId, + toolName, + error: getErrorMessage(err), + }) + } +} + +/** + * Re-emit the call frame as `executing` once the user allows the tool. + * + * Without this the card would keep offering Allow/Skip until the tool finished, + * because no further call frame arrives from Go after a decision. + */ +async function emitApprovedCall( + toolCallId: string, + toolName: string, + executor: MothershipStreamV1ToolExecutor, + args: Record | undefined, + options: OrchestratorOptions +): Promise { + try { + await options.onEvent?.({ + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: MothershipStreamV1ToolStatus.executing, + ...(args ? { arguments: args } : {}), + }, + }) + } catch (err) { + logger.warn('Failed to emit approved tool call frame', { + toolCallId, + toolName, + error: getErrorMessage(err), + }) + } +} + +/** + * Hold a gated tool call until the user answers, then hand off to `execute`. + * + * The returned promise is what gets registered as the call's pending promise, + * and it deliberately stays unsettled across both the wait AND the subsequent + * execution. That is what keeps the mothership resume blocked: the checkpoint + * loop waits on the pending promises before it POSTs `/api/tools/resume`, so + * as long as this has not settled, no tool result reaches Go. + */ +export function runGatedToolExecution( + toolCall: ToolCallState, + toolCallId: string, + toolName: string, + args: Record | undefined, + executor: MothershipStreamV1ToolExecutor, + context: StreamingContext, + options: OrchestratorOptions, + execute: () => Promise | null, + /** + * False when the row this call would render on is hidden. Consent cannot be + * asked for, so the call is refused instead of silently run or left to hang. + */ + canPrompt = true +): Promise { + toolCall.status = 'awaiting_approval' + + return withCopilotSpan( + TraceSpan.CopilotToolWaitForPermission, + { + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), + }, + async (span): Promise => { + if (!canPrompt) { + // Fail closed. Running it would defeat the point of the flag, and + // waiting would hang the turn on a prompt that is never drawn. + logger.error('Gated tool has no visible row to prompt on; refusing to run it', { + toolCallId, + toolName, + }) + const output = noPromptOutput(toolName) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.skipped, + output, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.skipped, + output, + options + ) + return { status: MothershipStreamV1ToolOutcome.success, message: output.message } + } + + const decision = await waitForToolPermissionDecision( + toolCallId, + PERMISSION_WAIT_TIMEOUT_MS, + options.abortSignal + ) + + if (!decision) { + // Timed out or the turn was stopped. Fail the call rather than running + // it: an unanswered prompt is not consent. + span.setAttribute(TraceAttr.ToolOutcome, MothershipStreamV1ToolOutcome.cancelled) + const error = 'Timed out waiting for the user to approve this tool.' + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + output: { error }, + error, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.cancelled, + { error }, + options, + error + ) + return { status: MothershipStreamV1ToolOutcome.error, message: error } + } + + span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) + + if (decisionSuppressesFuturePrompts(decision.decision)) { + // Same-turn effect: a second call to this tool later in the turn must + // not re-prompt. The durable write (chat row or user settings) happens + // in the endpoint. + context.toolPermissions.autoAllowed.add(toolName) + } + + if (!decisionAllowsExecution(decision.decision)) { + const output = skipOutput(toolName) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.skipped, + output, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.skipped, + output, + options + ) + // Reported as success so a declined tool is a decision the model reads + // and adapts to, not a failure that counts toward Go's tool-failure + // breaker. + return { status: MothershipStreamV1ToolOutcome.success, message: output.message } + } + + await emitApprovedCall(toolCallId, toolName, executor, args, options) + + const execution = execute() + if (!execution) { + return { status: MothershipStreamV1ToolOutcome.success } + } + return execution + } + ) +} diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 9316dff9070..bf4908896db 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -1,5 +1,8 @@ import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { + type MothershipStreamV1CompletionStatus, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' import type { RequestTraceV1Span } from '@/lib/copilot/generated/request-trace-v1' import type { StreamEvent } from '@/lib/copilot/request/session' import type { TraceCollector } from '@/lib/copilot/request/trace' @@ -7,7 +10,7 @@ import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/to export type { StreamEvent } -export type LocalToolCallStatus = 'pending' | 'executing' +export type LocalToolCallStatus = 'pending' | 'executing' | 'awaiting_approval' export type ToolCallStatus = LocalToolCallStatus | MothershipStreamV1ToolOutcome const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set( @@ -149,6 +152,13 @@ export interface StreamingContext { streamComplete: boolean wasAborted: boolean errors: string[] + /** + * Terminal status carried by the backend's `complete` event. Set only once + * the backend declares the turn finished, so it can outrank in-band failures + * recorded on the way there (a tool or subagent that failed and was handed + * back to the model as data). + */ + completionStatus?: MothershipStreamV1CompletionStatus usage?: { prompt: number; completion: number } cost?: { input: number; output: number; total: number } /** @@ -161,6 +171,16 @@ export interface StreamingContext { activeFileIntents: Map trace: TraceCollector subAgentTraceSpans?: Map + /** + * Per-request state for the tool permission gate. `autoAllowed` starts from + * the user's saved always-allow list and is added to in place when they pick + * "always allow" mid-turn, so a later call to the same tool in this same turn + * is not prompted a second time. + */ + toolPermissions: { + enabled: boolean + autoAllowed: Set + } } interface FileAttachment { diff --git a/apps/sim/lib/copilot/resources/availability.ts b/apps/sim/lib/copilot/resources/availability.ts new file mode 100644 index 00000000000..442ce06e2eb --- /dev/null +++ b/apps/sim/lib/copilot/resources/availability.ts @@ -0,0 +1,20 @@ +import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' +import { isDesktopOnlyResource, type MothershipResource } from '@/lib/copilot/resources/types' +import { isTerminalAvailable } from '@/lib/terminal/transport' + +/** + * Whether this client can show the resource's panel at all. + * + * The browser and terminal panels are stored with the chat like any other + * resource, so the tab is still there when the chat is reopened. But they are + * windows onto something the desktop app owns — an embedded browser view, a + * pty — and opening that same chat in the web app would otherwise restore a + * tab that leads to an error. Such resources stay in the chat's stored + * resources either way; this only decides whether to put them on screen. + */ +export function canDisplayResource(resource: MothershipResource): boolean { + if (!isDesktopOnlyResource(resource)) return true + if (resource.type === 'browser') return isBrowserAgentAvailable() + if (resource.type === 'terminal') return isTerminalAvailable() + return false +} diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index ff97f23cc6f..150c8b217b7 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -181,57 +181,33 @@ describe('extractResourcesFromToolResult', () => { }) describe('extractDeletedResourcesFromToolResult', () => { - it('extracts every successfully deleted workflow from the batch result', () => { - const resources = extractDeletedResourcesFromToolResult( - 'delete_workflow', - { workflowIds: ['wf-1', 'wf-2', 'wf-failed'] }, - { - deleted: [ - { workflowId: 'wf-1', name: 'First workflow' }, - { workflowId: 'wf-2', name: 'Second workflow' }, - ], - failed: ['wf-failed'], - } - ) - - expect(resources).toEqual([ - { type: 'workflow', id: 'wf-1', title: 'First workflow' }, - { type: 'workflow', id: 'wf-2', title: 'Second workflow' }, - ]) - }) - - it('extracts deleted files from delete_file result data', () => { + it('extracts every kind rm deleted and skips the ones that failed', () => { expect( extractDeletedResourcesFromToolResult( - 'delete_file', - { paths: ['files/one.md', 'files/two.md'] }, + 'rm', + { paths: ['files/Reports/Old%20Report.pdf'] }, { - success: true, - data: { - deleted: [ - { id: 'file-1', name: 'one.md' }, - { id: 'file-2', name: 'two.md' }, - ], - failed: [], - }, + results: [ + { from: 'files/Reports/Old%20Report.pdf', kind: 'file', id: 'file-1' }, + { from: 'files/Archive', kind: 'file_folder', id: 'folder-1' }, + { from: 'workflows/Lead%20Router', kind: 'workflow', id: 'wf-1' }, + { from: 'workflows/Old%20Projects', kind: 'workflow_folder', id: 'wfolder-1' }, + { from: 'tables/Leads', kind: 'table', id: 'tbl-1' }, + { from: 'knowledgebases/support-docs', kind: 'knowledge_base', id: 'kb-1' }, + { from: 'files/missing.md', kind: 'file', error: 'Not found: files/missing.md' }, + ], } ) ).toEqual([ - { type: 'file', id: 'file-1', title: 'one.md' }, - { type: 'file', id: 'file-2', title: 'two.md' }, + { type: 'file', id: 'file-1', title: 'Old Report.pdf' }, + { type: 'filefolder', id: 'folder-1', title: 'Archive' }, + { type: 'workflow', id: 'wf-1', title: 'Lead Router' }, + { type: 'folder', id: 'wfolder-1', title: 'Old Projects' }, + { type: 'table', id: 'tbl-1', title: 'Leads' }, + { type: 'knowledgebase', id: 'kb-1', title: 'support-docs' }, ]) }) - it('extracts deleted file folders from delete_file_folder result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'delete_file_folder', - { paths: ['files/Archive'] }, - { success: true, data: { folders: 1, files: 2, deletedFolderIds: ['folder-1'] } } - ) - ).toEqual([{ type: 'filefolder', id: 'folder-1', title: 'Folder' }]) - }) - it('extracts only successfully deleted tables from user_table result data', () => { expect( extractDeletedResourcesFromToolResult( @@ -255,16 +231,6 @@ describe('extractDeletedResourcesFromToolResult', () => { ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) }) - it('extracts deleted workflow folders from manage_folder delete results', () => { - expect( - extractDeletedResourcesFromToolResult( - 'manage_folder', - { operation: 'delete', folderId: 'folder-1' }, - { deleted: ['folder-1'], failed: [] } - ) - ).toEqual([{ type: 'folder', id: 'folder-1', title: 'Folder' }]) - }) - it('removes scheduledtask resources on manage_scheduled_task delete', () => { const resources = extractDeletedResourcesFromToolResult( 'manage_scheduled_task', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 7874718ef5b..ddd7b4f52db 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,9 +1,6 @@ import { CreateFile, CreateWorkflow, - DeleteFile, - DeleteFileFolder, - DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, Ffmpeg, @@ -13,8 +10,8 @@ import { GenerateVideo, Knowledge, KnowledgeBase, - ManageFolder, ManageScheduledTask, + Rm, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -243,14 +240,24 @@ export function extractResourcesFromToolResult( } const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { - [DeleteWorkflow.id]: 'workflow', - [DeleteFile.id]: 'file', - [DeleteFileFolder.id]: 'filefolder', [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', - [ManageFolder.id]: 'folder', [ManageScheduledTask.id]: 'scheduledtask', + // rm spans categories, so unlike every other entry its resource type comes + // from each outcome's kind rather than from this map. The entry exists so + // hasDeleteCapability(rm) holds; the rm case below ignores this value. + [Rm.id]: 'file', +} + +/** rm reports what it deleted per path; map that kind to the type the UI tracks. */ +const RM_KIND_RESOURCE_TYPE: Record = { + file: 'file', + file_folder: 'filefolder', + workflow: 'workflow', + workflow_folder: 'folder', + table: 'table', + knowledge_base: 'knowledgebase', } export function hasDeleteCapability(toolName: string): boolean { @@ -276,57 +283,20 @@ export function extractDeletedResourcesFromToolResult( const operation = (args.operation ?? params?.operation) as string | undefined switch (toolName) { - case DeleteWorkflow.id: { - const deleted = Array.isArray(result.deleted) ? result.deleted : [] - const resources = deleted.flatMap((entry): ChatResource[] => { - const deletedWorkflow = asRecord(entry) - const workflowId = deletedWorkflow.workflowId - if (typeof workflowId !== 'string' || !workflowId) return [] - return [ - { - type: resourceType, - id: workflowId, - title: typeof deletedWorkflow.name === 'string' ? deletedWorkflow.name : 'Workflow', - }, - ] - }) - if (resources.length > 0) return resources - - // Backward compatibility for historical single-workflow tool results. - const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted === true) { - return [ - { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, - ] - } - return [] - } - - case DeleteFile.id: { - const deleted = Array.isArray(data.deleted) ? data.deleted : [] - return deleted.flatMap((entry): ChatResource[] => { - const deletedFile = asRecord(entry) - const fileId = deletedFile.id - if (typeof fileId !== 'string' || !fileId) return [] - return [ - { - type: resourceType, - id: fileId, - title: typeof deletedFile.name === 'string' ? deletedFile.name : 'File', - }, - ] + case Rm.id: { + const outcomes = Array.isArray(result.results) ? result.results : [] + return outcomes.flatMap((entry): ChatResource[] => { + const outcome = asRecord(entry) + if (outcome.error) return [] + const { id, kind, from } = outcome + if (typeof id !== 'string' || !id || typeof kind !== 'string') return [] + const type = RM_KIND_RESOURCE_TYPE[kind] + if (!type) return [] + const path = typeof from === 'string' ? from : '' + const leaf = path.split('/').filter(Boolean).pop() ?? '' + return [{ type, id, title: leaf ? decodeURIComponent(leaf) : 'Deleted resource' }] }) } - - case DeleteFileFolder.id: { - const deletedFolderIds = Array.isArray(data.deletedFolderIds) - ? data.deletedFolderIds.filter( - (id): id is string => typeof id === 'string' && id.length > 0 - ) - : [] - return deletedFolderIds.map((id) => ({ type: resourceType, id, title: 'Folder' })) - } - case WorkspaceFile.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) @@ -378,14 +348,6 @@ export function extractDeletedResourcesFromToolResult( return [] } - case ManageFolder.id: { - if (operation !== 'delete') return [] - const deletedIds = Array.isArray(result.deleted) ? (result.deleted as unknown[]) : [] - return deletedIds.flatMap((id): ChatResource[] => - typeof id === 'string' && id ? [{ type: resourceType, id, title: 'Folder' }] : [] - ) - } - case ManageScheduledTask.id: { if (operation !== 'delete') return [] const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts new file mode 100644 index 00000000000..928198d7747 --- /dev/null +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { addCopilotChatResourceBodySchema } from '@/lib/api/contracts/copilot' +import { + BROWSER_SESSION_RESOURCE_ID, + isDesktopOnlyResource, + isEphemeralResource, + type MothershipResource, + MothershipResourceType, + PERSISTED_RESOURCE_TYPES, + TERMINAL_SESSION_RESOURCE_ID, +} from './types' + +function resource(overrides: Partial = {}): MothershipResource { + return { type: 'file', id: 'r1', title: 'Thing', ...overrides } +} + +describe('isEphemeralResource', () => { + it('persists the desktop panels so their tabs survive reopening the chat', () => { + expect( + isEphemeralResource( + resource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' }) + ) + ).toBe(false) + expect( + isEphemeralResource( + resource({ type: 'terminal', id: TERMINAL_SESSION_RESOURCE_ID, title: 'Terminal' }) + ) + ).toBe(false) + }) + + it('keeps synthetic panels client-only', () => { + expect(isEphemeralResource(resource({ type: 'generic', id: 'results' }))).toBe(true) + expect(isEphemeralResource(resource({ type: 'file', id: 'streaming-file' }))).toBe(true) + }) + + it('treats an unrecognized type as ephemeral rather than trying a doomed write', () => { + expect(isEphemeralResource(resource({ type: 'nonsense' as MothershipResourceType }))).toBe(true) + }) +}) + +describe('isDesktopOnlyResource', () => { + it('marks the panels that need the desktop bridge', () => { + expect(isDesktopOnlyResource(resource({ type: 'browser' }))).toBe(true) + expect(isDesktopOnlyResource(resource({ type: 'terminal' }))).toBe(true) + }) + + it('leaves ordinary workspace resources alone', () => { + expect(isDesktopOnlyResource(resource({ type: 'workflow' }))).toBe(false) + expect(isDesktopOnlyResource(resource({ type: 'file' }))).toBe(false) + }) +}) + +/** + * The bug this guards against: the client decided what to persist from one + * list and the API validated against another, so `browser`, `task` and + * `integration` were openable but unsaveable — every write 400'd into a + * warning log and the tabs were gone on reload. Both sides now come from + * `PERSISTED_RESOURCE_TYPES`; these fail if anything reintroduces a second + * list. + */ +describe('client and server agree on what can be persisted', () => { + it.each(PERSISTED_RESOURCE_TYPES)('the API accepts a %s resource', (type) => { + const parsed = addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type, id: 'r1', title: 'Thing' }, + }) + expect(parsed.success).toBe(true) + }) + + it('the API rejects every type the client refuses to send', () => { + const ephemeral = Object.values(MothershipResourceType).filter((type) => + isEphemeralResource(resource({ type })) + ) + expect(ephemeral.length).toBeGreaterThan(0) + for (const type of ephemeral) { + const parsed = addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type, id: 'r1', title: 'Thing' }, + }) + expect(parsed.success, `expected the API to reject ${type}`).toBe(false) + } + }) + + it('covers every resource type, so a new one has to make the choice explicitly', () => { + const all = Object.values(MothershipResourceType) + const ephemeral = all.filter((type) => isEphemeralResource(resource({ type }))) + expect([...PERSISTED_RESOURCE_TYPES, ...ephemeral].sort()).toEqual([...all].sort()) + }) +}) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 41e0fee863b..09c03370d99 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -10,6 +10,8 @@ export const MothershipResourceType = { log: 'log', integration: 'integration', generic: 'generic', + browser: 'browser', + terminal: 'terminal', } as const export type MothershipResourceType = (typeof MothershipResourceType)[keyof typeof MothershipResourceType] @@ -21,10 +23,88 @@ export interface MothershipResource { path?: string } +interface ResourcePolicy { + /** Stored with the chat, so the tab is still there when the chat is reopened. */ + persisted: boolean + /** + * Backed by something only the desktop app can provide. Still persisted, but + * a client without the bridge leaves the tab out rather than restoring a + * panel with nothing behind it. + */ + desktopOnly?: boolean +} + +/** + * What the app does with each kind of resource, in one place. + * + * These rules used to live in three, and they disagreed. A client-side check + * decided what to send, a Zod enum in the API contract decided what to accept, + * and a runtime allowlist in the route handler decided again — but the enum + * rejected `browser`, `task` and `integration` before the allowlist that + * permitted them ever ran. Those tabs looked fine until the chat was reopened, + * because the write had been failing the whole time into a warning log. The + * contract enum and the handler now derive from this table, so a type can no + * longer be openable and unsaveable at the same time. + */ +const RESOURCE_POLICY: Record = { + table: { persisted: true }, + file: { persisted: true }, + workflow: { persisted: true }, + knowledgebase: { persisted: true }, + folder: { persisted: true }, + filefolder: { persisted: true }, + task: { persisted: true }, + scheduledtask: { persisted: true }, + log: { persisted: true }, + integration: { persisted: true }, + // A synthetic panel with no addressable entity behind it to reopen. + generic: { persisted: false }, + browser: { persisted: true, desktopOnly: true }, + terminal: { persisted: true, desktopOnly: true }, +} + +/** + * Resource types the chat will store. The API contract builds its enum from + * this, which is what keeps client and server from drifting. + */ +export const PERSISTED_RESOURCE_TYPES = ( + Object.keys(RESOURCE_POLICY) as MothershipResourceType[] +).filter((type) => RESOURCE_POLICY[type].persisted) as [ + MothershipResourceType, + ...MothershipResourceType[], +] + +/** True when the resource's panel needs the desktop bridge to show anything. */ +export function isDesktopOnlyResource(resource: MothershipResource): boolean { + return RESOURCE_POLICY[resource.type]?.desktopOnly === true +} + export function isEphemeralResource(resource: MothershipResource): boolean { - return resource.type === 'generic' || resource.id === 'streaming-file' + // The in-flight file preview is a placeholder that becomes a real file once + // the write lands, so persisting it would restore a tab for a file that was + // never created. + if (resource.id === 'streaming-file') return true + // An unrecognized type is treated as ephemeral: the server would reject it + // anyway, and failing to store it is better than a write that always errors. + return !RESOURCE_POLICY[resource.type]?.persisted } +/** + * Singleton id for the live browser-session panel, which hosts the desktop + * app's natively embedded browser view. Only this metadata is stored with the + * chat: reopening restores the tab, while the page and browser profile stay + * owned by the desktop app. + */ +export const BROWSER_SESSION_RESOURCE_ID = 'browser-session' + +/** + * Singleton id for the live terminal panel. As with the browser, only the + * metadata is stored — reopening the chat brings the panel back with a fresh + * shell, since the pty and its scrollback belong to the desktop app and do not + * outlive it. + */ +export const TERMINAL_SESSION_RESOURCE_ID = 'terminal-session' + /** Placeholder resource titles that a more specific title may overwrite during dedup. */ export const GENERIC_RESOURCE_TITLES = new Set([ 'Table', diff --git a/apps/sim/lib/copilot/tool-executor/index.ts b/apps/sim/lib/copilot/tool-executor/index.ts index 6b84ce9f2b5..a287c8835af 100644 --- a/apps/sim/lib/copilot/tool-executor/index.ts +++ b/apps/sim/lib/copilot/tool-executor/index.ts @@ -1,3 +1,3 @@ export { executeTool } from './executor' export { ensureHandlersRegistered } from './register-handlers' -export { getToolEntry, isSimExecuted } from './router' +export { getToolEntry, isSimExecuted, toolRequiresApproval } from './router' diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 7b5c9086716..29e18387f7c 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -5,7 +5,6 @@ import { Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, - DeleteWorkflow, DeleteWorkspaceMcpServer, DeployApi, DeployChat, @@ -29,7 +28,6 @@ import { LoadDeployment, ManageCredential, ManageCustomTool, - ManageFolder, ManageMcpTool, ManageScheduledTask, ManageSkill, @@ -43,6 +41,7 @@ import { Read as ReadTool, Redeploy, RestoreResource, + Rm as RmTool, RunBlock, RunCode, RunFromBlock, @@ -93,12 +92,15 @@ import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' -import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' +import { + executeVfsCp, + executeVfsMkdir, + executeVfsMv, + executeVfsRm, +} from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, - executeDeleteWorkflow, executeGenerateApiKey, - executeManageFolder, executeMoveWorkflow, executeRenameWorkflow, executeRunBlock, @@ -147,8 +149,6 @@ function buildHandlerMap(): Record { [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), [CreateWorkflow.id]: h(executeCreateWorkflow), - [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [ManageFolder.id]: h(executeManageFolder), // rename_workflow / move_workflow were removed from the mothership catalog // in favor of mv; the executors stay registered under literal names so // in-flight checkpoints still resume. Delete after the mv release soaks. @@ -188,6 +188,7 @@ function buildHandlerMap(): Record { [MvTool.id]: h(executeVfsMv), [CpTool.id]: h(executeVfsCp), [MkdirTool.id]: h(executeVfsMkdir), + [RmTool.id]: h(executeVfsRm), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/copilot/tool-executor/router.ts index 46a6815cfd7..13ea484300a 100644 --- a/apps/sim/lib/copilot/tool-executor/router.ts +++ b/apps/sim/lib/copilot/tool-executor/router.ts @@ -39,6 +39,11 @@ export function isKnownTool(toolId: string): boolean { return isToolInCatalog(toolId) } +/** Declared in the mothership tool catalog; Go carries the flag but never enforces it. */ +export function toolRequiresApproval(toolId: string): boolean { + return getToolEntry(toolId)?.requiresApproval === true +} + interface PartitionedBatch { sim: ToolCallDescriptor[] go: ToolCallDescriptor[] diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts new file mode 100644 index 00000000000..b00b95f499f --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment jsdom + */ +import { sleep } from '@sim/utils/helpers' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteBrowserTool, mockReportCompletion } = vi.hoisted(() => ({ + mockExecuteBrowserTool: vi.fn(), + mockReportCompletion: vi.fn(), +})) + +vi.mock('@/lib/browser-agent/transport', () => ({ + executeBrowserTool: mockExecuteBrowserTool, +})) +vi.mock('@/lib/copilot/tools/client/completion', () => ({ + reportClientToolCompletion: mockReportCompletion, +})) + +import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +/** Waits for the fire-and-forget execution promise chain to settle. */ +async function flush(): Promise { + await sleep(0) +} + +let toolCallCounter = 0 +function nextToolCallId(): string { + toolCallCounter += 1 + return `tool-call-${toolCallCounter}` +} + +describe('executeBrowserToolOnClient', () => { + beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.clear() + useBrowserSessionStore.getState().setSessionAlive(true) + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('executes the tool and reports success when the session is alive', async () => { + mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith(toolCallId, 'browser_snapshot', {}, 30_000) + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), { + text: 'page content', + }) + }) + + // The copilot serializes a result carrying this `attachment` shape into a + // real image content block, so the data URL has to be reshaped rather than + // passed through — an inline data URL would be charged against the tool + // result budget as text and shown to the model as a base64 string. + it('reshapes a screenshot into an image attachment the model can see', async () => { + mockExecuteBrowserTool.mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + url: 'https://example.com/pricing', + width: 1024, + height: 640, + }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_screenshot', {}) + await flush() + + const [, , , reported] = mockReportCompletion.mock.calls[0] + expect(reported.attachment).toEqual({ + type: 'image', + source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' }, + }) + expect(reported.content).toContain('https://example.com/pricing') + expect(reported.dataUrl).toBeUndefined() + expect(reported.width).toBe(1024) + }) + + it('falls back to a note when a screenshot is not a usable data URL', async () => { + mockExecuteBrowserTool.mockResolvedValue({ dataUrl: 'not-a-data-url', url: 'https://x.dev' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_screenshot', {}) + await flush() + + const [, , , reported] = mockReportCompletion.mock.calls[0] + expect(reported.attachment).toBeUndefined() + expect(reported.note).toContain('could not be encoded') + }) + + // The desktop driver parses browser_wait_for.timeoutMs with a lenient num() + // that coerces numeric strings, then clamps to 120s. This side has to match: + // budgeting less time than the desktop actually waits makes the renderer + // abort first, which strands the native promise on the serialized tool queue + // and stalls every later browser call behind it. + it.each([ + ['number', 30_000, 45_000], + ['numeric string', '30000', 45_000], + ['absent', undefined, 25_000], + ['non-numeric', 'soon', 25_000], + ['above the desktop clamp', 500_000, 135_000], + ])( + 'budgets browser_wait_for above the desktop wait (%s)', + async (_label, timeoutMs, expected) => { + mockExecuteBrowserTool.mockResolvedValue({ found: true }) + const toolCallId = nextToolCallId() + const params = timeoutMs === undefined ? {} : { timeoutMs } + + executeBrowserToolOnClient(toolCallId, 'browser_wait_for', params) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith( + toolCallId, + 'browser_wait_for', + params, + expected + ) + } + ) + + it('rejects page-dependent tools up front when the session is closed', async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'error', + expect.stringContaining('browser session is closed'), + expect.objectContaining({ sessionClosed: true }) + ) + }) + + it('still allows session-revival tools when the session is closed', async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + mockExecuteBrowserTool.mockResolvedValue({ url: 'https://example.com' }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_navigate', { url: 'https://example.com' }) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledWith( + toolCallId, + 'browser_navigate', + { url: 'https://example.com' }, + 45_000 + ) + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), { + url: 'https://example.com', + }) + }) + + it('tags a failure with sessionClosed when the session died mid-call', async () => { + mockExecuteBrowserTool.mockImplementation(async () => { + useBrowserSessionStore.getState().setSessionAlive(false) + throw new Error('The browser did not respond within 30000ms') + }) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {}) + await flush() + + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'error', + expect.stringContaining('browser session is closed'), + expect.objectContaining({ + sessionClosed: true, + error: expect.stringContaining('The browser did not respond within 30000ms'), + }) + ) + }) + + it('reports a plain error without the sessionClosed tag when the session is alive', async () => { + mockExecuteBrowserTool.mockRejectedValue(new Error('element not found')) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_click', { ref: 'e12' }) + await flush() + + expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'error', 'element not found', { + error: 'element not found', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts new file mode 100644 index 00000000000..14c8d0c3be4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -0,0 +1,277 @@ +/** + * Client-side execution of `browser_*` copilot tools. + * + * Mirrors the other client-executed tool flows (run-tool, local filesystem): + * the Go orchestrator emits a client-executed tool call and blocks on Redis; + * this module performs the action through the desktop app's built-in agent + * browser and reports the outcome via the confirm endpoint, which wakes the + * server-side waiter. + */ +import type { BrowserToolName } from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { executeBrowserTool } from '@/lib/browser-agent/transport' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const logger = createLogger('CopilotBrowserToolExecution') + +const DEFAULT_TOOL_TIMEOUT_MS = 30_000 +const NAVIGATION_TOOL_TIMEOUT_MS = 45_000 +const WAIT_FOR_TIMEOUT_GRACE_MS = 15_000 +// Mirror the desktop driver's parse of browser_wait_for.timeoutMs exactly. It +// coerces numeric strings and clamps to a maximum; reading the value more +// strictly here would budget less time than the desktop actually waits, and the +// renderer aborting first strands the native promise on the serialized tool +// queue so every later browser call stalls behind it. +const DEFAULT_WAIT_FOR_TIMEOUT_MS = 10_000 +const MAX_WAIT_FOR_TIMEOUT_MS = 120_000 + +/** + * Tools that can revive a closed browser session by opening a fresh tab. + * Everything else requires a live page and is rejected up front when the + * session is closed, instead of burning the full IPC timeout per call — a + * dead session used to answer every tool with an indistinguishable generic + * ~30s timeout, which the agent retried indefinitely. + */ +const SESSION_REVIVAL_TOOLS: ReadonlySet = new Set([ + 'browser_navigate', + 'browser_open_url', + 'browser_open_tab', + 'browser_list_tabs', +]) + +const SESSION_CLOSED_MESSAGE = + 'The agent browser session is closed, so this browser tool cannot run. ' + + 'Call browser_navigate or browser_open_tab to start a new session, or report the situation to the user. ' + + 'Do not retry other browser tools until a new session is open.' +/** Tool events older than this are replays, not live instructions — never act on them. */ +const MAX_EVENT_AGE_MS = 120_000 +const EXECUTED_STORAGE_PREFIX = 'sim:copilot:browser-tool-executed:' + +/** + * Exactly-once guard. Stream recovery and tab reloads replay persisted tool + * events; a browser action must never run twice (re-opening tabs, re-clicking + * buttons). In-memory set for the fast path, sessionStorage so a reload of the + * same tab cannot re-execute what it already did. + */ +const executedToolCallIds = new Set() + +function hasAlreadyExecuted(toolCallId: string): boolean { + if (executedToolCallIds.has(toolCallId)) return true + if (typeof window === 'undefined') return false + try { + return window.sessionStorage.getItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`) !== null + } catch { + return false + } +} + +function markExecuted(toolCallId: string): void { + executedToolCallIds.add(toolCallId) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`, '1') + } catch { + // Best-effort; the in-memory set still covers this tab's lifetime. + } +} + +/** Milliseconds since the event was emitted, or null when unparsable. */ +function eventAgeMs(eventTs: string | undefined): number | null { + if (!eventTs) return null + const emitted = Date.parse(eventTs) + return Number.isNaN(emitted) ? null : Date.now() - emitted +} + +function timeoutForTool(toolName: BrowserToolName, params: Record): number | null { + if (toolName === 'browser_request_takeover') return null + if ( + toolName === 'browser_navigate' || + toolName === 'browser_open_url' || + toolName === 'browser_go_back' || + toolName === 'browser_go_forward' || + toolName === 'browser_open_tab' + ) { + return NAVIGATION_TOOL_TIMEOUT_MS + } + if (toolName === 'browser_wait_for') { + const raw = Number(params.timeoutMs) + const requested = + Number.isFinite(raw) && raw > 0 + ? Math.min(raw, MAX_WAIT_FOR_TIMEOUT_MS) + : DEFAULT_WAIT_FOR_TIMEOUT_MS + return requested + WAIT_FOR_TIMEOUT_GRACE_MS + } + return DEFAULT_TOOL_TIMEOUT_MS +} + +/** Splits a `data:;base64,` URL into its parts. */ +function parseBase64DataUrl(dataUrl: string): { mediaType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl) + if (!match) return null + return { mediaType: match[1], data: match[2] } +} + +/** + * Reshapes a screenshot into the `attachment` contract the copilot serializes + * into a real image content block, so the model sees the page rather than a + * note about it. The data URL itself never goes inline: `content` is the text + * the model reads beside the image, and the bytes travel under `attachment`. + * + * A malformed data URL degrades to the text note rather than shipping an + * attachment the provider would reject. + */ +function sanitizeResultForModel( + toolName: BrowserToolName, + result: unknown +): Record | undefined { + if (!isRecordLike(result)) { + return result === undefined ? undefined : { value: result } + } + if (toolName === 'browser_screenshot' && typeof result.dataUrl === 'string') { + const { dataUrl, ...rest } = result + const image = parseBase64DataUrl(dataUrl) + if (!image) { + return { + ...rest, + note: 'The screenshot could not be encoded. Use browser_snapshot or browser_read_text instead.', + } + } + const location = typeof rest.url === 'string' && rest.url ? ` of ${rest.url}` : '' + return { + ...rest, + content: `Screenshot${location}. This is the rendered viewport only — it carries no element ids, so use browser_snapshot before interacting.`, + attachment: { + type: 'image', + source: { type: 'base64', media_type: image.mediaType, data: image.data }, + }, + } + } + return result +} + +/** + * Fire-and-forget entry point invoked by the stream tool-event handler when a + * `browser_*` client tool call arrives. + * + * @param eventTs - the stream envelope's emission timestamp; stale events + * (replays after reconnect/reload) are dropped rather than re-executed. + */ +export function executeBrowserToolOnClient( + toolCallId: string, + toolName: BrowserToolName, + params: Record, + eventTs?: string +): void { + if (hasAlreadyExecuted(toolCallId)) { + logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName }) + return + } + const age = eventAgeMs(eventTs) + if (age !== null && age > MAX_EVENT_AGE_MS) { + logger.info('Skipping stale browser tool event', { toolCallId, toolName, age }) + return + } + markExecuted(toolCallId) + void doExecuteBrowserTool(toolCallId, toolName, params).catch((err) => { + logger.error('Unhandled error in client-side browser tool execution', { + toolCallId, + toolName, + error: toError(err).message, + }) + }) +} + +/** True when the desktop app has reported the agent browser session closed. */ +function isSessionClosed(): boolean { + return !useBrowserSessionStore.getState().sessionAlive +} + +async function doExecuteBrowserTool( + toolCallId: string, + toolName: BrowserToolName, + params: Record +): Promise { + if (isSessionClosed() && !SESSION_REVIVAL_TOOLS.has(toolName)) { + logger.warn('Rejecting browser tool: agent browser session is closed', { + toolCallId, + toolName, + }) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + SESSION_CLOSED_MESSAGE, + { error: SESSION_CLOSED_MESSAGE, sessionClosed: true } + ).catch((reportErr) => { + logger.error('Failed to report browser session-closed error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + return + } + // If the user leaves the page mid-action the awaited result is lost; tell + // the waiter so the turn fails fast instead of hanging until its timeout. + const onPageHide = () => { + navigator.sendBeacon( + COPILOT_CONFIRM_API_PATH, + new Blob( + [ + JSON.stringify({ + toolCallId, + status: ASYNC_TOOL_CONFIRMATION_STATUS.error, + message: + 'The user left the Sim window while this browser action was running, so its result was lost.', + }), + ], + { type: 'application/json' } + ) + ) + } + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', onPageHide) + } + + logger.info('Executing browser tool via the desktop agent browser', { toolCallId, toolName }) + + try { + const result = await executeBrowserTool( + toolCallId, + toolName, + params, + timeoutForTool(toolName, params) + ) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Browser action completed', + sanitizeResultForModel(toolName, result) + ) + } catch (err) { + // The session dying mid-call (e.g. during a takeover) surfaces as a + // generic timeout; tag it so the model learns the real, terminal cause + // instead of retrying against a dead session. + const sessionClosed = isSessionClosed() + const message = sessionClosed + ? `${toError(err).message} ${SESSION_CLOSED_MESSAGE}` + : toError(err).message + logger.warn('Browser tool failed', { toolCallId, toolName, error: message, sessionClosed }) + await reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + ...(sessionClosed ? { sessionClosed: true } : {}), + }).catch((reportErr) => { + logger.error('Failed to report browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + } finally { + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', onPageHide) + } + } +} diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts new file mode 100644 index 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/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index dbe06363d7a..7609e0e570b 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,11 +1,17 @@ -// load_agent_skill is retained for historical persisted messages; it is no -// longer emitted now that internal skills autoload. +// load_agent_skill and load_custom_tool are retained for historical persisted +// messages; neither is emitted any more. load_custom_tool was renamed +// load_mcp_tool once it became clear that MCP was the only catalog kind it +// could ever match. // search_integration_tools is gateway plumbing: the discovery step is not a // user-meaningful action, only the resolved call_integration_tool row is. +// load_skill is the same shape — the agent pulling in a reference guide before +// doing the work is a step toward the action, not the action. const HIDDEN_TOOL_NAMES = new Set([ 'load_agent_skill', 'load_custom_tool', + 'load_mcp_tool', 'load_integration_tool', + 'load_skill', 'search_integration_tools', ]) diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts new file mode 100644 index 00000000000..2bd8749aaa3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReportCompletion } = vi.hoisted(() => ({ + mockReportCompletion: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/client/completion', () => ({ + reportClientToolCompletion: mockReportCompletion, +})) + +import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' + +const mount = { + id: 'mount-1', + name: 'Project', + uri: 'localfs://mount-1/', + path: '~/code/project', + remembered: true, +} +const vfsRoot = 'user-local/Project--mount-1' + +describe('executeLocalFilesystemTool', () => { + const localFilesystem = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'simDesktop', { + configurable: true, + value: { localFilesystem }, + }) + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('projects granted mounts and glob results into canonical user-local VFS paths', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'glob') { + return { + ok: true, + data: { + entries: [ + { + name: 'index.ts', + uri: 'localfs://mount-1/src/index.ts', + kind: 'file', + size: 10, + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + ], + truncated: false, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-1', + 'glob', + { pattern: 'user-local/**/*.ts' }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'glob', + uri: 'localfs://mount-1/', + pattern: 'user-local/**/*.ts', + pathPrefix: vfsRoot, + requestId: 'tool-1', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-1', + 'success', + 'Local filesystem tool completed.', + { files: [`${vfsRoot}/src/index.ts`] } + ) + }) + expect(JSON.stringify(mockReportCompletion.mock.calls)).not.toContain('localfs://') + expect(JSON.stringify(mockReportCompletion.mock.calls)).not.toContain('~/code/project') + }) + + it('maps ordinary VFS read arguments to a bounded desktop read', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'read') { + return { + ok: true, + data: { + uri: 'localfs://mount-1/README.md', + content: 'second line', + startLine: 2, + endLine: 2, + totalLines: 3, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-read', + 'read', + { path: `${vfsRoot}/README.md`, offset: 1, limit: 1 }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'read', + uri: 'localfs://mount-1/README.md', + startLine: 2, + lineCount: 1, + requestId: 'tool-read', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-read', + 'success', + 'Local filesystem tool completed.', + { content: 'second line', totalLines: 3 } + ) + }) + }) + + it('preserves normal grep regex/options and rewrites every result path', async () => { + localFilesystem.mockImplementation(async (request: { operation: string }) => { + if (request.operation === 'list_mounts') { + return { ok: true, data: { mounts: [mount] } } + } + if (request.operation === 'grep') { + return { + ok: true, + data: { + matches: [ + { + uri: 'localfs://mount-1/src/index.ts', + line: 7, + text: 'const TODO = true', + }, + ], + truncated: false, + }, + } + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + + executeLocalFilesystemTool( + 'tool-grep', + 'grep', + { + pattern: 'TODO|FIXME', + path: vfsRoot, + ignoreCase: true, + lineNumbers: false, + context: 2, + maxResults: 10, + }, + { workspaceId: 'ws-1' } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'grep', + uri: 'localfs://mount-1/', + pattern: 'TODO|FIXME', + caseSensitive: false, + maxResults: 10, + outputMode: 'content', + lineNumbers: false, + context: 2, + requestId: 'tool-grep', + }) + expect(mockReportCompletion).toHaveBeenCalledWith( + 'tool-grep', + 'success', + 'Local filesystem tool completed.', + { + matches: [{ path: `${vfsRoot}/src/index.ts`, line: 7, content: 'const TODO = true' }], + } + ) + }) + }) + + it('cancels an in-flight native read on abort and never reports a stale completion', async () => { + let finishRead: + | ((response: { ok: false; code: 'CANCELLED'; error: string }) => void) + | undefined + localFilesystem.mockImplementation((request: { operation: string; requestId?: string }) => { + if (request.operation === 'list_mounts') { + return Promise.resolve({ ok: true, data: { mounts: [mount] } }) + } + if (request.operation === 'read') { + return new Promise((resolve) => { + finishRead = resolve + }) + } + if (request.operation === 'cancel') { + finishRead?.({ ok: false, code: 'CANCELLED', error: 'cancelled' }) + return Promise.resolve({ ok: true, data: { cancelled: true } }) + } + throw new Error(`Unexpected operation: ${request.operation}`) + }) + const controller = new AbortController() + + executeLocalFilesystemTool( + 'tool-abort', + 'read', + { path: `${vfsRoot}/README.md` }, + { workspaceId: 'ws-1', signal: controller.signal } + ) + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'read', requestId: 'tool-abort' }) + ) + }) + controller.abort('user stopped') + + await vi.waitFor(() => { + expect(localFilesystem).toHaveBeenCalledWith({ + operation: 'cancel', + requestId: 'tool-abort', + }) + }) + expect(mockReportCompletion).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.ts new file mode 100644 index 00000000000..04091d484b4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.ts @@ -0,0 +1,409 @@ +import type { + LocalFilesystemData, + LocalFilesystemMount, + LocalFilesystemRequest, + LocalFilesystemResponse, +} from '@sim/desktop-bridge' +import { + DEFAULT_GREP_CONTEXT, + DEFAULT_GREP_RESULTS, + DEFAULT_READ_LINES, + MAX_GREP_CONTEXT, + MAX_GREP_RESULTS, + MAX_READ_LINES, +} from '@sim/desktop-bridge/local-filesystem-limits' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import micromatch from 'micromatch' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { USER_LOCAL_VFS_ROOT } from '@/lib/copilot/tools/local-filesystem' +import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { getDesktopBridge } from '@/lib/desktop' + +const logger = createLogger('CopilotLocalFilesystemTool') +/** + * This glob runs entirely in the renderer against already-listed mounts, so + * unlike the grep and read limits it is nobody else's business — the shell + * never authorizes against it. Deliberately local. + */ +const MAX_USER_LOCAL_GLOB_RESULTS = 500 + +const VFS_GLOB_OPTIONS: micromatch.Options = { + bash: false, + dot: false, + windows: false, + nobrace: true, + noext: true, +} + +interface LocalFilesystemExecutionContext { + workspaceId: string + chatId?: string + signal?: AbortSignal +} + +function requiredString(args: Record, name: string): string { + const value = args[name] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} is required`) + } + return value +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function bridge(): NonNullable { + const desktop = getDesktopBridge() + if (!desktop?.localFilesystem) { + throw new Error('The desktop local filesystem bridge is unavailable.') + } + return desktop +} + +function successfulData(response: LocalFilesystemResponse): LocalFilesystemData { + if (!response.ok) { + throw new Error(response.error) + } + return response.data +} + +function abortError(signal: AbortSignal): Error { + const error = new Error(signal.reason ? String(signal.reason) : 'Operation aborted') + error.name = 'AbortError' + return error +} + +function requestIdForToolCall(toolCallId: string): string { + return toolCallId +} + +async function invokeBridge( + request: LocalFilesystemRequest, + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw abortError(signal) + const requestId = 'requestId' in request ? request.requestId : undefined + const onAbort = () => { + if (requestId) { + void bridge().localFilesystem({ operation: 'cancel', requestId }) + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + try { + const data = successfulData(await bridge().localFilesystem(request)) + if (signal?.aborted) throw abortError(signal) + return data + } finally { + signal?.removeEventListener('abort', onAbort) + } +} + +function encodeMountName(name: string): string { + try { + return encodeVfsSegment(name) + } catch { + return encodeURIComponent(name) + } +} + +function mountVfsRoot(mount: LocalFilesystemMount): string { + return `${USER_LOCAL_VFS_ROOT}/${encodeMountName(mount.name)}--${mount.id}` +} + +function vfsPathForUri(mount: LocalFilesystemMount, uri: string): string { + const parsed = new URL(uri) + if (parsed.protocol !== 'localfs:' || parsed.hostname !== mount.id) { + throw new Error('The desktop app returned a local path outside the selected folder.') + } + const relativePath = parsed.pathname.replace(/^\/+/, '') + return relativePath ? `${mountVfsRoot(mount)}/${relativePath}` : mountVfsRoot(mount) +} + +function localUriForVfsPath(mount: LocalFilesystemMount, path: string): string { + const root = mountVfsRoot(mount) + if (path === root) return mount.uri + if (!path.startsWith(`${root}/`)) { + throw new Error(`Path is not inside a granted user-local folder: ${path}`) + } + return `${mount.uri}${path.slice(root.length + 1)}` +} + +async function listMounts(): Promise { + const data = await invokeBridge({ operation: 'list_mounts' }) + if (!('mounts' in data)) { + throw new Error('The desktop app returned an invalid mount list.') + } + return data.mounts +} + +function mountForPath(mounts: LocalFilesystemMount[], path: string): LocalFilesystemMount { + const match = mounts.find((mount) => { + const root = mountVfsRoot(mount) + return path === root || path.startsWith(`${root}/`) + }) + if (!match) { + throw new Error( + `No granted user-local folder contains "${path}". Use glob({pattern:"user-local/**"}) to discover canonical paths.` + ) + } + return match +} + +function omitHostPaths(data: LocalFilesystemData): LocalFilesystemData { + if ('mount' in data) { + if (!data.mount) return data + const { path: _path, ...mount } = data.mount as LocalFilesystemMount & { path?: unknown } + return { ...data, mount } + } + if ('mounts' in data) { + return { + ...data, + mounts: data.mounts.map((rawMount) => { + const { path: _path, ...mount } = rawMount as LocalFilesystemMount & { path?: unknown } + return mount + }), + } + } + return data +} + +async function executeUserLocalGlob( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise<{ files: string[] }> { + const pattern = requiredString(args, 'pattern') + const mounts = await listMounts() + const files = new Set() + const requestId = requestIdForToolCall(toolCallId) + + for (const mount of mounts) { + if (signal?.aborted) throw abortError(signal) + const root = mountVfsRoot(mount) + if (micromatch.isMatch(root, pattern, VFS_GLOB_OPTIONS)) { + files.add(root) + } + + const data = await invokeBridge( + { + operation: 'glob', + uri: mount.uri, + pattern, + pathPrefix: root, + requestId, + }, + signal + ) + if (!('entries' in data)) { + throw new Error('The desktop app returned an invalid glob result.') + } + for (const entry of data.entries) { + const path = vfsPathForUri(mount, entry.uri) + if (micromatch.isMatch(path, pattern, VFS_GLOB_OPTIONS)) { + files.add(path) + if (files.size >= MAX_USER_LOCAL_GLOB_RESULTS) break + } + } + if (files.size >= MAX_USER_LOCAL_GLOB_RESULTS) break + } + + return { files: [...files].sort() } +} + +async function executeUserLocalRead( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise<{ content: string; totalLines: number }> { + const path = requiredString(args, 'path') + const mounts = await listMounts() + const mount = mountForPath(mounts, path) + const offset = Math.max(0, Math.trunc(optionalNumber(args.offset) ?? 0)) + const requestedLimit = optionalNumber(args.limit) + const lineCount = Math.min( + MAX_READ_LINES, + Math.max(1, Math.trunc(requestedLimit ?? DEFAULT_READ_LINES)) + ) + const data = await invokeBridge( + { + operation: 'read', + uri: localUriForVfsPath(mount, path), + startLine: offset + 1, + lineCount, + requestId: requestIdForToolCall(toolCallId), + }, + signal + ) + if (!('content' in data) || !('totalLines' in data)) { + throw new Error('The desktop app returned an invalid read result.') + } + return { content: data.content, totalLines: data.totalLines } +} + +async function executeUserLocalGrep( + toolCallId: string, + args: Record, + signal?: AbortSignal +): Promise> { + const pattern = requiredString(args, 'pattern') + const path = requiredString(args, 'path').replace(/\/+$/, '') + const outputMode = + args.output_mode === 'files_with_matches' || args.output_mode === 'count' + ? args.output_mode + : 'content' + const maxResults = Math.min( + MAX_GREP_RESULTS, + Math.max(1, Math.trunc(optionalNumber(args.maxResults) ?? DEFAULT_GREP_RESULTS)) + ) + const mounts = await listMounts() + const targets = + path === USER_LOCAL_VFS_ROOT + ? mounts.map((mount) => ({ mount, uri: mount.uri })) + : [ + { + mount: mountForPath(mounts, path), + uri: '', + }, + ] + if (targets.length === 1 && !targets[0].uri) { + targets[0].uri = localUriForVfsPath(targets[0].mount, path) + } + + const contentMatches: Array<{ path: string; line: number; content: string }> = [] + const matchingFiles = new Set() + const counts = new Map() + const requestId = requestIdForToolCall(toolCallId) + + for (const target of targets) { + const data = await invokeBridge( + { + operation: 'grep', + uri: target.uri, + pattern, + caseSensitive: args.ignoreCase !== true, + maxResults, + outputMode, + lineNumbers: args.lineNumbers !== false, + context: Math.min( + MAX_GREP_CONTEXT, + Math.max(0, Math.trunc(optionalNumber(args.context) ?? DEFAULT_GREP_CONTEXT)) + ), + requestId, + }, + signal + ) + + if ('matches' in data) { + for (const match of data.matches) { + contentMatches.push({ + path: vfsPathForUri(target.mount, match.uri), + line: match.line, + content: match.text, + }) + if (contentMatches.length >= maxResults) break + } + } else if ('files' in data) { + for (const uri of data.files) { + matchingFiles.add(vfsPathForUri(target.mount, uri)) + if (matchingFiles.size >= maxResults) break + } + } else if ('counts' in data) { + for (const count of data.counts) { + counts.set(vfsPathForUri(target.mount, count.uri), count.count) + if (counts.size >= maxResults) break + } + } else { + throw new Error('The desktop app returned an invalid grep result.') + } + + const currentCount = + outputMode === 'files_with_matches' + ? matchingFiles.size + : outputMode === 'count' + ? counts.size + : contentMatches.length + if (currentCount >= maxResults) break + } + + if (outputMode === 'files_with_matches') { + return { files: [...matchingFiles].sort() } + } + if (outputMode === 'count') { + return { + counts: [...counts.entries()] + .map(([countPath, count]) => ({ path: countPath, count })) + .sort((a, b) => a.path.localeCompare(b.path)), + } + } + return { + matches: contentMatches.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line), + } +} + +async function execute( + toolCallId: string, + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): Promise { + if (toolName === 'glob') return executeUserLocalGlob(toolCallId, args, context.signal) + if (toolName === 'grep') return executeUserLocalGrep(toolCallId, args, context.signal) + return executeUserLocalRead(toolCallId, args, context.signal) +} + +export function executeLocalFilesystemTool( + toolCallId: string, + toolName: string, + args: Record, + context: LocalFilesystemExecutionContext +): void { + void execute(toolCallId, toolName, args, context).then( + async (data) => { + if (context.signal?.aborted) return + try { + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Local filesystem tool completed.', + data + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool completion', { + toolCallId, + toolName, + error: toError(reportError).message, + }) + } + }, + async (error) => { + if (context.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + return + } + const message = toError(error).message + logger.warn('Local filesystem tool failed', { toolCallId, toolName, error: message }) + try { + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + message, + { error: message } + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool error', { + toolCallId, + toolName, + error: toError(reportError).message, + }) + } + } + ) +} + +export const userLocalVfsTestHelpers = { + mountVfsRoot, + vfsPathForUri, + localUriForVfsPath, +} diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 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/client/terminal-tool-execution.ts b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts new file mode 100644 index 00000000000..ce4e8ccbcc0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts @@ -0,0 +1,207 @@ +/** + * Client-side execution of `terminal_*` copilot tools. + * + * Mirrors the other client-executed tool flows (browser, run-tool, local + * filesystem): the Go orchestrator emits a client-executed tool call and blocks + * on Redis; this module performs the action through the desktop app's terminal + * and reports the outcome via the confirm endpoint, which wakes the + * server-side waiter. + */ +import { createLogger } from '@sim/logger' +import { + isTerminalOperation, + type TerminalOperation, + type TerminalToolArgs, +} from '@sim/terminal-protocol' +import { toError } from '@sim/utils/errors' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { executeTerminalTool } from '@/lib/terminal/transport' + +const logger = createLogger('CopilotTerminalToolExecution') + +/** Tool events older than this are replays, not live instructions. */ +const MAX_EVENT_AGE_MS = 120_000 +const EXECUTED_STORAGE_PREFIX = 'sim:copilot:terminal-tool-executed:' + +/** + * Exactly-once guard. Stream recovery and tab reloads replay persisted tool + * events, and re-running a shell command is the least forgiving thing in this + * codebase to get wrong — `rm -rf` twice is not the same as once. In-memory set + * for the fast path, sessionStorage so a reload of the same tab cannot + * re-execute what it already did. + */ +const executedToolCallIds = new Set() + +function hasAlreadyExecuted(toolCallId: string): boolean { + if (executedToolCallIds.has(toolCallId)) return true + if (typeof window === 'undefined') return false + try { + return window.sessionStorage.getItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`) !== null + } catch { + return false + } +} + +function markExecuted(toolCallId: string): void { + executedToolCallIds.add(toolCallId) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`, '1') + } catch { + // Best-effort; the in-memory set still covers this tab's lifetime. + } +} + +function eventAgeMs(eventTs: string | undefined): number | null { + if (!eventTs) return null + const emitted = Date.parse(eventTs) + return Number.isNaN(emitted) ? null : Date.now() - emitted +} + +/** + * `terminal_run` has no client-side deadline: it can sit on an approval chip + * for as long as the user takes, and the desktop side already bounds the + * command itself. The rest are near-instant, so a short timeout keeps a wedged + * bridge from stalling the turn. + */ +const QUICK_TOOL_TIMEOUT_MS = 15_000 + +function timeoutForOperation(operation: TerminalOperation): number | null { + // `run` waits on a command and `handoff` waits on a person; neither has a + // deadline this side can usefully impose. + return operation === 'run' || operation === 'handoff' ? null : QUICK_TOOL_TIMEOUT_MS +} + +/** + * Splits a `terminal` tool call into its operation and arguments. The model + * supplies both inside one params object, and an unrecognized operation is + * rejected here rather than sent to the desktop, so a malformed call fails + * with a useful message instead of a bridge error. + */ +function parseCall(params: Record): { + operation: TerminalOperation + args: TerminalToolArgs +} | null { + const operation = params.operation + if (!isTerminalOperation(operation)) return null + const args = params.args + return { + operation, + args: + args && typeof args === 'object' && !Array.isArray(args) ? (args as TerminalToolArgs) : {}, + } +} + +/** + * Fire-and-forget entry point invoked by the stream tool-event handler when a + * `terminal` client tool call arrives. + * + * @param eventTs - the stream envelope's emission timestamp; stale events + * (replays after reconnect/reload) are dropped rather than re-executed. + */ +export function executeTerminalToolOnClient( + toolCallId: string, + params: Record, + eventTs?: string +): void { + const call = parseCall(params) + if (!call) { + logger.warn('Ignoring terminal tool call with no recognized operation', { toolCallId }) + return + } + const operation = call.operation + if (hasAlreadyExecuted(toolCallId)) { + logger.info('Skipping already-executed terminal tool (replay)', { toolCallId, operation }) + return + } + const age = eventAgeMs(eventTs) + if (age !== null && age > MAX_EVENT_AGE_MS) { + logger.info('Skipping stale terminal tool event', { toolCallId, operation, age }) + return + } + markExecuted(toolCallId) + void doExecuteTerminalTool(toolCallId, operation, call.args).catch((err) => { + logger.error('Unhandled error in client-side terminal tool execution', { + toolCallId, + operation, + error: toError(err).message, + }) + }) +} + +async function doExecuteTerminalTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs +): Promise { + // If the user leaves the page mid-command the awaited result is lost; tell + // the waiter so the turn fails fast instead of hanging until its timeout. + const onPageHide = () => { + navigator.sendBeacon( + COPILOT_CONFIRM_API_PATH, + new Blob( + [ + JSON.stringify({ + toolCallId, + status: ASYNC_TOOL_CONFIRMATION_STATUS.error, + message: + 'The user left the Sim window while this terminal command was running, so its result was lost.', + }), + ], + { type: 'application/json' } + ) + ) + } + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', onPageHide) + } + + logger.info('Executing terminal operation via the desktop terminal', { toolCallId, operation }) + + try { + const timeoutMs = timeoutForOperation(operation) + const invocation = executeTerminalTool(toolCallId, operation, args) + const result = + timeoutMs === null + ? await invocation + : await Promise.race([ + invocation, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`The terminal did not respond within ${timeoutMs}ms`)), + timeoutMs + ) + }), + ]) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.success, + 'Terminal action completed', + result as Record | undefined + ) + } catch (err) { + const error = toError(err) + // A declined command is a normal outcome, not a fault: reporting it as + // cancelled lets the model adapt instead of retrying the same command. + const status = + error.name === 'REJECTED' + ? ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + : ASYNC_TOOL_CONFIRMATION_STATUS.error + logger.warn('Terminal operation failed', { toolCallId, operation, error: error.message }) + await reportClientToolCompletion(toolCallId, status, error.message, { + error: error.message, + ...(error.name ? { code: error.name } : {}), + }).catch((reportErr) => { + logger.error('Failed to report terminal tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) + } finally { + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', onPageHide) + } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 51dabd8de14..3fc90655188 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -68,13 +68,6 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) -vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ - resolveWorkflowAliasForWorkspace: vi.fn().mockResolvedValue(null), -})) -vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({ - isPlanAliasPath: () => false, - workflowAliasSandboxPath: (p: string) => p, -})) import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' @@ -443,3 +436,90 @@ describe('executeFunctionExecute file mounts', () => { expect(file.type).toBeUndefined() }) }) + +async function mountError(inputs: Record): Promise { + try { + await executeFunctionExecute(inputs, context as never) + } catch (error) { + return (error as Error).message + } + throw new Error('expected the mount to be rejected') +} + +describe('executeFunctionExecute unmountable namespaces', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true }) + mockIsFeatureEnabled.mockResolvedValue(false) + mockHasCloudStorage.mockReturnValue(true) + mockListWorkspaceFiles.mockResolvedValue([]) + mockFindWorkspaceFileRecord.mockReturnValue(null) + mockListWorkspaceFileFolders.mockResolvedValue([]) + }) + + it('tells the agent a tool-result artifact is backend-served, not a wrong path', async () => { + const message = await mountError({ + inputFiles: ['internal/tool-results/user_table-toolu_019Ef.json'], + }) + + expect(message).toContain('Cannot mount "internal/tool-results/user_table-toolu_019Ef.json"') + expect(message).toContain('stored by the copilot backend') + expect(message).toContain('This path is correct') + expect(message).toContain('outputs.files[].path') + expect(message).toContain('user_table: outputPath') + // The old message sent the agent hunting for a canonical path that never existed. + expect(message).not.toContain('Input file not found') + expect(message).not.toContain('canonical VFS path copied from glob/read') + }) + + it('covers the rest of internal/ without the tool-result rerun advice', async () => { + const message = await mountError({ inputFiles: ['internal/memories/SESSION.md'] }) + + expect(message).toContain('served by the copilot backend') + expect(message).toContain('read or grep it') + expect(message).not.toContain('outputPath') + }) + + it('points recently-deleted/ paths at restore_resource', async () => { + const message = await mountError({ inputFiles: ['recently-deleted/files/old.csv'] }) + + expect(message).toContain('restore_resource') + }) + + it('points tables/ paths at inputs.tables', async () => { + const message = await mountError({ inputFiles: ['tables/Leads/meta.json'] }) + + expect(message).toContain('inputs.tables') + }) + + it('names the namespace for VFS metadata views', async () => { + const message = await mountError({ inputFiles: ['workflows/My%20Flow/state.json'] }) + + expect(message).toContain('workflows/ paths are VFS metadata views') + }) + + it('keeps the uploads/ guidance intact', async () => { + const message = await mountError({ inputFiles: ['uploads/report.json'] }) + + expect(message).toContain('materialize_file') + }) + + it('still reports a genuine files/ miss as not found', async () => { + const message = await mountError({ inputFiles: ['files/typo.csv'] }) + + expect(message).toContain('Input file not found: "files/typo.csv"') + }) + + it('explains an unmountable namespace passed as a directory', async () => { + const message = await mountError({ inputs: { directories: ['internal/tool-results'] } }) + + expect(message).toContain('Cannot mount "internal/tool-results"') + expect(message).toContain('stored by the copilot backend') + }) + + it('still reports a genuine files/ folder miss as not found', async () => { + const message = await mountError({ inputs: { directories: ['files/Missing'] } }) + + expect(message).toContain('Input directory not found: "files/Missing"') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 828e27b5956..6029f71c0fe 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,7 +1,5 @@ import { createLogger } from '@sim/logger' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getColumnId } from '@/lib/table/column-keys' @@ -159,6 +157,47 @@ async function pushWorkspaceFileMount( mounted.buffered += buffer.length } +/** + * Explains why a VFS path the agent legitimately discovered cannot be mounted, and + * what to do instead. Only workspace `files/` are backed by storage the sandbox can + * fetch from — `internal/` is served by the copilot backend and its bytes never reach + * Sim, `uploads/` is chat-scoped, `recently-deleted/` is archived, and the remaining + * namespaces are metadata views rather than stored file bytes. Returns null for + * `files/` references, where "not found" is the honest answer. + * + * These paths are correct and are advertised to the model as read/grep-able, so the + * generic not-found message ("copy the exact canonical path") is actively wrong for + * them: it sends the agent hunting for a path that does not exist. + */ +function unmountableNamespaceReason(filePath: string): string | null { + // Trailing slash so a bare namespace passed as a directory matches the same prefixes + // as a file path inside it. + const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` + + if (path.startsWith('uploads/')) { + return 'uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.' + } + if (path.startsWith('internal/tool-results/')) { + return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (function_execute: outputs.files[].path, user_table: outputPath) and mount that files/... path.' + } + if (path.startsWith('internal/')) { + return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' + } + if (path.startsWith('recently-deleted/')) { + return 'deleted resources are not mountable into the sandbox. Use restore_resource to restore it first, then mount the restored files/... path.' + } + if (path.startsWith('tables/')) { + return 'tables are not mounted as files. Pass the table in inputs.tables instead and it is mounted as CSV.' + } + const namespace = /^(workflows|knowledgebases|components|environment|agent|jobs|tasks)\//.exec( + path + )?.[1] + if (namespace) { + return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read or grep it and inline the values you need in code.` + } + return null +} + interface CanonicalFileInput { path: string sandboxPath?: string @@ -203,7 +242,6 @@ export async function resolveInputFiles( ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } - const betaEnabled = await isFeatureEnabled('mothership-beta') if (inputFiles?.length && workspaceId) { if (inputFiles.length > MAX_MOUNTED_FILES) { @@ -211,9 +249,7 @@ export async function resolveInputFiles( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId, { - includeReservedSystemFiles: betaEnabled, - }) + const allFiles = await listWorkspaceFiles(workspaceId) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -222,21 +258,11 @@ export async function resolveInputFiles( ? (fileRef as CanonicalFileInput).path : undefined if (!filePath) continue - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath }) - if (!alias && isPlanAliasPath(filePath)) { - logger.warn('Unsupported plan alias input file path', { filePath }) - continue - } - if (alias?.kind === 'plans_dir') { - logger.warn('Input file is a plan alias directory', { filePath }) - continue - } - const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath) + const record = findWorkspaceFileRecord(allFiles, filePath) if (!record) { - if (filePath.startsWith('uploads/')) { - throw new Error( - `Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.` - ) + const unmountable = unmountableNamespaceReason(filePath) + if (unmountable) { + throw new Error(`Cannot mount "${filePath}": ${unmountable}`) } throw new Error( `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` @@ -246,21 +272,14 @@ export async function resolveInputFiles( typeof fileRef === 'object' && fileRef !== null ? (fileRef as CanonicalFileInput).sandboxPath : undefined - const mountPath = - explicitSandboxPath || - (alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record)) + const mountPath = explicitSandboxPath || getSandboxWorkspaceFilePath(record) await pushWorkspaceFileMount(sandboxFiles, record, mountPath, mounted) } } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: betaEnabled, - }) - const allFiles = await listWorkspaceFiles(workspaceId, { - folders, - includeReservedSystemFiles: betaEnabled, - }) + const folders = await listWorkspaceFileFolders(workspaceId) + const allFiles = await listWorkspaceFiles(workspaceId, { folders }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -269,28 +288,23 @@ export async function resolveInputFiles( ? (dirRef as CanonicalDirectoryInput).path : undefined if (!dirPath) continue - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath }) - if (alias && alias.kind !== 'plans_dir') { - throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`) - } - if (!alias && isPlanAliasPath(dirPath)) { - throw new Error(`Unsupported plan alias directory: ${dirPath}`) - } - const backingDirPath = alias?.backingPath ?? dirPath - const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, '')) + const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) const folderDisplayPath = folderSegments.join('/') const folder = folders.find((candidate) => candidate.path === folderDisplayPath) if (!folder) { - throw new Error(`Input directory not found: ${dirPath}`) + const unmountable = unmountableNamespaceReason(dirPath) + throw new Error( + unmountable + ? `Cannot mount "${dirPath}": ${unmountable}` + : `Input directory not found: "${dirPath}". Pass a canonical workspace folder path copied from glob/read (e.g. "files/Reports").` + ) } const mountRoot = typeof dirRef === 'object' && dirRef !== null && (dirRef as CanonicalDirectoryInput).sandboxPath ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : alias - ? workflowAliasSandboxPath(alias.aliasPath) - : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` const descendants = allFiles.filter((file) => { if (!file.folderPath) return false return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) @@ -329,11 +343,7 @@ export async function resolveInputFiles( for (const record of descendants) { const relativeFolder = record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' - const relativePath = alias - ? encodeVfsPathSegments( - [relativeFolder, record.name].filter(Boolean).join('/').split('/') - ) - : [relativeFolder, record.name].filter(Boolean).join('/') + const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') await pushWorkspaceFileMount(sandboxFiles, record, `${mountRoot}/${relativePath}`, mounted) } } diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index d0e2cbc18cd..ce1bf99bc2b 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -446,32 +446,6 @@ describe('executeMaterializeFile - extract operation', () => { expect(mockDecompress).toHaveBeenCalledTimes(1) }) - it('folds reserved system folder names into the "archive" fallback folder', async () => { - // '.changelogs' / '.plans' back workflow changelog/plan aliases; extraction - // must never write into them (and the already-extracted lookup hides them, - // so a second extract would silently duplicate). - mockFindUpload.mockResolvedValue( - zipRow({ displayName: '.changelogs.zip', originalName: '.changelogs.zip' }) - ) - mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) - mockDecompress.mockResolvedValue({ - extracted: [{ id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }], - skipped: 0, - skippedUnsafePaths: [], - }) - - const result = await executeMaterializeFile( - { fileNames: ['.changelogs.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(true) - expect(mockDecompress).toHaveBeenCalledWith( - expect.any(Buffer), - expect.objectContaining({ rootFolderSegments: ['archive'] }) - ) - }) - it('folds degenerate archive names into the "archive" fallback folder', async () => { mockFindUpload.mockResolvedValue(zipRow({ displayName: '..zip', originalName: '..zip' })) mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 7c47c0105ac..f5e35019793 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -14,7 +14,6 @@ import { import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { getServePathPrefix } from '@/lib/uploads' import { ArchiveError, @@ -304,9 +303,6 @@ async function executeImport( * empty), so a hostile upload name like `..zip` or `\x01.zip` lands in the * `archive` fallback instead of surfacing a raw internal error — and so the * VFS-encoded destination path can be computed before anything is extracted. - * Reserved system backing folders (`.changelogs`, `.plans`) also fall back: - * extraction must never write into — or hide behind — those namespaces (the - * already-extracted lookup skips them, so they'd also duplicate silently). */ function archiveFolderBaseName(displayName: string): string { const stripped = displayName @@ -315,12 +311,7 @@ function archiveFolderBaseName(displayName: string): string { .replace(/[\x00-\x1f\x7f]/g, '') .replace(/[/\\]/g, '-') .trim() - if ( - !stripped || - stripped === '.' || - stripped === '..' || - isReservedWorkflowAliasBackingDisplayPath(stripped) - ) { + if (!stripped || stripped === '.' || stripped === '..') { return 'archive' } return stripped diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index a59afaf30d0..c3c3ac14384 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -14,6 +14,7 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | Mod+Z | Undo | | Mod+Shift+Z | Redo | | Mod+C | Copy selected blocks | +| Mod+X | Cut selected blocks | | Mod+V | Paste blocks | | Delete/Backspace | Delete selected blocks or edges | | Shift+L | Auto-layout canvas | @@ -23,9 +24,6 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa ### Panel Navigation | Shortcut | Action | |----------|--------| -| C | Focus Copilot tab | -| T | Focus Toolbar tab | -| E | Focus Editor tab | | Mod+F | Open workflow search and replace | | Mod+Alt+F | Focus Toolbar search | @@ -34,6 +32,8 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa |----------|--------| | Mod+K | Open search | | Mod+Shift+A | Add new agent workflow | +| Mod+Shift+P | Create workflow | +| Mod+B | Toggle sidebar | | Mod+L | Go to logs | ### Utility @@ -44,10 +44,10 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa ### Mouse Controls | Action | Control | |--------|---------| -| Pan/move canvas | Left-drag on empty space, scroll, or trackpad | -| Select multiple blocks | Right-drag to draw selection box | +| Pan/move canvas | Left-drag on empty space (hand mode, the default), middle-drag, scroll, or trackpad | +| Select multiple blocks | Shift+drag to draw a selection box. In cursor mode, left-drag on empty space draws it instead | | Drag block | Left-drag on block header | -| Add to selection | Mod+Click on blocks | +| Add to selection | Mod+Click or Shift+Click on blocks | ## Quick Reference — Workspaces | Action | How | @@ -71,13 +71,15 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | Action | How | |--------|-----| | Add a block | Drag from Toolbar panel, or right-click canvas → Add Block | -| Multi-select blocks | Mod+Click additional blocks, or shift-drag selection box | +| Multi-select blocks | Mod+Click or Shift+Click additional blocks, or Shift+drag a selection box | | Copy/Paste blocks | Mod+C / Mod+V | | Duplicate/Delete blocks | Right-click → action | | Rename a block | Click block name in header | | Enable/Disable block | Right-click → Enable/Disable | | Lock/Unlock block | Hover block → Click lock icon (Admin only) | | Toggle handle orientation | Right-click → Toggle Handles | +| Open a block in the Editor panel | Right-click → Open Editor | +| Move a block out of a loop/parallel | Right-click → Remove from Subflow | | Configure a block | Select block → use Editor panel on right | ## Quick Reference — Connections @@ -111,6 +113,6 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa |--------|-----| | Add/Edit/Delete workflow variable | Panel → Variables → Add Variable | | Add environment variable | Settings → Environment Variables → Add | -| Reference workflow variable | Use syntax | +| Reference workflow variable | Use syntax | | Reference environment variable | Use {{ENV_VAR}} syntax | ` diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index c27fbd737cd..8e69e1dce8f 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -97,63 +97,4 @@ describe('executeOpenResource', () => { ], }) }) - - it('opens workflow alias file paths through workspace file reference resolution', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_plan_file', - name: 'implementation.md', - folderPath: 'system/workflows/My Workflow/.plans', - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', path: 'workflows/My%20Workflow/.plans/implementation.md' }], - }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } - ) - - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'workflows/My%20Workflow/.plans/implementation.md' - ) - expect(result).toMatchObject({ - success: true, - resources: [ - { - type: 'file', - id: 'wf_plan_file', - title: 'implementation.md', - path: 'files/system/workflows/My%20Workflow/.plans/implementation.md', - }, - ], - }) - }) - - it('opens root plan alias file paths through workspace file reference resolution', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_root_plan', - name: 'root.md', - folderPath: 'system/.plans', - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', path: '.plans/root.md' }], - }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } - ) - - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', '.plans/root.md') - expect(result).toMatchObject({ - success: true, - resources: [ - { - type: 'file', - id: 'wf_root_plan', - title: 'root.md', - path: 'files/system/.plans/root.md', - }, - ], - }) - }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index f14a565c292..322c5524e5d 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -269,15 +269,6 @@ describe('vfs mv/cp', () => { }) expect(result.success).toBe(true) }) - - it('rejects reserved alias backing paths', async () => { - const result = await executeVfsMv( - { sources: ['files/.plans/wf_1/launch.md'], destination: 'files/launch.md' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('Reserved system paths') - }) }) describe('workflows', () => { @@ -418,14 +409,11 @@ describe('vfs mv/cp', () => { }) }) - it('rejects flat namespaces and reserved paths', async () => { - const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, context) + it('rejects flat namespaces', async () => { + const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) expect(result.success).toBe(false) expect(result.output).toMatchObject({ - results: [ - { from: 'tables/CRM', error: expect.stringContaining('flat namespace') }, - { from: 'files/.plans/wf_1', error: expect.stringContaining('Reserved') }, - ], + results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], }) expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 44682d3e615..e5b7ffefda4 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -16,10 +16,13 @@ import { decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRequestId } from '@/lib/core/utils/request' -import { getKnowledgeBases, updateKnowledgeBase } from '@/lib/knowledge/service' -import { listTables, renameTable } from '@/lib/table/service' +import { + deleteKnowledgeBase, + getKnowledgeBases, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import { deleteTable, listTables, renameTable } from '@/lib/table/service' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -27,16 +30,20 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, + resolveWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performCreateFolder, + performDeleteFolder, + performDeleteWorkflow, performUpdateFolder, performUpdateWorkflow, } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { + performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, performUpdateWorkspaceFileFolder, } from '@/lib/workspace-files/orchestration' @@ -57,6 +64,17 @@ const CATEGORY_REJECTIONS: Record = { 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', } +/** + * Same categories as CATEGORY_REJECTIONS, but the advice differs for a delete: + * an upload needs no cleanup and a recently-deleted item is already gone. + */ +const RM_CATEGORY_REJECTIONS: Record = { + uploads: + 'uploads/ files are chat-scoped and disappear with the chat — there is nothing to delete.', + 'recently-deleted': + 'recently-deleted/ items are already deleted. Use restore_resource to bring one back.', +} + interface VfsMutateOutcome { from: string to?: string @@ -70,13 +88,17 @@ function topLevelSegment(path: string): string { return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' } -function classifyCategory(path: string): { category: MutateCategory } | { error: string } { +function classifyCategory( + path: string, + rejections: Record = CATEGORY_REJECTIONS, + verbNoun = 'movable' +): { category: MutateCategory } | { error: string } { const top = topLevelSegment(path) if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } - const rejection = CATEGORY_REJECTIONS[top] + const rejection = rejections[top] if (rejection) return { error: rejection } return { - error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, + error: `"${path}" is not a ${verbNoun} resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, } } @@ -96,7 +118,10 @@ function assertMutationNotAborted(context: ExecutionContext): void { } } -function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { +function buildResult( + verb: MutateVerb | 'mkdir' | 'rm', + outcomes: VfsMutateOutcome[] +): ToolCallResult { const failed = outcomes.filter((o) => o.error) if (failed.length === outcomes.length) { return { @@ -161,11 +186,6 @@ export async function executeVfsMkdir( outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) continue } - if (top === 'files' && isWorkflowAliasBackingPath(path)) { - outcomes.push({ from: path, kind, error: `Reserved system path: ${path}` }) - continue - } - try { assertMutationNotAborted(context) let folderId: string | null @@ -348,15 +368,6 @@ async function mutateWorkspaceFiles( error: 'Workspace files cannot be copied — cp only duplicates workflows.', } } - for (const path of [...sources, destination]) { - if (isWorkflowAliasBackingPath(path)) { - return { - success: false, - error: `Reserved system paths cannot be moved or renamed: ${path}`, - } - } - } - const dest = await planDestination({ destination, sourceCount: sources.length, @@ -508,6 +519,36 @@ function makeWorkflowFolderEnsurer( } } +interface WorkflowRow { + id: string + name: string + folderId: string | null +} + +/** + * Every workflow in the workspace keyed by its canonical VFS directory, so a + * path resolves without a query per path. Shared by mv/cp and rm, which ask the + * same question of a workflows/ path: is this a workflow or a folder? + */ +async function loadWorkflowsByVfsPath( + workspaceId: string, + folderPathById: Map +): Promise> { + const rows = await db + .select({ id: workflowTable.id, name: workflowTable.name, folderId: workflowTable.folderId }) + .from(workflowTable) + .where(eq(workflowTable.workspaceId, workspaceId)) + const byPath = new Map() + for (const row of rows) { + const dir = canonicalWorkflowVfsDir({ + name: row.name, + folderPath: row.folderId ? folderPathById.get(row.folderId) : null, + }) + if (!byPath.has(dir)) byPath.set(dir, row) + } + return byPath +} + async function mutateWorkflows( verb: MutateVerb, sources: string[], @@ -518,22 +559,7 @@ async function mutateWorkflows( const index = await loadWorkflowFolderIndex(workspaceId) const { folderPathById, folderIdByPath } = index - const workflowRows = await db - .select({ - id: workflowTable.id, - name: workflowTable.name, - folderId: workflowTable.folderId, - }) - .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) - const workflowByPath = new Map() - for (const row of workflowRows) { - const dir = canonicalWorkflowVfsDir({ - name: row.name, - folderPath: row.folderId ? folderPathById.get(row.folderId) : null, - }) - if (!workflowByPath.has(dir)) workflowByPath.set(dir, row) - } + const workflowByPath = await loadWorkflowsByVfsPath(workspaceId, folderPathById) const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) @@ -550,7 +576,7 @@ async function mutateWorkflows( // Resolve every source against the in-memory maps before mutating anything. type SourceRef = - | { source: string; workflow: (typeof workflowRows)[number] } + | { source: string; workflow: WorkflowRow } | { source: string; folderId: string } | { source: string; error: string } const refs: SourceRef[] = [] @@ -766,3 +792,275 @@ async function renameFlatResource( { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, ]) } + +/** + * rm over the VFS: deletes the resource each path names. Every delete here is + * SOFT — the resource lands in recently-deleted/ and restore_resource brings it + * back — so this is the product's delete, not a purge. + * + * Scope is deliberately "things with a path". Removing something INSIDE a + * resource (a table row, a KB document, a workflow block) is an edit to that + * resource and stays with its owning tool. + */ +export async function executeVfsRm( + params: Record, + context: ExecutionContext +): Promise { + try { + const paths = normalizeSources(params.paths) + if (paths.length === 0) { + return { success: false, error: 'paths is required (an array of VFS paths to delete)' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + // Loaded at most once, and only when a workflows/ path in this call needs it. + let workflowIndex: Promise | undefined + const getWorkflowIndex = () => (workflowIndex ??= loadWorkflowRemoveIndex(workspaceId)) + + const outcomes: VfsMutateOutcome[] = [] + for (const path of paths) { + const classified = classifyCategory(path, RM_CATEGORY_REJECTIONS, 'deletable') + if ('error' in classified) { + outcomes.push({ from: path, kind: defaultKindFor(path), error: classified.error }) + continue + } + try { + assertMutationNotAborted(context) + outcomes.push( + await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) + ) + } catch (error) { + outcomes.push({ from: path, kind: defaultKindFor(path), error: toError(error).message }) + } + } + + return buildResult('rm', outcomes) + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +/** Best-effort kind for an outcome that failed before the resource was identified. */ +function defaultKindFor(path: string): VfsMutateOutcome['kind'] { + switch (topLevelSegment(path)) { + case 'workflows': + return 'workflow' + case 'tables': + return 'table' + case 'knowledgebases': + return 'knowledge_base' + default: + return 'file' + } +} + +function removeOne( + category: MutateCategory, + path: string, + context: ExecutionContext, + workspaceId: string, + getWorkflowIndex: () => Promise +): Promise { + switch (category) { + case 'files': + return removeWorkspaceFilePath(path, context, workspaceId) + case 'workflows': + return removeWorkflowPath(path, context, workspaceId, getWorkflowIndex) + case 'tables': + return removeTablePath(path, context, workspaceId) + case 'knowledgebases': + return removeKnowledgeBasePath(path, context, workspaceId) + } +} + +/** + * A files/ path is either a leaf file or a folder, and the two cannot collide, + * so resolving the file first and falling back to the folder is unambiguous. + * Both go through performDeleteWorkspaceFileItems — deleting a folder archives + * the files and subfolders inside it. + */ +async function removeWorkspaceFilePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const file = await resolveWorkspaceFileReference(workspaceId, path) + if (file) { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: context.userId, + fileIds: [file.id], + }) + if (!result.success) { + return { from: path, kind: 'file', id: file.id, error: result.error || 'Failed to delete' } + } + logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) + return { from: path, kind: 'file', id: file.id } + } + + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { + return { from: path, kind: 'file', error: 'Path must name a file or folder under files/' } + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: context.userId, + folderIds: [folderId], + }) + if (!result.success) { + return { + from: path, + kind: 'file_folder', + id: folderId, + error: result.error || 'Failed to delete', + } + } + logger.info('Deleted file folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'file_folder', id: folderId } +} + +interface WorkflowRemoveIndex { + workflowByPath: Map + folderIdByPath: Map +} + +async function loadWorkflowRemoveIndex(workspaceId: string): Promise { + const { folderPathById, folderIdByPath } = await loadWorkflowFolderIndex(workspaceId) + return { + workflowByPath: await loadWorkflowsByVfsPath(workspaceId, folderPathById), + folderIdByPath, + } +} + +/** + * Workflow first, then folder — the same resolution order mv uses. The lock + * assertions are what make a locked workflow (or one inside a locked folder) + * fail here rather than silently archiving. + */ +async function removeWorkflowPath( + path: string, + context: ExecutionContext, + workspaceId: string, + getWorkflowIndex: () => Promise +): Promise { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { + return { + from: path, + kind: 'workflow', + error: 'Path must name a workflow or folder under workflows/', + } + } + const encoded = encodeVfsPathSegments(segments) + const { workflowByPath, folderIdByPath } = await getWorkflowIndex() + + const workflow = workflowByPath.get(`workflows/${encoded}`) + if (workflow) { + await assertWorkflowMutable(workflow.id) + const result = await performDeleteWorkflow({ workflowId: workflow.id, userId: context.userId }) + if (!result.success) { + return { + from: path, + kind: 'workflow', + id: workflow.id, + error: result.error || 'Failed to delete workflow', + } + } + logger.info('Deleted workflow via rm', { workflowId: workflow.id, workspaceId }) + return { from: path, kind: 'workflow', id: workflow.id } + } + + const folderId = folderIdByPath.get(encoded) + if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } + + await assertFolderMutable(folderId) + const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId }) + if (!result.success) { + return { + from: path, + kind: 'workflow_folder', + id: folderId, + error: result.error || 'Failed to delete folder', + } + } + logger.info('Deleted workflow folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'workflow_folder', id: folderId } +} + +/** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ +function flatResourceName(path: string, category: 'tables' | 'knowledgebases'): string | null { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length !== 1) return null + return normalizeVfsSegment(segments[0]) +} + +async function removeTablePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const canonical = flatResourceName(path, 'tables') + if (!canonical) { + return { + from: path, + kind: 'table', + error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', + } + } + const match = (await listTables(workspaceId)).find( + (table) => normalizeVfsSegment(table.name) === canonical + ) + if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } + + await deleteTable(match.id, generateRequestId(), context.userId) + logger.info('Archived table via rm', { tableId: match.id, workspaceId }) + return { from: path, kind: 'table', id: match.id } +} + +async function removeKnowledgeBasePath( + path: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const canonical = flatResourceName(path, 'knowledgebases') + if (!canonical) { + return { + from: path, + kind: 'knowledge_base', + error: + 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', + } + } + if (canonical === normalizeVfsSegment('connectors')) { + return { + from: path, + kind: 'knowledge_base', + error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', + } + } + const match = (await getKnowledgeBases(context.userId, workspaceId)).find( + (kb) => normalizeVfsSegment(kb.name) === canonical + ) + if (!match) + return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } + + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + if (!access.hasAccess) { + return { + from: path, + kind: 'knowledge_base', + id: match.id, + error: `Write access required to delete knowledge base "${match.name}"`, + } + } + + await deleteKnowledgeBase(match.id, generateRequestId()) + logger.info('Deleted knowledge base via rm', { knowledgeBaseId: match.id, workspaceId }) + return { from: path, kind: 'knowledge_base', id: match.id } +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index fd4a515a8af..0a1992ddd3c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -275,7 +275,10 @@ export async function executeVfsRead( success: false, error: isOversizedReadPlaceholder(uploadResult.content) ? uploadResult.content - : 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + : // Same as the workspace-file branch below: this size gate runs on + // the whole upload before any window, so "retry with offset/limit" + // would loop. Point at grep scoped to this path instead. + `Read result too large to return inline. Grep this single upload instead of reading it — grep({pattern: "...", path: "${path}"}) — because offset/limit do NOT shrink an upload read: the size check runs on the whole file before the window is applied.`, } } const windowedUpload = applyWindow(uploadResult) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index a6a1d675dc4..8aea602d323 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -28,14 +28,7 @@ import { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, } from '@/lib/workflows/executor/execution-state' -import { - performCreateFolder, - performCreateWorkflow, - performDeleteFolder, - performDeleteWorkflow, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' +import { performCreateWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -349,15 +342,9 @@ function notifyWorkflowUpdated(workflowId: string): void { } import type { - CreateFolderParams, CreateWorkflowParams, - DeleteFolderParams, - DeleteWorkflowParams, GenerateApiKeyParams, - ManageFolderParams, - MoveFolderParams, MoveWorkflowParams, - RenameFolderParams, RenameWorkflowParams, RunBlockParams, RunFromBlockParams, @@ -461,51 +448,6 @@ export async function executeCreateWorkflow( } } -export async function executeCreateFolder( - params: CreateFolderParams, - context: ExecutionContext -): Promise { - try { - const name = typeof params?.name === 'string' ? params.name.trim() : '' - if (!name) { - return { success: false, error: 'name is required' } - } - if (name.length > 200) { - return { success: false, error: 'Folder name must be 200 characters or less' } - } - - const workspaceId = - params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - const parentId = params?.parentId || null - - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - await assertFolderMutable(parentId) - assertWorkflowMutationNotAborted(context) - - const result = await performCreateFolder({ - userId: context.userId, - workspaceId, - name, - parentId, - }) - if (!result.success || !result.folder) { - return { success: false, error: result.error || 'Failed to create folder' } - } - - return { - success: true, - output: { - folderId: result.folder.id, - name: result.folder.name, - workspaceId: result.folder.workspaceId, - parentId: result.folder.parentId, - }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunWorkflow( params: RunWorkflowParams, context: ExecutionContext @@ -773,47 +715,6 @@ export async function executeMoveWorkflow( } } -export async function executeMoveFolder( - params: MoveFolderParams, - context: ExecutionContext -): Promise { - try { - const folderId = params.folderId - if (!folderId) { - return { success: false, error: 'folderId is required' } - } - - const parentId = params.parentId || null - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - if (!(await verifyFolderWorkspace(folderId, workspaceId))) { - return { success: false, error: 'Folder not found' } - } - if (parentId && !(await verifyFolderWorkspace(parentId, workspaceId))) { - return { success: false, error: 'Parent folder not found' } - } - - await assertFolderMutable(folderId) - await assertFolderMutable(parentId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateFolder({ - folderId, - workspaceId, - userId: context.userId, - parentId, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to move folder' } - } - - return { success: true, output: { folderId, parentId } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunWorkflowUntilBlock( params: RunWorkflowUntilBlockParams, context: ExecutionContext @@ -1096,143 +997,6 @@ export async function executeSetBlockEnabled( } } -export async function executeDeleteWorkflow( - params: DeleteWorkflowParams, - context: ExecutionContext -): Promise { - try { - const workflowIds = params.workflowIds - if (!workflowIds || workflowIds.length === 0) { - return { success: false, error: 'workflowIds is required' } - } - - const deleted: Array<{ workflowId: string; name: string }> = [] - const failed: string[] = [] - - for (const workflowId of workflowIds) { - try { - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - - const result = await performDeleteWorkflow({ workflowId, userId: context.userId }) - if (result.success) { - deleted.push({ workflowId, name: workflowRecord.name }) - } else { - failed.push(workflowId) - } - } catch { - failed.push(workflowId) - } - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -export async function executeDeleteFolder( - params: DeleteFolderParams, - context: ExecutionContext -): Promise { - try { - const folderIds = params.folderIds - if (!folderIds || folderIds.length === 0) { - return { success: false, error: 'folderIds is required' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - const folders = await listFolders(workspaceId) - const deleted: string[] = [] - const failed: string[] = [] - - for (const folderId of folderIds) { - const folder = folders.find((f) => f.folderId === folderId) - if (!folder) { - failed.push(folderId) - continue - } - - assertWorkflowMutationNotAborted(context) - - try { - await assertFolderMutable(folderId) - - const result = await performDeleteFolder({ - folderId, - workspaceId, - userId: context.userId, - folderName: folder.folderName, - }) - - if (result.success) { - deleted.push(folderId) - } else { - failed.push(folderId) - } - } catch { - failed.push(folderId) - } - } - - return { success: deleted.length > 0, output: { deleted, failed } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -async function executeRenameFolder( - params: RenameFolderParams, - context: ExecutionContext -): Promise { - try { - const folderId = params.folderId - if (!folderId) { - return { success: false, error: 'folderId is required' } - } - const name = typeof params.name === 'string' ? params.name.trim() : '' - if (!name) { - return { success: false, error: 'name is required' } - } - if (name.length > 200) { - return { success: false, error: 'Folder name must be 200 characters or less' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - if (!(await verifyFolderWorkspace(folderId, workspaceId))) { - return { success: false, error: 'Folder not found' } - } - - await assertFolderMutable(folderId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateFolder({ - folderId, - workspaceId, - userId: context.userId, - name, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename folder' } - } - - return { success: true, output: { folderId, name } } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - /** * Strip the `workflows/` VFS prefix from a folder path, returning the * folder-relative remainder. `workflows` (or an empty path) maps to the @@ -1285,116 +1049,6 @@ function resolveFolderIdByPath( return { folderId } } -/** Resolve the folder a manage_folder op targets, preferring folderId over path. */ -async function resolveManageFolderTarget( - params: ManageFolderParams, - getFolderPaths: () => Promise -): Promise<{ folderId: string } | { error: string }> { - const directId = typeof params.folderId === 'string' ? params.folderId.trim() : '' - if (directId) return { folderId: directId } - const path = typeof params.path === 'string' ? params.path.trim() : '' - if (!path) return { error: 'Provide the folder path (e.g. "workflows/Marketing") or folderId.' } - return resolveFolderIdByPath(path, await getFolderPaths()) -} - -/** - * Resolve the destination parent for move/create. parentId/destinationPath are - * optional; their absence (or an explicit root) targets the workspace root - * (parentId null). - */ -async function resolveManageFolderParent( - params: ManageFolderParams, - getFolderPaths: () => Promise -): Promise<{ parentId: string | null } | { error: string }> { - const directId = typeof params.parentId === 'string' ? params.parentId.trim() : '' - if (directId) return { parentId: directId } - if (params.parentId === null) return { parentId: null } - const dest = typeof params.destinationPath === 'string' ? params.destinationPath.trim() : '' - if (!dest || !workflowFolderRelativePath(dest)) return { parentId: null } - const parent = resolveFolderIdByPath(dest, await getFolderPaths(), 'Destination folder') - if ('error' in parent) return parent - return { parentId: parent.folderId } -} - -/** - * Single entry point for folder CRUD (create/rename/move/delete). Resolves the - * VFS-path/folderId handles, then delegates to the existing folder handlers so - * all DB orchestration (performCreateFolder / performUpdateFolder / - * performDeleteFolder) stays in one place. - */ -export async function executeManageFolder( - params: ManageFolderParams, - context: ExecutionContext -): Promise { - try { - const operation = typeof params?.operation === 'string' ? params.operation.trim() : '' - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - - // Fetch the workspace folder list at most once, lazily — only when a path - // (vs an explicit id) actually needs resolving, and shared across the - // target + parent lookups a single move/create performs. - let folderPathsPromise: Promise | undefined - const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathIndex(workspaceId)) - - switch (operation) { - case 'create': { - let name = typeof params.name === 'string' ? params.name.trim() : '' - let parentId: string | null = null - const path = typeof params.path === 'string' ? params.path.trim() : '' - if (!name && path) { - const segments = decodeVfsPathSegments(workflowFolderRelativePath(path)) - if (segments.length === 0) { - return { success: false, error: 'create requires a folder name or path' } - } - name = segments[segments.length - 1] - const parentSegments = segments.slice(0, -1) - if (parentSegments.length > 0) { - const parent = resolveFolderIdByPath( - encodeVfsPathSegments(parentSegments), - await getFolderPaths(), - 'Parent folder' - ) - if ('error' in parent) return { success: false, error: parent.error } - parentId = parent.folderId - } - } else { - const parent = await resolveManageFolderParent(params, getFolderPaths) - if ('error' in parent) return { success: false, error: parent.error } - parentId = parent.parentId - } - if (!name) return { success: false, error: 'create requires a folder name or path' } - return executeCreateFolder({ name, parentId: parentId ?? undefined, workspaceId }, context) - } - case 'rename': { - const name = typeof params.name === 'string' ? params.name.trim() : '' - if (!name) return { success: false, error: 'rename requires a new name' } - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - return executeRenameFolder({ folderId: target.folderId, name }, context) - } - case 'move': { - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - const parent = await resolveManageFolderParent(params, getFolderPaths) - if ('error' in parent) return { success: false, error: parent.error } - return executeMoveFolder({ folderId: target.folderId, parentId: parent.parentId }, context) - } - case 'delete': { - const target = await resolveManageFolderTarget(params, getFolderPaths) - if ('error' in target) return { success: false, error: target.error } - return executeDeleteFolder({ folderIds: [target.folderId] }, context) - } - default: - return { - success: false, - error: `Unknown operation "${operation}". Use create, rename, move, or delete.`, - } - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/local-filesystem.ts b/apps/sim/lib/copilot/tools/local-filesystem.ts new file mode 100644 index 00000000000..db6fe72b476 --- /dev/null +++ b/apps/sim/lib/copilot/tools/local-filesystem.ts @@ -0,0 +1,23 @@ +/** + * Granted local folders are addressed through the ordinary VFS: the model uses + * `read`/`grep`/`glob` against paths under `user-local/`, exactly as it does + * for workspace files. There is no separate local toolset, and local files are + * read-only. + */ +export const USER_LOCAL_VFS_ROOT = 'user-local' + +export function hasUserLocalVfsPrefix(value: unknown): value is string { + if (typeof value !== 'string') return false + return value === USER_LOCAL_VFS_ROOT || value.startsWith(`${USER_LOCAL_VFS_ROOT}/`) +} + +export function isUserLocalVfsToolCall( + name: string, + args: Record | undefined +): boolean { + if (!args) return false + if (name === 'read') return hasUserLocalVfsPrefix(args.path) + if (name === 'grep') return hasUserLocalVfsPrefix(args.path) + if (name === 'glob') return hasUserLocalVfsPrefix(args.pattern) + return false +} diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 146d0237773..1d5e3629546 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -6,7 +6,6 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { inferContentType } from './workspace-file' const logger = createLogger('CreateFileServerTool') @@ -27,7 +26,6 @@ interface CreateFileResult { name: string contentType: string vfsPath: string - backingVfsPath?: string } } @@ -52,13 +50,6 @@ export const createFileServerTool: BaseServerTool -} - -interface DeleteFileResult { - success: boolean - message: string - data?: { - deleted: { id: string; name: string }[] - failed: string[] - } -} - -export const deleteFileServerTool: BaseServerTool = { - name: DeleteFile.id, - async execute(params: DeleteFileArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - - const nested = params.args - const paths: string[] = - params.paths ?? - (nested?.paths as string[] | undefined) ?? - [params.path || (nested?.path as string) || ''].filter(Boolean) - const legacyFileIds: string[] = - params.fileIds ?? - (nested?.fileIds as string[] | undefined) ?? - [params.fileId || (nested?.fileId as string) || ''].filter(Boolean) - - if (paths.length === 0 && legacyFileIds.length === 0) { - return { success: false, message: 'paths is required' } - } - - const deletable: { id: string; name: string }[] = [] - const failed: string[] = [] - - for (const path of paths) { - const existingFile = await resolveWorkspaceFileReference(workspaceId, path) - if (!existingFile) { - failed.push(path) - continue - } - deletable.push({ id: existingFile.id, name: existingFile.name }) - } - - for (const fileId of legacyFileIds) { - const existingFile = await getWorkspaceFile(workspaceId, fileId) - if (!existingFile) { - failed.push(fileId) - continue - } - deletable.push({ id: fileId, name: existingFile.name }) - } - - if (deletable.length > 0) { - assertServerToolNotAborted(context) - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - fileIds: deletable.map((file) => file.id), - }) - if (!result.success) { - return { success: false, message: result.error || 'Failed to delete files' } - } - } - - for (const file of deletable) { - logger.info('File deleted via delete_file', { - fileId: file.id, - name: file.name, - userId: context.userId, - }) - } - - const parts: string[] = [] - if (deletable.length > 0) - parts.push(`Deleted: ${deletable.map((file) => file.name).join(', ')}`) - if (failed.length > 0) parts.push(`Not found: ${failed.join(', ')}`) - - return { - success: deletable.length > 0, - message: parts.join('. '), - data: { deleted: deletable, failed }, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 5f2bf0b7e22..01fce21d370 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox, @@ -89,10 +88,11 @@ export async function getE2BDocFormat(fileName: string): Promise ({ - betaFlag: { value: false }, +const { mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({ mockLoadCompiledDoc: vi.fn(), mockRunSandboxTask: vi.fn(), })) @@ -28,9 +27,6 @@ vi.mock('./doc-compiled-store', () => ({ loadCompiledDoc: mockLoadCompiledDoc, storeCompiledDoc: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: vi.fn(async () => betaFlag.value), -})) vi.mock('@/app/api/files/utils', () => ({ getContentType: (name: string) => name.endsWith('.pdf') @@ -54,7 +50,6 @@ describe('resolveServableDocBytes', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isDocSandboxEnabled: true }) - betaFlag.value = false }) it('swaps generated-doc source for the compiled artifact + binary content type', async () => { @@ -148,10 +143,9 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).not.toHaveBeenCalled() }) - it('throws when a generated XLSX artifact is not ready (E2B + mothership-beta enabled)', async () => { + it('throws when a generated XLSX artifact is not ready (E2B enabled)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) setEnvFlags({ isDocSandboxEnabled: true }) - betaFlag.value = true await expect( resolveServableDocBytes({ @@ -165,8 +159,6 @@ describe('resolveServableDocBytes', () => { }) it('returns raw XLSX source when there is no workspaceId (xlsx has no isolated-vm path)', async () => { - betaFlag.value = true - const result = await resolveServableDocBytes({ rawBuffer: XLSX_SOURCE, fileName: 'sheet.xlsx', diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 9236438b1e7..22c26d4b7b3 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -8,7 +7,6 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -19,7 +17,6 @@ import { import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performCreateWorkspaceFileFolder, - performDeleteWorkspaceFileItems, performMoveWorkspaceFileItems, performUpdateWorkspaceFileFolder, } from '@/lib/workspace-files/orchestration' @@ -53,13 +50,6 @@ interface MoveFileFolderArgs extends WorkspaceScopedArgs { parentId?: string | null } -interface DeleteFileFolderArgs extends WorkspaceScopedArgs { - paths?: string[] - path?: string - folderIds?: string[] - folderId?: string -} - interface MoveFileArgs extends WorkspaceScopedArgs { paths?: string[] path?: string @@ -231,13 +221,6 @@ export const createFileFolderServerTool: BaseServerTool = { - name: DeleteFileFolder.id, - async execute( - params: DeleteFileFolderArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) - const folderIds = - paths.length > 0 - ? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path))) - : (params.folderIds ?? - stringArrayValue(payload?.folderIds) ?? - [stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean)) - if (folderIds.length === 0) return { success: false, message: 'paths is required' } - - assertServerToolNotAborted(context) - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - folderIds, - }) - if (!result.success || !result.deletedItems) { - return { success: false, message: result.error || 'Failed to delete file folders' } - } - - logger.info('File folders deleted via delete_file_folder', { - workspaceId, - folderIds, - folders: result.deletedItems.folders, - files: result.deletedItems.files, - userId: context.userId, - }) - - return { - success: result.deletedItems.folders > 0 || result.deletedItems.files > 0, - message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, - data: { ...result.deletedItems, deletedFolderIds: folderIds }, - } - } catch (error) { - return { success: false, message: toError(error).message } - } - }, -} - export const moveFileServerTool: BaseServerTool = { name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 7ddf618b382..95c14ea56e7 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -8,9 +8,6 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' @@ -209,9 +206,8 @@ export async function compileDocForWrite(args: { if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { return { ok: false, - message: isDocSandboxEnabled - ? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.' - : 'Excel (.xlsx) generation requires the document sandbox, which is not enabled in this environment.', + message: + 'Excel (.xlsx) generation requires the document sandbox, which is not enabled in this environment.', } } @@ -293,32 +289,8 @@ export const workspaceFileServerTool: BaseServerTool = { [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], rename_file: ['*'], - [DeleteFile.id]: ['*'], [shareFileServerTool.name]: ['*'], move_file: ['*'], create_file_folder: ['*'], rename_file_folder: ['*'], move_file_folder: ['*'], - [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], [GenerateVideo.id]: ['generate'], @@ -177,14 +171,12 @@ const baseServerToolRegistry: Record = { [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, [renameFileServerTool.name]: renameFileServerTool, - [deleteFileServerTool.name]: deleteFileServerTool, [shareFileServerTool.name]: shareFileServerTool, [moveFileServerTool.name]: moveFileServerTool, [listFileFoldersServerTool.name]: listFileFoldersServerTool, [createFileFolderServerTool.name]: createFileFolderServerTool, [renameFileFolderServerTool.name]: renameFileFolderServerTool, [moveFileFolderServerTool.name]: moveFileFolderServerTool, - [deleteFileFolderServerTool.name]: deleteFileFolderServerTool, [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, [generateImageServerTool.name]: generateImageServerTool, [generateVideoServerTool.name]: generateVideoServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 8c515ef18f3..b0dd887cb49 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -419,6 +419,37 @@ describe('userTableServerTool.import_file', () => { }) }) + it('points a chat-upload path at materialize_file instead of globbing files/', async () => { + mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) + + const result = await userTableServerTool.execute( + { + operation: 'import_file', + args: { tableId: 'tbl_1', fileId: 'uploads/people.csv' }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/materialize_file/) + expect(result.message).not.toMatch(/glob\("files/) + }) + + it('still tells the agent to glob files\\/ for a genuine workspace-file miss', async () => { + mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) + + const result = await userTableServerTool.execute( + { + operation: 'import_file', + args: { tableId: 'tbl_1', fileId: 'files/typo.csv' }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/File not found: "files\/typo\.csv"/) + }) + it('rejects a background import while another job holds the table slot', async () => { mockResolveWorkspaceFileReference.mockResolvedValueOnce({ name: 'big.csv', diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index a88e1dee903..9d3f35438ff 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -115,6 +115,14 @@ const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) { const record = await resolveWorkspaceFileReference(workspaceId, fileReference) if (!record) { + // Only workspace files resolve here. A chat upload is a real, correctly-copied + // path, so pointing it at glob("files/**") would send the agent looking for a + // file that is not in that tree until materialize_file moves it there. + if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new Error( + `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } throw new Error( `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index da571a7cd01..feaa674f753 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -17,6 +17,7 @@ import { getToolCompletedTitle, getToolDisplayTitle, getToolStatusDisplayTitle, + getWaitCountdownTitle, humanizeToolName, mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' @@ -55,7 +56,7 @@ function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[] describe('humanizeToolName', () => { it('title-cases snake_case names', () => { - expect(humanizeToolName('manage_folder')).toBe('Manage Folder') + expect(humanizeToolName('manage_scheduled_task')).toBe('Manage Scheduled Task') }) it('title-cases kebab-case names', () => { @@ -231,17 +232,25 @@ describe('getToolDisplayTitle for the vfs verbs', () => { expect(getToolDisplayTitle('create_file')).toBe('Creating file') }) - it('shows deleted file and folder names', () => { - expect( - getToolDisplayTitle('delete_file', { - paths: ['files/Reports/Old%20Report.pdf'], - }) - ).toBe('Deleting Old Report.pdf') + it('titles rm from toolTitle, falling back to the paths', () => { + expect(getToolDisplayTitle('rm', { toolTitle: 'Old Report.pdf' })).toBe( + 'Deleting Old Report.pdf' + ) + // rm spans categories, so with no toolTitle the paths are the only signal. expect( - getToolDisplayTitle('delete_file_folder', { + getToolDisplayTitle('rm', { paths: ['files/Old%20Reports', 'files/Drafts'], }) ).toBe('Deleting Old Reports and Drafts') + expect( + getToolDisplayTitle('rm', { + paths: ['workflows/Lead%20Router'], + }) + ).toBe('Deleting Lead Router') + // rm has no TOOL_TITLES entry (its case always returns), so a bare call + // must not fall through to the humanizer and render as "Rm". + expect(getToolDisplayTitle('rm', {})).toBe('Deleting resource') + expect(getToolDisplayTitle('rm')).toBe('Deleting resource') }) it('uses the derived verb for mv titles', () => { @@ -280,8 +289,8 @@ describe('getToolDisplayTitle for workflow resources', () => { 'Editing Lead Router' ) expect( - getToolDisplayTitle('delete_workflow', { - workflowNames: ['Lead Router', 'Lead Enricher'], + getToolDisplayTitle('rm', { + paths: ['workflows/Lead%20Router', 'workflows/Lead%20Enricher'], }) ).toBe('Deleting Lead Router and Lead Enricher') }) @@ -313,16 +322,7 @@ describe('getToolDisplayTitle for managed resources', () => { }, 'Renaming Stripe to Production Stripe', ], - [ - 'manage_folder', - { operation: 'rename', path: 'workflows/Old%20Name', name: 'New Name' }, - 'Renaming Old Name to New Name', - ], - [ - 'manage_folder', - { operation: 'delete', path: 'workflows/Marketing/Q3%20Campaigns' }, - 'Deleting Q3 Campaigns', - ], + ['rm', { paths: ['workflows/Marketing/Q3%20Campaigns'] }, 'Deleting Q3 Campaigns'], ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], ['manage_skill', { operation: 'list' }, 'Viewing skills'], @@ -472,3 +472,134 @@ describe('getToolDisplayTitle for context management', () => { expect(getToolStatusDisplayTitle('Summarizing context', 'success')).toBe('Summarized context') }) }) + +describe('wait titles', () => { + // The row is on screen for the whole pause, so a bare "Wait" reads as a + // stall. The duration is the entire content of this tool. + it('names the duration it is pausing for', () => { + expect(getToolDisplayTitle('wait', { seconds: 30 })).toBe('Waiting 30s') + }) + + it('includes the reason when the model gives one', () => { + expect(getToolDisplayTitle('wait', { seconds: 15, reason: 'the test suite to finish' })).toBe( + 'Waiting 15s for the test suite to finish' + ) + }) + + it('degrades to a bare verb rather than printing a bogus duration', () => { + expect(getToolDisplayTitle('wait', {})).toBe('Waiting') + expect(getToolDisplayTitle('wait', { seconds: 0 })).toBe('Waiting') + expect(getToolDisplayTitle('wait', { seconds: 'soon' })).toBe('Waiting') + }) + + it('rounds a fractional duration instead of showing decimals', () => { + expect(getToolDisplayTitle('wait', { seconds: 2.4 })).toBe('Waiting 2s') + }) + + it('reads as past tense once the pause is over', () => { + expect(getToolCompletedTitle('Waiting 30s')).toBe('Waited 30s') + expect(getToolCompletedTitle('Waiting 15s for the test suite to finish')).toBe( + 'Waited 15s for the test suite to finish' + ) + }) +}) + +describe('terminal titles', () => { + const call = (operation: string, args?: Record) => + getToolDisplayTitle('terminal', { operation, ...(args ? { args } : {}) }) + + it('names the command being run', () => { + expect(call('run', { command: 'bun test' })).toBe('Running bun test') + }) + + it('titles each operation rather than reading as a bare "Terminal"', () => { + expect(call('read')).toBe('Reading terminal') + expect(call('input')).toBe('Typing into terminal') + expect(call('kill')).toBe('Stopping command') + expect(call('list')).toBe('Listing terminals') + expect(call('new')).toBe('Opening terminal') + expect(call('panes')).toBe('Listing tmux panes') + }) + + it('collapses newlines so a multi-line command stays one row', () => { + expect(call('run', { command: 'cd apps/sim\n bun test' })).toBe('Running cd apps/sim bun test') + }) + + it('truncates a long command rather than wrapping the row', () => { + const title = call('run', { command: 'echo '.repeat(40) }) + expect(title.length).toBeLessThanOrEqual('Running '.length + 48) + expect(title.endsWith('…')).toBe(true) + }) + + it('falls back to a generic label before the arguments have streamed in', () => { + expect(call('run')).toBe('Running command') + expect(getToolDisplayTitle('terminal', {})).toBe('Using terminal') + }) + + it('names what the user has to do when the terminal is handed over', () => { + expect(call('handoff', { reason: 'Enter your sudo password' })).toBe( + 'Waiting for you: Enter your sudo password' + ) + expect(call('handoff')).toBe('Waiting for you in the terminal') + }) + + it('still titles rows from before the tools were consolidated', () => { + // Persisted transcripts reference the old one-tool-per-operation names. + expect(getToolDisplayTitle('terminal_run', { command: 'bun test' })).toBe('Running bun test') + expect(getToolDisplayTitle('terminal_read')).toBe('Reading terminal') + }) + + it('reads as past tense once each terminal action settles', () => { + expect(getToolCompletedTitle('Running bun test')).toBe('Ran bun test') + expect(getToolCompletedTitle('Stopping command')).toBe('Stopped command') + expect(getToolCompletedTitle('Reading terminal')).toBe('Read terminal') + expect(getToolCompletedTitle('Using terminal')).toBe('Used terminal') + }) +}) + +describe('wait countdown', () => { + const args = { seconds: 30, reason: 'Claude Code to finish the summary' } + + it('starts at the full duration', () => { + expect(getWaitCountdownTitle(args, 0)).toBe('Waiting 30s for Claude Code to finish the summary') + }) + + it('counts down as the pause runs', () => { + expect(getWaitCountdownTitle(args, 5_000)).toBe( + 'Waiting 25s for Claude Code to finish the summary' + ) + expect(getWaitCountdownTitle(args, 29_000)).toBe( + 'Waiting 1s for Claude Code to finish the summary' + ) + }) + + it('holds a whole second rather than ticking on partial ones', () => { + expect(getWaitCountdownTitle(args, 999)).toBe(getWaitCountdownTitle(args, 0)) + expect(getWaitCountdownTitle(args, 1_001)).toBe(getWaitCountdownTitle(args, 1_999)) + }) + + it('drops the number at zero instead of freezing on "0s"', () => { + // The row can outlive its own countdown while the turn picks back up, and + // a stuck "0s" reads as broken. + expect(getWaitCountdownTitle(args, 30_000)).toBe( + 'Waiting for Claude Code to finish the summary' + ) + expect(getWaitCountdownTitle(args, 90_000)).toBe( + 'Waiting for Claude Code to finish the summary' + ) + }) + + it('never counts up from a clock that jumped backwards', () => { + expect(getWaitCountdownTitle(args, -5_000)).toBe( + 'Waiting 30s for Claude Code to finish the summary' + ) + }) + + it('stays a bare verb when there is no duration to count', () => { + expect(getWaitCountdownTitle({}, 3_000)).toBe('Waiting') + }) + + it('matches the static title at zero elapsed', () => { + expect(getWaitCountdownTitle(args, 0)).toBe(getToolDisplayTitle('wait', args)) + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5aee46abeb9..091d289a5b1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -85,6 +85,18 @@ function namedOperationTitle( return display ? `${display.verb} ${target || display.resource}` : placeholder } +/** Compact form of a URL for titles: host + path, no scheme/query noise. */ +function displayUrl(raw: string): string { + if (!raw) return '' + try { + const url = new URL(raw) + const path = url.pathname === '/' ? '' : url.pathname + return `${url.host}${path}`.slice(0, 80) + } catch { + return raw.slice(0, 80) + } +} + function isWorkflowArtifactPath(path: string, filename: string): boolean { const trimmed = path.trim() return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) @@ -421,15 +433,11 @@ const TOOL_TITLES: Record = { generate_video: 'Generating video', generate_audio: 'Generating audio', ffmpeg: 'Processing media', - manage_folder: 'Folder action', check_deployment_status: 'Checking deployment status', complete_scheduled_task: 'Completing scheduled task', create_file: 'Creating file', create_file_folder: 'Creating folder', create_workspace_mcp_server: 'Creating MCP server', - delete_file: 'Deleting file', - delete_file_folder: 'Deleting folder', - delete_workflow: 'Deleting workflow', delete_workspace_mcp_server: 'Deleting MCP server', deploy_api: 'Deploying API', deploy_chat: 'Deploying chat', @@ -473,6 +481,20 @@ const TOOL_TITLES: Record = { update_deployment_version: 'Updating deployment', update_scheduled_task_history: 'Updating task history', update_workspace_mcp_server: 'Updating MCP server', + // Browser agent tools without an argument-aware title. + browser_go_back: 'Going back', + browser_go_forward: 'Going forward', + browser_switch_tab: 'Switching tab', + browser_close_tab: 'Closing tab', + browser_list_tabs: 'Listing tabs', + browser_list_sessions: 'Checking signed-in sites', + browser_snapshot: 'Scanning page', + browser_read_text: 'Reading page', + browser_screenshot: 'Taking screenshot', + browser_click: 'Clicking element', + browser_type: 'Typing text', + browser_select_option: 'Selecting option', + browser_hover: 'Hovering element', // Subagent trigger tools, when surfaced as a tool call. workflow: 'Workflow Agent', run: 'Run Agent', @@ -487,6 +509,7 @@ const TOOL_TITLES: Record = { search: 'Search Agent', file: 'File Agent', media: 'Media Agent', + browser: 'Browser Agent', superagent: 'Executing action', respond: 'Gathering thoughts', context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE, @@ -532,6 +555,86 @@ export function humanizeToolName(name: string): string { return humanizeDisplayIdentifier(name) } +/** One shape, so the live countdown and the settled title never diverge. */ +function formatWaitTitle(seconds: number, reason: string): string { + const duration = seconds > 0 ? ` ${seconds}s` : '' + return reason ? `Waiting${duration} for ${reason}` : `Waiting${duration}` +} + +function requestedWaitSeconds(args: ToolArgs): number { + const raw = args?.seconds + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.round(raw) : 0 +} + +/** + * The duration is the whole content of a pause: without it the row is an + * unexplained stall, which is what the user is staring at while it runs. + */ +function waitTitle(args: ToolArgs): string { + return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) +} + +/** + * The title of a pause that is still running, counting down what is left. + * + * A number that never changes next to a spinner looks the same as a hung turn, + * and a pause is the one row where the user is doing nothing but watching it. + * At zero the number is dropped rather than frozen at "0s", because the pause + * itself is over and what remains is the turn picking back up. + */ +export function getWaitCountdownTitle(args: ToolArgs, elapsedMs: number): string { + const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000)) + const remaining = Math.max(0, requestedWaitSeconds(args) - elapsedSeconds) + return formatWaitTitle(remaining, stringArg(args, 'reason')) +} + +/** Past this a command wraps the row; the terminal panel still shows it in full. */ +const MAX_COMMAND_TITLE_LENGTH = 48 + +function runningCommandTitle(rawCommand: string): string { + const command = rawCommand.replace(/\s+/g, ' ') + if (!command) return 'Running command' + const shortened = + command.length > MAX_COMMAND_TITLE_LENGTH + ? `${command.slice(0, MAX_COMMAND_TITLE_LENGTH - 1)}…` + : command + return `Running ${shortened}` +} + +const TERMINAL_OPERATION_TITLES: Record = { + read: 'Reading terminal', + input: 'Typing into terminal', + kill: 'Stopping command', + cwd: 'Checking terminal', + list: 'Listing terminals', + new: 'Opening terminal', + switch: 'Switching terminal', + close: 'Closing terminal', + panes: 'Listing tmux panes', +} + +/** + * The terminal tool carries what it does in `operation`, so the row title has + * to come from the arguments rather than the tool name — otherwise every shell + * action in the transcript reads simply "Terminal". + */ +function terminalTitle(args: ToolArgs): string { + const operation = stringArg(args, 'operation') + const nested = args?.args + const inner: ToolArgs = + nested && typeof nested === 'object' && !Array.isArray(nested) + ? (nested as Record) + : undefined + if (operation === 'run') return runningCommandTitle(stringArg(inner, 'command')) + if (operation === 'handoff') { + // Matches the browser takeover row: the reason is the whole point of the + // row, since it is what the user has to act on. + const reason = stringArg(inner, 'reason') + return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the terminal' + } + return TERMINAL_OPERATION_TITLES[operation] ?? 'Using terminal' +} + /** * Resolve a tool-call display title from its name and arguments. Argument-aware * cases come first, then the static map, then a humanized fallback. This never @@ -564,6 +667,31 @@ export function getToolDisplayTitle(name: string, args?: Record return materializeFileTitle(args) case 'open_resource': return openResourceTitle(args) + case 'wait': + return waitTitle(args) + case 'terminal': + return terminalTitle(args) + // The surface used to be one tool per operation. Conversations recorded + // then still reference those names, so they keep their titles rather than + // regressing to a humanized "Terminal Run". + case 'terminal_run': + return runningCommandTitle(stringArg(args, 'command')) + case 'terminal_read': + return 'Reading terminal' + case 'terminal_input': + return 'Typing into terminal' + case 'terminal_kill': + return 'Stopping command' + case 'terminal_cwd': + return 'Checking terminal' + case 'terminal_list': + return 'Listing terminals' + case 'terminal_new': + return 'Opening terminal' + case 'terminal_switch': + return 'Switching terminal' + case 'terminal_close': + return 'Closing terminal' case 'restore_resource': { const type = stringArg(args, 'type') return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` @@ -607,14 +735,6 @@ export function getToolDisplayTitle(name: string, args?: Record return setGlobalWorkflowVariablesTitle(args) case 'create_file': return createFileTitle(args) - case 'delete_file': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'file')}` - } - case 'delete_file_folder': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'folder')}` - } case 'share_file': { const action = stringArg(args, 'action') || 'share' const path = stringArg(args, 'path') @@ -629,13 +749,6 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'workflowName', 'name', 'title') return `Editing ${target || 'workflow'}` } - case 'delete_workflow': { - const target = summarizeTargets( - stringArrayArg(args, 'workflowNames'), - countedResourceTarget(args, 'workflowIds', 'workflow', 'workflows') - ) - return `Deleting ${target}` - } case 'create_workspace_mcp_server': { const target = firstStringArg(args, 'name', 'serverName', 'title') return `Creating ${target || 'MCP server'}` @@ -679,6 +792,14 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Creating ${target}` : 'Creating folder' } + case 'rm': { + // toolTitle is the model's phrasing; the paths are the fallback because + // rm spans categories and there is no one noun to count. + const target = + firstStringArg(args, 'toolTitle', 'title') || + summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource') + return target ? `Deleting ${target}` : 'Deleting' + } case 'enrichment_run': { const subject = nestedStringArg( args, @@ -695,6 +816,38 @@ export function getToolDisplayTitle(name: string, args?: Record const url = stringArg(args, 'url') return url ? `Scraping ${url}` : 'Scraping page' } + case 'browser_navigate': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url}` : 'Opening page' + } + case 'browser_open_url': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url}` : 'Opening page' + } + case 'browser_open_tab': { + const url = displayUrl(stringArg(args, 'url')) + return url ? `Opening ${url} in a new tab` : 'Opening new tab' + } + case 'browser_wait_for': { + const text = stringArg(args, 'text') + return text ? `Waiting for "${text}"` : 'Waiting for page' + } + case 'browser_press_key': { + const key = stringArg(args, 'key') + return key ? `Pressing ${key}` : 'Pressing key' + } + case 'browser_scroll': { + const direction = stringArg(args, 'direction') + return direction ? `Scrolling ${direction}` : 'Scrolling page' + } + case 'browser_extract': { + const instruction = stringArg(args, 'instruction') + return instruction ? `Extracting ${instruction}` : 'Extracting page data' + } + case 'browser_request_takeover': { + const reason = stringArg(args, 'reason') + return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the browser' + } case 'crawl_website': { const url = stringArg(args, 'url') return url ? `Crawling ${url}` : 'Crawling website' @@ -763,32 +916,6 @@ export function getToolDisplayTitle(name: string, args?: Record delete: { verb: 'Deleting', resource: 'credential' }, }) } - case 'manage_folder': { - const operation = stringArg(args, 'operation') - if (operation === 'rename') { - const rawFrom = firstStringArg(args, 'oldPath', 'source', 'path', 'folderName') - const rawTo = firstStringArg(args, 'newPath', 'destination', 'newName', 'name', 'title') - const from = rawFrom ? pathLeaf(rawFrom) : '' - const to = rawTo ? pathLeaf(rawTo) : '' - if (from && to) return `Renaming ${from} to ${to}` - return to ? `Renaming folder to ${to}` : 'Renaming folder' - } - const rawTarget = firstStringArg( - args, - 'newPath', - 'destination', - 'path', - 'folderName', - 'name', - 'title' - ) - const target = rawTarget ? pathLeaf(rawTarget) : '' - return namedOperationTitle(args, target, 'Folder action', { - create: { verb: 'Creating', resource: 'folder' }, - move: { verb: 'Moving', resource: 'folder' }, - delete: { verb: 'Deleting', resource: 'folder' }, - }) - } case 'run_workflow': case 'run_from_block': case 'run_workflow_until_block': @@ -826,6 +953,8 @@ const COMPLETED_VERB_REWRITES: Record = { Cancelling: 'Cancelled', Calling: 'Called', Checking: 'Checked', + Clicking: 'Clicked', + Closing: 'Closed', Combining: 'Combined', Comparing: 'Compared', Completing: 'Completed', @@ -846,6 +975,8 @@ const COMPLETED_VERB_REWRITES: Record = { Gathering: 'Gathered', Generating: 'Generated', Getting: 'Got', + Going: 'Went', + Hovering: 'Hovered', Importing: 'Imported', Inspecting: 'Inspected', Listing: 'Listed', @@ -856,6 +987,7 @@ const COMPLETED_VERB_REWRITES: Record = { Opening: 'Opened', Overwriting: 'Overwrote', Preparing: 'Prepared', + Pressing: 'Pressed', Processing: 'Processed', Promoting: 'Promoted', Querying: 'Queried', @@ -867,19 +999,28 @@ const COMPLETED_VERB_REWRITES: Record = { Restoring: 'Restored', Running: 'Ran', Saving: 'Saved', + Scanning: 'Scanned', Scraping: 'Scraped', + Scrolling: 'Scrolled', Searching: 'Searched', + Selecting: 'Selected', Setting: 'Set', Sharing: 'Shared', + Stopping: 'Stopped', Summarizing: 'Summarized', + Switching: 'Switched', Syncing: 'Synced', + Taking: 'Took', Toggling: 'Toggled', Trimming: 'Trimmed', + Typing: 'Typed', Undeploying: 'Undeployed', Unsharing: 'Unshared', Updating: 'Updated', + Using: 'Used', Validating: 'Validated', Viewing: 'Viewed', + Waiting: 'Waited', Writing: 'Wrote', } diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index 0acdbbda432..764199f697e 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -7,9 +7,6 @@ const mocks = vi.hoisted(() => { return { FileConflictError, - ensureWorkflowAliasBacking: vi.fn(), - ensureWorkspacePlanBacking: vi.fn(), - resolveWorkflowAliasForWorkspace: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), @@ -20,15 +17,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({ - ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking, - ensureWorkspacePlanBacking: mocks.ensureWorkspacePlanBacking, -})) - -vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ - resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, -})) - vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, @@ -45,244 +33,13 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' -describe('resource writer workflow aliases', () => { +describe('resource writer', () => { beforeEach(() => { vi.clearAllMocks() - mocks.ensureWorkflowAliasBacking.mockResolvedValue({}) - mocks.ensureWorkspacePlanBacking.mockResolvedValue({}) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id') }) - it('creates workflow plan aliases through backing workspace files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-plan', - name: 'launch.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'launch.md', - 'text/markdown', - { folderId: 'folder-id', exactName: true } - ) - expect(result).toMatchObject({ - id: 'file-plan', - vfsPath: 'workflows/My%20Workflow/.plans/launch.md', - backingVfsPath: 'files/.plans/wf_1/launch.md', - mode: 'create', - }) - }) - - it('overwrites workflow changelog aliases through backing workspace files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'changelog', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/changelog.md', - backingPath: 'files/.changelogs/wf_1.md', - backingFolderPath: 'files/.changelogs', - }) - mocks.getWorkspaceFileByName.mockResolvedValue({ - id: 'file-changelog', - name: 'wf_1.md', - type: 'text/markdown', - folderPath: '.changelogs', - }) - mocks.updateWorkspaceFileContent.mockResolvedValue({ - id: 'file-changelog', - name: 'wf_1.md', - size: 7, - type: 'text/markdown', - url: '/download', - folderPath: '.changelogs', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/changelog.md', - mode: 'overwrite', - }, - buffer: Buffer.from('updated'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.updateWorkspaceFileContent).toHaveBeenCalledWith( - 'workspace-1', - 'file-changelog', - 'user-1', - Buffer.from('updated'), - 'text/markdown' - ) - expect(result).toMatchObject({ - id: 'file-changelog', - vfsPath: 'workflows/My%20Workflow/changelog.md', - backingVfsPath: 'files/.changelogs/wf_1.md', - mode: 'overwrite', - }) - }) - - it('creates root workspace plan aliases through workspace backing files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: '.plans/root.md', - backingPath: 'files/.plans/workspace/root.md', - backingFolderPath: 'files/.plans/workspace', - planRelativePath: 'root.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-root-plan', - name: 'root.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: '.plans/root.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.ensureWorkspacePlanBacking).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'workspace'], - }) - expect(result).toMatchObject({ - id: 'file-root-plan', - vfsPath: '.plans/root.md', - backingVfsPath: 'files/.plans/workspace/root.md', - mode: 'create', - }) - }) - - it('rejects direct writes to reserved workflow alias backing paths', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - - await expect( - writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/.plans/wf_1/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - ).rejects.toThrow( - 'Reserved workflow alias backing paths must be accessed through their alias path' - ) - - expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('rejects validation of reserved workflow alias backing paths', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - - await expect( - validateWorkspaceFileWriteTarget({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/.changelogs/wf_1.md', - mode: 'overwrite', - }, - }) - ).rejects.toThrow( - 'Reserved workflow alias backing paths must be accessed through their alias path' - ) - - expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() - }) - - it('uses exact-name creates for alias backing files', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-plan', - name: 'launch.md', - size: 7, - type: 'text/markdown', - url: '/download', - }) - - await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'launch.md', - 'text/markdown', - { folderId: 'folder-id', exactName: true } - ) - }) - it('auto-creates missing parent folders for plain workspace file creates', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.uploadWorkspaceFile.mockResolvedValue({ @@ -326,7 +83,6 @@ describe('resource writer workflow aliases', () => { }) it('validates create targets read-only, resolving existing parent folders without creating', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') mocks.getWorkspaceFileByName.mockResolvedValue(null) @@ -349,7 +105,6 @@ describe('resource writer workflow aliases', () => { }) it('accepts create targets with missing parent folders during validation without creating them', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) const validation = await validateWorkspaceFileWriteTarget({ @@ -370,35 +125,4 @@ describe('resource writer workflow aliases', () => { folderId: null, }) }) - - it('reports alias path when exact-name alias backing creation conflicts', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ - kind: 'plan_file', - scope: 'workflow', - workflowId: 'wf_1', - workflowName: 'My Workflow', - workflowPath: 'workflows/My%20Workflow', - aliasPath: 'workflows/My%20Workflow/.plans/launch.md', - backingPath: 'files/.plans/wf_1/launch.md', - backingFolderPath: 'files/.plans/wf_1', - planRelativePath: 'launch.md', - }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockRejectedValue(new mocks.FileConflictError('launch.md')) - - await expect( - writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'workflows/My%20Workflow/.plans/launch.md', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/markdown', - }) - ).rejects.toThrow( - 'File already exists at workflows/My%20Workflow/.plans/launch.md. Use mode "overwrite" to update it.' - ) - }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 148c0ee0333..33ad1c3530e 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,24 +1,10 @@ import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { - ensureWorkflowAliasBacking, - ensureWorkspacePlanBacking, -} from '@/lib/copilot/vfs/workflow-alias-backing' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { - isPlanAliasPath, - isWorkflowAliasBackingPath, - WORKFLOW_CHANGELOG_BACKING_FOLDER, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - type WorkflowAliasTarget, -} from '@/lib/copilot/vfs/workflow-aliases' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, normalizeWorkspaceFileItemName, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { - FileConflictError, getWorkspaceFileByName, resolveWorkspaceFileReference, updateWorkspaceFileContent, @@ -41,7 +27,6 @@ export interface WorkspaceFileWriteResult { contentType: string downloadUrl?: string vfsPath: string - backingVfsPath?: string mode: WorkspaceFileWriteMode } @@ -55,7 +40,6 @@ export type WorkspaceFileWriteValidation = | { mode: 'create' vfsPath: string - backingVfsPath?: string fileName: string /** Null for root targets AND for parent chains that don't exist yet — validation is read-only; missing folders are created at write time. */ folderId: string | null @@ -63,7 +47,6 @@ export type WorkspaceFileWriteValidation = | { mode: 'overwrite' vfsPath: string - backingVfsPath?: string existingFileId: string } @@ -122,9 +105,7 @@ async function resolveCreateTarget( throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) } } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) if (!folderId) { return { fileName: parsed.fileName, @@ -151,138 +132,11 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) } -function assertNotReservedWorkflowAliasBackingPath(path: string): void { - if (isWorkflowAliasBackingPath(path)) { - throw new Error( - `Reserved workflow alias backing paths must be accessed through their alias path: ${path}` - ) - } -} - -async function resolveWorkflowAliasFileTarget(args: { - workspaceId: string - userId?: string - alias: WorkflowAliasTarget -}): Promise { - if (args.alias.kind === 'plans_dir') { - throw new Error(`Cannot write file content to plan alias directory: ${args.alias.aliasPath}`) - } - - if (args.userId && args.alias.scope === 'workflow') { - await ensureWorkflowAliasBacking({ - workspaceId: args.workspaceId, - userId: args.userId, - workflowId: args.alias.workflowId, - workflowName: args.alias.workflowName, - }) - } else if (args.userId && args.alias.scope === 'workspace') { - await ensureWorkspacePlanBacking({ - workspaceId: args.workspaceId, - userId: args.userId, - }) - } - - if (args.alias.kind === 'changelog') { - const folderSegments = [WORKFLOW_CHANGELOG_BACKING_FOLDER] - const folderId = args.userId - ? await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: folderSegments, - }) - : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { - includeReservedSystemFolders: true, - }) - if (!folderId) { - throw new Error( - `Workflow changelog backing folder is not provisioned for ${args.alias.aliasPath}` - ) - } - const fileName = `${args.alias.workflowId}.md` - return { - fileName, - folderId, - vfsPath: args.alias.aliasPath, - existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), - } - } - - const relativeSegments = decodeVfsPathSegments(args.alias.planRelativePath ?? '') - if (relativeSegments.length === 0) { - throw new Error(`Workflow plan alias must include a file path: ${args.alias.aliasPath}`) - } - const fileName = normalizeWorkspaceFileItemName(relativeSegments.at(-1) ?? '', 'File') - const folderSegments = [ - WORKFLOW_PLANS_BACKING_FOLDER, - args.alias.scope === 'workflow' ? args.alias.workflowId : WORKSPACE_PLANS_BACKING_FOLDER, - ...relativeSegments.slice(0, -1), - ].map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) - const folderId = args.userId - ? await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: folderSegments, - }) - : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { - includeReservedSystemFolders: true, - }) - if (!folderId) { - throw new Error(`Plan backing directory is not provisioned for ${args.alias.aliasPath}.`) - } - - return { - fileName, - folderId, - vfsPath: args.alias.aliasPath, - existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), - } -} - export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string userId?: string target: WorkspaceFileWriteTarget }): Promise { - const alias = await resolveWorkflowAliasForWorkspace({ - workspaceId: args.workspaceId, - path: args.target.path, - }) - if (!alias && isPlanAliasPath(args.target.path)) { - throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) - } - if (alias) { - const resolved = await resolveWorkflowAliasFileTarget({ - workspaceId: args.workspaceId, - userId: args.userId, - alias, - }) - if (args.target.mode === 'overwrite') { - if (!resolved.existingFile) { - throw new Error(`File not found for overwrite: ${alias.aliasPath}`) - } - return { - mode: 'overwrite', - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - existingFileId: resolved.existingFile.id, - } - } - if (resolved.existingFile) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - return { - mode: 'create', - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - fileName: resolved.fileName, - folderId: resolved.folderId, - } - } - - assertNotReservedWorkflowAliasBackingPath(args.target.path) - if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { @@ -312,77 +166,6 @@ export async function writeWorkspaceFileByPath(args: { inferredMimeType: string }): Promise { const contentType = args.target.mimeType || args.inferredMimeType - const alias = await resolveWorkflowAliasForWorkspace({ - workspaceId: args.workspaceId, - path: args.target.path, - }) - if (!alias && isPlanAliasPath(args.target.path)) { - throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) - } - if (alias) { - const resolved = await resolveWorkflowAliasFileTarget({ - workspaceId: args.workspaceId, - userId: args.userId, - alias, - }) - - if (args.target.mode === 'overwrite') { - if (!resolved.existingFile) { - throw new Error(`File not found for overwrite: ${alias.aliasPath}`) - } - const updated = await updateWorkspaceFileContent( - args.workspaceId, - resolved.existingFile.id, - args.userId, - args.buffer, - contentType || resolved.existingFile.type - ) - return { - id: updated.id, - name: updated.name, - size: updated.size, - contentType: updated.type, - downloadUrl: updated.url, - vfsPath: alias.aliasPath, - backingVfsPath: vfsPathForRecord(updated), - mode: 'overwrite', - } - } - - if (resolved.existingFile) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - const uploaded = await uploadWorkspaceFile( - args.workspaceId, - args.userId, - args.buffer, - resolved.fileName, - contentType, - { folderId: resolved.folderId, exactName: true } - ).catch((error: unknown) => { - if (error instanceof FileConflictError) { - throw new Error( - `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` - ) - } - throw error - }) - return { - id: uploaded.id, - name: uploaded.name, - size: uploaded.size, - contentType: uploaded.type, - downloadUrl: uploaded.url, - vfsPath: alias.aliasPath, - backingVfsPath: alias.backingPath, - mode: 'create', - } - } - - assertNotReservedWorkflowAliasBackingPath(args.target.path) - if (args.target.mode === 'overwrite') { const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) if (!existing) { diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts deleted file mode 100644 index e770b516c7c..00000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { dbChainMockFns, drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - ensureWorkspaceFileFolderPath: vi.fn(), - listWorkspaceFileFolders: vi.fn(), - getWorkspaceFileByName: vi.fn(), - uploadWorkspaceFile: vi.fn(), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, - listWorkspaceFileFolders: mocks.listWorkspaceFileFolders, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFileByName: mocks.getWorkspaceFileByName, - uploadWorkspaceFile: mocks.uploadWorkspaceFile, -})) - -import { cleanupWorkflowAliasBacking, ensureWorkflowAliasBacking } from './workflow-alias-backing' - -describe('workflow alias backing', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.ensureWorkspaceFileFolderPath.mockImplementation(({ pathSegments }) => - Promise.resolve(`folder:${pathSegments.join('/')}`) - ) - }) - - it('provisions reserved folders and creates a headed changelog when missing', async () => { - mocks.getWorkspaceFileByName - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'file-1', name: 'wf_1.md' }) - - const result = await ensureWorkflowAliasBacking({ - workspaceId: 'workspace-1', - userId: 'user-1', - workflowId: 'wf_1', - workflowName: 'My Workflow', - }) - - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.changelogs'], - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'wf_1'], - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['.plans', 'workspace'], - }) - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('# My Workflow Changelog\n', 'utf-8'), - 'wf_1.md', - 'text/markdown', - { folderId: 'folder:.changelogs' } - ) - expect(result.changelogFile).toMatchObject({ id: 'file-1' }) - }) - - it('reuses an existing changelog backing file', async () => { - mocks.getWorkspaceFileByName.mockResolvedValueOnce({ id: 'file-existing', name: 'wf_2.md' }) - - const result = await ensureWorkflowAliasBacking({ - workspaceId: 'workspace-1', - userId: 'user-1', - workflowId: 'wf_2', - }) - - expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() - expect(result.changelogFile).toMatchObject({ id: 'file-existing' }) - }) - - describe('cleanupWorkflowAliasBacking', () => { - /** - * Folder paths resolve independently of `deletedAt`, so a live file parented - * to an archived folder still resolves to a backing path. The archived - * `.changelogs` folder below therefore has to participate in file ownership - * while staying out of the set of folders that get archived. - */ - const folders = [ - { id: 'changelog-live', path: '.changelogs', deletedAt: null }, - { id: 'changelog-archived', path: '.changelogs', deletedAt: new Date() }, - { id: 'plans-wf1', path: '.plans/wf_1', deletedAt: null }, - { id: 'plans-wf1-nested', path: '.plans/wf_1/nested', deletedAt: null }, - { id: 'plans-wf1-archived', path: '.plans/wf_1/old', deletedAt: new Date() }, - { id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null }, - { id: 'unrelated', path: 'documents', deletedAt: null }, - ] - - beforeEach(() => { - mocks.listWorkspaceFileFolders.mockResolvedValue(folders) - }) - - it('scopes file ownership by folder id, including archived folders', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - - expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested', 'plans-wf1-archived']) - expect(inArrayValues).toContainEqual(['changelog-live', 'changelog-archived']) - }) - - it('archives only live folders owned by the workflow', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - - expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested']) - expect(inArrayValues.flat()).not.toContain('plans-wf2') - expect(inArrayValues.flat()).not.toContain('unrelated') - }) - - it('matches the changelog by the workflow-scoped filename', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'wf_1.md') - }) - - it('restricts the update to workspace-context files', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'workspace') - }) - - it('still archives the changelog when the workflow has no plans folder', async () => { - mocks.listWorkspaceFileFolders.mockResolvedValue([ - { id: 'changelog-live', path: '.changelogs', deletedAt: null }, - { id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null }, - ]) - - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values) - expect(inArrayValues).toContainEqual(['changelog-live']) - expect(inArrayValues.flat()).not.toContain('plans-wf2') - expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) - }) - - /** - * The decisive guard: `and()` drops `undefined`, so an ownership clause that - * resolved to nothing would leave a WHERE matching every file in the - * workspace. No ownership must mean no UPDATE is issued at all. - */ - it('issues no update at all when the workflow owns no backing folders', async () => { - mocks.listWorkspaceFileFolders.mockResolvedValue([ - { id: 'unrelated', path: 'documents', deletedAt: null }, - ]) - - const result = await cleanupWorkflowAliasBacking({ - workspaceId: 'workspace-1', - workflowId: 'wf_missing', - }) - - expect(dbChainMockFns.update).not.toHaveBeenCalled() - expect(drizzleOrmMock.inArray).not.toHaveBeenCalled() - expect(result).toEqual({ files: 0, folders: 0 }) - }) - - it('never archives files belonging to another workflow', async () => { - await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' }) - - const inArrayValues = drizzleOrmMock.inArray.mock.calls.flatMap(([, values]) => values) - expect(inArrayValues).not.toContain('plans-wf2') - }) - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts deleted file mode 100644 index 8cec8b2ffc1..00000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { db } from '@sim/db' -import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' -import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm' -import { - WORKFLOW_CHANGELOG_BACKING_FOLDER, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, -} from '@/lib/copilot/vfs/workflow-aliases' -import { - ensureWorkspaceFileFolderPath, - listWorkspaceFileFolders, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - uploadWorkspaceFile, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -export interface WorkflowAliasBacking { - changelogFolderId: string - plansRootFolderId: string - workflowPlansFolderId: string - workspacePlansFolderId: string - changelogFile: WorkspaceFileRecord | null -} - -function initialChangelogContent(workflowName?: string): string { - const title = workflowName?.trim() || 'Workflow' - return `# ${title} Changelog\n` -} - -export async function ensureWorkflowAliasBacking(args: { - workspaceId: string - userId: string - workflowId: string - workflowName?: string -}): Promise { - const changelogFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_CHANGELOG_BACKING_FOLDER], - }) - const plansRootFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], - }) - const workflowPlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, args.workflowId], - }) - const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], - }) - - if ( - !changelogFolderId || - !plansRootFolderId || - !workflowPlansFolderId || - !workspacePlansFolderId - ) { - throw new Error('Failed to provision workflow alias backing folders') - } - - const changelogName = `${args.workflowId}.md` - let changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { - folderId: changelogFolderId, - }) - if (!changelogFile) { - await uploadWorkspaceFile( - args.workspaceId, - args.userId, - Buffer.from(initialChangelogContent(args.workflowName), 'utf-8'), - changelogName, - 'text/markdown', - { folderId: changelogFolderId } - ) - changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { - folderId: changelogFolderId, - }) - } - - return { - changelogFolderId, - plansRootFolderId, - workflowPlansFolderId, - workspacePlansFolderId, - changelogFile, - } -} - -export async function ensureWorkspacePlanBacking(args: { - workspaceId: string - userId: string -}): Promise<{ plansRootFolderId: string; workspacePlansFolderId: string }> { - const plansRootFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], - }) - const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId: args.workspaceId, - userId: args.userId, - pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], - }) - if (!plansRootFolderId || !workspacePlansFolderId) { - throw new Error('Failed to provision workspace plan backing folders') - } - return { plansRootFolderId, workspacePlansFolderId } -} - -export async function cleanupWorkflowAliasBacking(args: { - workspaceId: string - workflowId: string - deletedAt?: Date -}): Promise<{ files: number; folders: number }> { - const deletedAt = args.deletedAt ?? new Date() - const folders = await listWorkspaceFileFolders(args.workspaceId, { - scope: 'all', - includeReservedSystemFolders: true, - }) - - const workflowPlansPath = `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` - const isPlansFolder = (path: string) => - path === workflowPlansPath || path.startsWith(`${workflowPlansPath}/`) - - /** - * Folders whose files this workflow owns, resolved by id rather than by path. - * A file's `folderPath` is derived solely from its `folderId`, so matching on - * folder membership is equivalent to the path comparison it replaces — without - * loading every file in the workspace to compute it. - * - * Soft-deleted folders are included: path resolution ignores `deletedAt`, so a - * live file parented to an archived folder still resolved to these paths and - * must still be archived here. - */ - const ownedFileFolderIds = folders.filter((f) => isPlansFolder(f.path)).map((f) => f.id) - const changelogFolderIds = folders - .filter((f) => f.path === WORKFLOW_CHANGELOG_BACKING_FOLDER) - .map((f) => f.id) - - /** Only live folders are archived, matching the previous behavior. */ - const ownedFolderIds = folders - .filter((f) => !f.deletedAt && isPlansFolder(f.path)) - .map((f) => f.id) - - /** - * Collected as a list so the guard below tests the same value that is spread into - * `or()`. `and()` and `or()` both drop `undefined` arguments, so an ownership - * clause that silently resolved to nothing would leave a WHERE of workspace + - * context + not-deleted — which archives every file in the workspace. Gating on a - * non-empty list makes that unrepresentable. - */ - const ownershipFilters = [ - ownedFileFolderIds.length > 0 ? inArray(workspaceFiles.folderId, ownedFileFolderIds) : null, - changelogFolderIds.length > 0 - ? and( - inArray(workspaceFiles.folderId, changelogFolderIds), - eq(workspaceFiles.originalName, `${args.workflowId}.md`) - ) - : null, - ].filter((filter): filter is SQL => filter != null) - - let archivedFiles: { id: string }[] = [] - if (ownershipFilters.length > 0) { - archivedFiles = await db - .update(workspaceFiles) - .set({ deletedAt }) - .where( - and( - eq(workspaceFiles.workspaceId, args.workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt), - or(...ownershipFilters) - ) - ) - .returning({ id: workspaceFiles.id }) - } - - let archivedFolders: { id: string }[] = [] - if (ownedFolderIds.length > 0) { - archivedFolders = await db - .update(workspaceFileFolder) - .set({ deletedAt }) - .where( - and( - eq(workspaceFileFolder.workspaceId, args.workspaceId), - inArray(workspaceFileFolder.id, ownedFolderIds), - isNull(workspaceFileFolder.deletedAt) - ) - ) - .returning({ id: workspaceFileFolder.id }) - } - - return { files: archivedFiles.length, folders: archivedFolders.length } -} diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts deleted file mode 100644 index 7fa6fa52f7d..00000000000 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { db } from '@sim/db' -import { folder as folderTable, workflow } from '@sim/db/schema' -import { and, asc, eq, isNull } from 'drizzle-orm' -import { - buildWorkflowAliasWorkflowEntries, - isPlanAliasPath, - resolveWorkflowAliasPath, - resolveWorkspacePlanAliasPath, - type WorkflowAliasTarget, -} from '@/lib/copilot/vfs/workflow-aliases' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { canonicalizeVfsPath } from './path-utils' - -export async function resolveWorkflowAliasForWorkspace(args: { - workspaceId: string - path: string -}): Promise { - if (!(await isFeatureEnabled('mothership-beta'))) return null - if (!isPlanAliasPath(args.path)) return null - - let canonicalPath: string - try { - canonicalPath = canonicalizeVfsPath(args.path) - } catch { - canonicalPath = args.path.trim().replace(/^\/+|\/+$/g, '') - } - - const workspacePlanAlias = resolveWorkspacePlanAliasPath(canonicalPath) - if (workspacePlanAlias) return workspacePlanAlias - - const [workflowRows, folderRows] = await Promise.all([ - db - .select({ - id: workflow.id, - name: workflow.name, - folderId: workflow.folderId, - }) - .from(workflow) - .where(and(eq(workflow.workspaceId, args.workspaceId), isNull(workflow.archivedAt))) - .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), - db - .select({ - folderId: folderTable.id, - folderName: folderTable.name, - parentId: folderTable.parentId, - }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, args.workspaceId), - eq(folderTable.resourceType, 'workflow'), - isNull(folderTable.deletedAt) - ) - ) - .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)), - ]) - return resolveWorkflowAliasPath( - canonicalPath, - buildWorkflowAliasWorkflowEntries(workflowRows, folderRows) - ) -} diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts deleted file mode 100644 index 33b07013584..00000000000 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildWorkflowAliasWorkflowEntries, - isWorkflowAliasBackingPath, - resolveWorkflowAliasPath, - resolveWorkspacePlanAliasPath, - workflowChangelogBackingPath, - workspacePlanBackingPath, -} from './workflow-aliases' - -describe('workflow aliases', () => { - const folders = [ - { folderId: 'root-a', folderName: 'Folder A', parentId: null }, - { folderId: 'nested', folderName: 'Nested', parentId: 'root-a' }, - { folderId: 'root-b', folderName: 'Folder B', parentId: null }, - ] - - it('resolves root workspace plan aliases to workspace backing files', () => { - const alias = resolveWorkspacePlanAliasPath('.plans/root.md') - - expect(alias).toMatchObject({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: '.plans/root.md', - planRelativePath: 'root.md', - backingPath: workspacePlanBackingPath('root.md'), - }) - }) - - it('preserves nested root workspace plan paths in backing storage', () => { - const alias = resolveWorkspacePlanAliasPath('.plans/nested/phase-1.md') - - expect(alias).toMatchObject({ - kind: 'plan_file', - scope: 'workspace', - planRelativePath: 'nested/phase-1.md', - backingPath: 'files/.plans/workspace/nested/phase-1.md', - }) - }) - - it('rejects root plan directory paths as file aliases', () => { - expect(resolveWorkspacePlanAliasPath('.plans')).toMatchObject({ - kind: 'plans_dir', - scope: 'workspace', - }) - expect(resolveWorkspacePlanAliasPath('.plans/.folder')).toMatchObject({ - kind: 'plans_dir', - scope: 'workspace', - }) - expect(resolveWorkspacePlanAliasPath('.plans/links.json')).toBeNull() - }) - - it('resolves root workflow changelog aliases to workflow-id keyed backing files', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_123', name: 'Root Flow', folderId: null }], - [] - ) - - const alias = resolveWorkflowAliasPath('workflows/Root%20Flow/changelog.md', workflows) - - expect(alias).toMatchObject({ - kind: 'changelog', - workflowId: 'wf_123', - aliasPath: 'workflows/Root%20Flow/changelog.md', - backingPath: workflowChangelogBackingPath('wf_123'), - }) - }) - - it('resolves nested plan aliases using the workflow folder path', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_nested', name: 'Planner', folderId: 'nested' }], - folders - ) - - const alias = resolveWorkflowAliasPath( - 'workflows/Folder%20A/Nested/Planner/.plans/launch.md', - workflows - ) - - expect(alias).toMatchObject({ - kind: 'plan_file', - workflowId: 'wf_nested', - planRelativePath: 'launch.md', - backingPath: 'files/.plans/wf_nested/launch.md', - }) - }) - - it('keeps same-name workflows in different folders distinct', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [ - { id: 'wf_a', name: 'Duplicate', folderId: 'root-a' }, - { id: 'wf_b', name: 'Duplicate', folderId: 'root-b' }, - ], - folders - ) - - expect( - resolveWorkflowAliasPath('workflows/Folder%20A/Duplicate/changelog.md', workflows) - ).toMatchObject({ workflowId: 'wf_a', backingPath: 'files/.changelogs/wf_a.md' }) - expect( - resolveWorkflowAliasPath('workflows/Folder%20B/Duplicate/changelog.md', workflows) - ).toMatchObject({ workflowId: 'wf_b', backingPath: 'files/.changelogs/wf_b.md' }) - }) - - it('keeps backing paths stable across workflow rename', () => { - const before = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_stable', name: 'Old Name', folderId: null }], - [] - ) - const after = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_stable', name: 'New Name', folderId: null }], - [] - ) - - expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', before)?.backingPath).toBe( - 'files/.changelogs/wf_stable.md' - ) - expect(resolveWorkflowAliasPath('workflows/New%20Name/changelog.md', after)?.backingPath).toBe( - 'files/.changelogs/wf_stable.md' - ) - expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', after)).toBeNull() - }) - - it('rejects arbitrary workflow-local files and missing workflows', () => { - const workflows = buildWorkflowAliasWorkflowEntries( - [{ id: 'wf_123', name: 'Root Flow', folderId: null }], - [] - ) - - expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull() - expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull() - }) - - it('recognizes reserved backing paths after VFS segment canonicalization', () => { - expect(isWorkflowAliasBackingPath('files/.plans/wf_1/launch.md')).toBe(true) - expect(isWorkflowAliasBackingPath('files/%2Eplans/wf_1/launch.md')).toBe(true) - expect(isWorkflowAliasBackingPath('files/ordinary/launch.md')).toBe(false) - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.ts deleted file mode 100644 index 2ad8e6aa0da..00000000000 --- a/apps/sim/lib/copilot/vfs/workflow-aliases.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { - canonicalWorkspaceFilePath, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -export const WORKFLOW_CHANGELOG_ALIAS_NAME = 'changelog.md' -export const WORKFLOW_PLANS_ALIAS_DIR = '.plans' -export const WORKFLOW_ALIAS_LINKS_NAME = 'links.json' -export const WORKFLOW_CHANGELOG_BACKING_FOLDER = '.changelogs' -export const WORKFLOW_PLANS_BACKING_FOLDER = '.plans' -export const WORKSPACE_PLANS_BACKING_FOLDER = 'workspace' - -export type WorkflowAliasKind = 'changelog' | 'plan_file' | 'plans_dir' -export type WorkflowAliasScope = 'workspace' | 'workflow' - -export interface WorkflowAliasWorkflow { - id: string - name: string - folderPath?: string | null -} - -export interface WorkflowAliasWorkflowRow { - id: string - name: string - folderId?: string | null -} - -export interface WorkflowAliasFolderRow { - folderId: string - folderName: string - parentId: string | null -} - -interface BaseWorkflowAliasTarget { - kind: WorkflowAliasKind - scope: WorkflowAliasScope - aliasPath: string - backingPath: string - backingFolderPath: string - planRelativePath?: string -} - -export type WorkflowAliasTarget = - | (BaseWorkflowAliasTarget & { - kind: 'changelog' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plans_dir' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plan_file' - scope: 'workflow' - workflowId: string - workflowName: string - workflowPath: string - planRelativePath: string - }) - | (BaseWorkflowAliasTarget & { - kind: 'plans_dir' - scope: 'workspace' - }) - | (BaseWorkflowAliasTarget & { - kind: 'plan_file' - scope: 'workspace' - planRelativePath: string - }) - -export interface WorkflowAliasLink { - kind: WorkflowAliasKind - aliasPath: string - backingPath: string - backingFileId?: string -} - -export function workflowVfsPath(workflow: WorkflowAliasWorkflow): string { - const safeName = normalizeVfsSegment(workflow.name) - return workflow.folderPath - ? `workflows/${workflow.folderPath}/${safeName}` - : `workflows/${safeName}` -} - -export function buildWorkflowAliasWorkflowEntries( - workflows: WorkflowAliasWorkflowRow[], - folders: WorkflowAliasFolderRow[] -): WorkflowAliasWorkflow[] { - const folderMap = new Map() - for (const folder of folders) { - folderMap.set(folder.folderId, { name: folder.folderName, parentId: folder.parentId }) - } - - const folderPathCache = new Map() - const folderPath = (folderId: string): string => { - const cached = folderPathCache.get(folderId) - if (cached) return cached - - const folder = folderMap.get(folderId) - if (!folder) return '' - - const safeName = normalizeVfsSegment(folder.name) - const path = folder.parentId ? `${folderPath(folder.parentId)}/${safeName}` : safeName - folderPathCache.set(folderId, path) - return path - } - - return workflows.map((workflow) => ({ - id: workflow.id, - name: workflow.name, - folderPath: workflow.folderId ? folderPath(workflow.folderId) : null, - })) -} - -export function workflowChangelogBackingPath(workflowId: string): string { - return canonicalWorkspaceFilePath({ - folderPath: WORKFLOW_CHANGELOG_BACKING_FOLDER, - name: `${workflowId}.md`, - }) -} - -export function workflowPlansBackingFolderPath(workflowId: string): string { - return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(workflowId)}` -} - -export function workspacePlansBackingFolderPath(): string { - return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(WORKSPACE_PLANS_BACKING_FOLDER)}` -} - -export function workspacePlanBackingPath(planRelativePath: string): string { - const segments = decodeVfsPathSegments(planRelativePath) - if (segments.length === 0) { - throw new Error('Workspace plan alias must include a plan file path') - } - return canonicalWorkspaceFilePath({ - folderPath: [ - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - ...segments.slice(0, -1), - ].join('/'), - name: segments[segments.length - 1], - }) -} - -export function workflowPlanBackingPath(workflowId: string, planRelativePath: string): string { - const segments = decodeVfsPathSegments(planRelativePath) - if (segments.length === 0) { - throw new Error('Workflow plan alias must include a plan file path') - } - return canonicalWorkspaceFilePath({ - folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, workflowId, ...segments.slice(0, -1)].join('/'), - name: segments[segments.length - 1], - }) -} - -function workflowAliasTargetForPath(workflow: WorkflowAliasWorkflow, rawPath: string) { - const workflowPath = workflowVfsPath(workflow) - const changelogPath = `${workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}` - if (rawPath === changelogPath) { - return { - kind: 'changelog' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: changelogPath, - backingPath: workflowChangelogBackingPath(workflow.id), - backingFolderPath: `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}`, - } - } - - const plansDirPath = `${workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}` - if (rawPath === plansDirPath || rawPath === `${plansDirPath}/.folder`) { - return { - kind: 'plans_dir' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: plansDirPath, - backingPath: workflowPlansBackingFolderPath(workflow.id), - backingFolderPath: workflowPlansBackingFolderPath(workflow.id), - } - } - - const plansPrefix = `${plansDirPath}/` - if (rawPath.startsWith(plansPrefix)) { - const planRelativePath = rawPath.slice(plansPrefix.length) - if (!planRelativePath || planRelativePath === '.folder') return null - return { - kind: 'plan_file' as const, - scope: 'workflow' as const, - workflowId: workflow.id, - workflowName: workflow.name, - workflowPath, - aliasPath: rawPath, - backingPath: workflowPlanBackingPath(workflow.id, planRelativePath), - backingFolderPath: workflowPlansBackingFolderPath(workflow.id), - planRelativePath, - } - } - - return null -} - -export function resolveWorkspacePlanAliasPath(path: string): WorkflowAliasTarget | null { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - if ( - normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || - normalizedPath === `${WORKFLOW_PLANS_ALIAS_DIR}/.folder` - ) { - return { - kind: 'plans_dir', - scope: 'workspace', - aliasPath: WORKFLOW_PLANS_ALIAS_DIR, - backingPath: workspacePlansBackingFolderPath(), - backingFolderPath: workspacePlansBackingFolderPath(), - } - } - - const plansPrefix = `${WORKFLOW_PLANS_ALIAS_DIR}/` - if (!normalizedPath.startsWith(plansPrefix)) return null - const planRelativePath = normalizedPath.slice(plansPrefix.length) - if ( - !planRelativePath || - planRelativePath === '.folder' || - planRelativePath === WORKFLOW_ALIAS_LINKS_NAME - ) { - return null - } - return { - kind: 'plan_file', - scope: 'workspace', - aliasPath: normalizedPath, - backingPath: workspacePlanBackingPath(planRelativePath), - backingFolderPath: workspacePlansBackingFolderPath(), - planRelativePath, - } -} - -export function resolveWorkflowAliasPath( - path: string, - workflows: WorkflowAliasWorkflow[] -): WorkflowAliasTarget | null { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - if (!normalizedPath.startsWith('workflows/')) return null - - const bySpecificity = [...workflows].sort( - (a, b) => workflowVfsPath(b).length - workflowVfsPath(a).length - ) - for (const workflow of bySpecificity) { - const target = workflowAliasTargetForPath(workflow, normalizedPath) - if (target) return target - } - return null -} - -export function isWorkflowAliasPath(path: string): boolean { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath.startsWith('workflows/') && - (normalizedPath.endsWith(`/${WORKFLOW_CHANGELOG_ALIAS_NAME}`) || - normalizedPath.includes(`/${WORKFLOW_PLANS_ALIAS_DIR}/`) || - normalizedPath.endsWith(`/${WORKFLOW_PLANS_ALIAS_DIR}`)) - ) -} - -export function isWorkspacePlanAliasPath(path: string): boolean { - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || - normalizedPath.startsWith(`${WORKFLOW_PLANS_ALIAS_DIR}/`) - ) -} - -export function isPlanAliasPath(path: string): boolean { - return isWorkspacePlanAliasPath(path) || isWorkflowAliasPath(path) -} - -export function isWorkflowAliasBackingPath(path: string): boolean { - const trimmedPath = path.trim().replace(/^\/+|\/+$/g, '') - let normalizedPath = trimmedPath - if (trimmedPath.startsWith('files/')) { - try { - normalizedPath = `files/${decodeVfsPathSegments(trimmedPath.slice('files/'.length)) - .map((segment) => normalizeVfsSegment(segment)) - .join('/')}` - } catch { - normalizedPath = trimmedPath - } - } - return ( - normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` || - normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` || - normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}/`) || - normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/`) - ) -} - -export function isReservedWorkflowAliasBackingDisplayPath(path?: string | null): boolean { - if (!path) return false - const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') - return ( - normalizedPath === WORKFLOW_CHANGELOG_BACKING_FOLDER || - normalizedPath === WORKFLOW_PLANS_BACKING_FOLDER || - normalizedPath.startsWith(`${WORKFLOW_CHANGELOG_BACKING_FOLDER}/`) || - normalizedPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/`) - ) -} - -export function workflowAliasSandboxPath(aliasPath: string): string { - return `/home/user/${aliasPath.trim().replace(/^\/+/, '')}` -} - -export function buildWorkflowAliasLinks(args: { - workflowPath: string - workflowId: string - changelog?: WorkspaceFileRecord | null - planFiles?: WorkspaceFileRecord[] -}): WorkflowAliasLink[] { - const links: WorkflowAliasLink[] = [ - { - kind: 'changelog', - aliasPath: `${args.workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`, - backingPath: workflowChangelogBackingPath(args.workflowId), - backingFileId: args.changelog?.id, - }, - { - kind: 'plans_dir', - aliasPath: `${args.workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`, - backingPath: workflowPlansBackingFolderPath(args.workflowId), - }, - ] - - for (const file of args.planFiles ?? []) { - const relativePath = file.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}`, '') - .replace(/^\/+/, '') - const aliasRelativePath = encodeVfsPathSegments( - [relativePath, file.name].filter(Boolean).join('/').split('/') - ) - const aliasPath = [args.workflowPath, WORKFLOW_PLANS_ALIAS_DIR, aliasRelativePath].join('/') - links.push({ - kind: 'plan_file', - aliasPath, - backingPath: canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }), - backingFileId: file.id, - }) - } - - return links -} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 24d788280b6..2be11810418 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -85,21 +85,8 @@ import { serializeVersions, serializeWorkflowMeta, } from '@/lib/copilot/vfs/serializers' -import { - buildWorkflowAliasLinks, - isWorkflowAliasBackingPath, - WORKFLOW_ALIAS_LINKS_NAME, - WORKFLOW_CHANGELOG_ALIAS_NAME, - WORKFLOW_PLANS_ALIAS_DIR, - WORKFLOW_PLANS_BACKING_FOLDER, - WORKSPACE_PLANS_BACKING_FOLDER, - workflowChangelogBackingPath, - workspacePlanBackingPath, - workspacePlansBackingFolderPath, -} from '@/lib/copilot/vfs/workflow-aliases' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -502,7 +489,6 @@ export class WorkspaceVFS { >() private deploymentCache = new Map>() private _workspaceId = '' - private _betaEnabled = false /** * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to @@ -668,7 +654,6 @@ export class WorkspaceVFS { this.deploymentCache = new Map() this._customBlockTypes = null this._workspaceId = workspaceId - this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -698,7 +683,6 @@ export class WorkspaceVFS { customBlocksSummary, mcpServersSummary, skillsSummary, - taskSummary, jobsSummary, wsRow, members, @@ -712,10 +696,13 @@ export class WorkspaceVFS { timed('custom_blocks', this.materializeCustomBlocks(workspaceId)), timed('mcp_servers', this.materializeMcpServers(workspaceId)), timed('skills', this.materializeSkills(workspaceId)), - timed('tasks', this.materializeTasks(workspaceId, userId)), timed('jobs', this.materializeJobs(workspaceId)), timed('workspace_row', getWorkspaceWithOwner(workspaceId)), timed('members', getUsersWithPermissions(workspaceId)), + // Writes tasks/ files only — WORKSPACE.md has no Tasks section + // (recent chats reorder every turn and would bust the cached + // prompt prefix), so nothing is destructured from this one. + timed('tasks', this.materializeTasks(workspaceId, userId)), ]) const workspaceMdData = { @@ -727,7 +714,6 @@ export class WorkspaceVFS { files: fileSummary, oauthIntegrations: envSummary.oauthIntegrations, envVariables: envSummary.envVariables, - tasks: taskSummary, customTools: toolsSummary, customBlocks: customBlocksSummary, mcpServers: mcpServersSummary, @@ -884,13 +870,10 @@ export class WorkspaceVFS { path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' ): Promise { - if (!this._betaEnabled && isWorkflowAliasBackingPath(path)) { - return null - } const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId, { includeReservedSystemFiles: true }) + const files = await listWorkspaceFiles(this._workspaceId) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } @@ -1271,18 +1254,12 @@ export class WorkspaceVFS { .replace(/\/content$/, '') .replace(/^\/+/, '') - if (!this._betaEnabled && isWorkflowAliasBackingPath(fileReference)) { - return null - } if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { - scope, - includeReservedSystemFiles: this._betaEnabled, - }) + const files = await listWorkspaceFiles(this._workspaceId, { scope }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null return readFileRecord(record) @@ -1345,7 +1322,6 @@ export class WorkspaceVFS { * Returns a summary for WORKSPACE.md generation. */ private async materializeWorkflows(workspaceId: string): Promise { - const workflowArtifactsEnabled = this._betaEnabled const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), listFolders(workspaceId), @@ -1354,16 +1330,6 @@ export class WorkspaceVFS { const folderPaths = this.buildFolderPaths(folderRows) const lockedFolderIds = this.computeLockedFolderIds(folderRows) - // NOTE: materialization is a pure READ. Alias backing (changelog/plan - // folders + files) is ensured at write time — workflow create/rename - // (lib/workflows/utils) and alias writes (vfs/resource-writer, - // tools/server/files/workspace-file) — never here. Ensuring per workflow - // on every materialize meant N storage/DB writes per read tool call, and - // concurrent materializations contending on the same rows. - const workspaceFiles = workflowArtifactsEnabled - ? await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) - : [] - // Register all folders in the VFS so empty folders are discoverable. for (const { folderId } of folderRows) { const folderPath = folderPaths.get(folderId) @@ -1381,74 +1347,6 @@ export class WorkspaceVFS { const inheritedFolderLock = wf.folderId ? lockedFolderIds.has(wf.folderId) : false this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf, { inheritedFolderLock })) - if (workflowArtifactsEnabled) { - const changelog = findWorkspaceFileRecord( - workspaceFiles, - workflowChangelogBackingPath(wf.id) - ) - let changelogContent = '' - if (changelog) { - try { - changelogContent = (await readFileRecord(changelog))?.content ?? '' - } catch (err) { - logger.warn('Failed to read workflow changelog alias backing file', { - workspaceId, - workflowId: wf.id, - fileId: changelog.id, - error: toError(err).message, - }) - } - } - if (changelog) { - this.files.set(`${prefix}${WORKFLOW_CHANGELOG_ALIAS_NAME}`, changelogContent) - } - this.files.set(`${prefix}${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') - - const planFiles = workspaceFiles.filter((file) => { - if (!file.folderPath) return false - return ( - file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}` || - file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}/`) - ) - }) - for (const planFile of planFiles) { - const relativeFolder = planFile.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}`, '') - .replace(/^\/+/, '') - const aliasPlanPath = [ - prefix, - `${WORKFLOW_PLANS_ALIAS_DIR}/`, - relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', - normalizeVfsSegment(planFile.name), - ].join('') - try { - this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') - } catch (err) { - logger.warn('Failed to read workflow plan alias backing file', { - workspaceId, - workflowId: wf.id, - fileId: planFile.id, - error: toError(err).message, - }) - } - } - this.files.set( - `${prefix}${WORKFLOW_ALIAS_LINKS_NAME}`, - JSON.stringify( - { - aliases: buildWorkflowAliasLinks({ - workflowPath, - workflowId: wf.id, - changelog, - planFiles, - }), - }, - null, - 2 - ) - ) - } - // Heavy per-workflow content is LAZY: a read/glob never loads the block // graph, runs lint, or queries executions/deployments. Only a read of the // specific artifact — or a grep whose scope touches it — resolves it. @@ -1747,15 +1645,8 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const workflowArtifactsEnabled = this._betaEnabled - const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: true, - }) - const files = await listWorkspaceFiles(workspaceId, { - folders, - includeReservedSystemFiles: true, - throwOnError: true, - }) + const folders = await listWorkspaceFileFolders(workspaceId) + const files = await listWorkspaceFiles(workspaceId, { folders, throwOnError: true }) // Batch-load public share state so each file's metadata carries an ambient // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. // Fail soft: share state is only metadata enrichment, so a lookup failure @@ -1771,12 +1662,6 @@ export class WorkspaceVFS { }) } for (const folder of folders) { - if ( - !workflowArtifactsEnabled && - isWorkflowAliasBackingPath(`files/${encodeVfsPathSegments(folder.path.split('/'))}`) - ) { - continue - } this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -1785,9 +1670,6 @@ export class WorkspaceVFS { folderPath: file.folderPath, name: file.name, }) - if (!workflowArtifactsEnabled && isWorkflowAliasBackingPath(filePath)) { - continue - } const share = shareByFileId.get(file.id) const shared = share?.isActive ?? false this.files.set( @@ -1809,86 +1691,13 @@ export class WorkspaceVFS { ) } - if (workflowArtifactsEnabled) { - this.files.set(`${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') - const workspacePlanFiles = files.filter((file) => { - if (!file.folderPath) return false - return ( - file.folderPath === - `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}` || - file.folderPath.startsWith( - `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}/` - ) - ) - }) - const workspacePlanLinks = [] - for (const planFile of workspacePlanFiles) { - const relativeFolder = planFile.folderPath - ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}`, '') - .replace(/^\/+/, '') - const aliasRelativePath = [ - relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', - normalizeVfsSegment(planFile.name), - ].join('') - const aliasPlanPath = `${WORKFLOW_PLANS_ALIAS_DIR}/${aliasRelativePath}` - const relativeSegments = aliasRelativePath.split('/').slice(0, -1) - for (let index = 0; index < relativeSegments.length; index++) { - this.files.set( - `${WORKFLOW_PLANS_ALIAS_DIR}/${relativeSegments.slice(0, index + 1).join('/')}/.folder`, - '' - ) - } - try { - this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') - workspacePlanLinks.push({ - kind: 'plan_file', - scope: 'workspace', - aliasPath: aliasPlanPath, - backingPath: workspacePlanBackingPath(aliasRelativePath), - backingFileId: planFile.id, - }) - } catch (err) { - logger.warn('Failed to read workspace plan alias backing file', { - workspaceId, - fileId: planFile.id, - error: toError(err).message, - }) - } - } - this.files.set( - `${WORKFLOW_PLANS_ALIAS_DIR}/${WORKFLOW_ALIAS_LINKS_NAME}`, - JSON.stringify( - { - aliases: [ - { - kind: 'plans_dir', - scope: 'workspace', - aliasPath: WORKFLOW_PLANS_ALIAS_DIR, - backingPath: workspacePlansBackingFolderPath(), - }, - ...workspacePlanLinks, - ], - }, - null, - 2 - ) - ) - } - - return files - .filter( - (f) => - !isWorkflowAliasBackingPath( - canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) - ) - ) - .map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })) + return files.map((f) => ({ + id: f.id, + name: f.name, + type: f.type, + size: f.size, + folderPath: f.folderPath ?? null, + })) } catch (err) { logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { workspaceId, @@ -2199,13 +2008,11 @@ export class WorkspaceVFS { } /** - * Materialize mothership task chats as browsable conversation files. - * Returns a summary for WORKSPACE.md generation. + * Materialize mothership task chats as browsable conversation files under + * `tasks/{title}/`. Nothing is returned: the inventory deliberately has no + * Tasks section, so these files are reached through glob/read only. */ - private async materializeTasks( - workspaceId: string, - userId: string - ): Promise { + private async materializeTasks(workspaceId: string, userId: string): Promise { try { const taskRows = await db .select({ @@ -2275,18 +2082,11 @@ export class WorkspaceVFS { this.files.set(`${prefix}chat.json`, serializeTaskChat(messages)) } } - - return taskRows.map((t) => ({ - id: t.id, - title: t.title || 'Untitled task', - updatedAt: t.updatedAt, - })) } catch (err) { logger.warn('Failed to materialize tasks', { workspaceId, error: toError(err).message, }) - return [] } } diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 1345b72c057..115242cbfbc 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -50,6 +50,18 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Holds tools the catalog marks `requiresApproval` — shell commands, workflow + * runs, sandboxed code, deployments, integration calls — behind an explicit + * Allow / Skip prompt, blocking the mothership turn until the user answers. + * + * Off by default: turning it on makes the copilot prompt on its most frequently + * used tools, so it is an opt-in change in how the product feels, not just a + * safety toggle. With it off nothing is stamped, gated, or persisted, and an + * approval stamp arriving from Go is cleared on the way to the client. + */ +export const isCopilotToolPermissionsEnabled = isTruthy(env.COPILOT_TOOL_PERMISSIONS_ENABLED) + /** * Is billing enforcement enabled. * diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7afd1fb7480..f2df1321447 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -55,6 +55,8 @@ export const env = createEnv({ COPILOT_BILLING_ATTRIBUTION_V1_ENABLED: z.boolean().optional(), /** Rejects markerless old-Go billing traffic only when explicitly enabled. */ COPILOT_BILLING_PROTOCOL_REQUIRED: z.boolean().optional(), + /** Gates risky copilot tools behind an Allow / Skip prompt. Off by default. */ + COPILOT_TOOL_PERMISSIONS_ENABLED: z.boolean().optional(), SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API COPILOT_SOURCE_ENV: z.enum(['dev', 'staging', 'prod']).optional(), // Source Sim environment sent to mothership for callbacks COPILOT_DEV_URL: z.string().url().optional(), // Sim agent API URL for the dev mothership environment @@ -491,7 +493,6 @@ export const env = createEnv({ // Invitations - for self-hosted deployments DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments) - MOTHERSHIP_BETA_FEATURES: z.boolean().optional(), // Enable beta Mothership planning/changelog artifact surfaces // Development Tools REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index aae8a7a2b6b..7f65c3ffd9e 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -75,7 +75,6 @@ describe('getFeatureFlags', () => { it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => { const flags = await getFeatureFlags() // All registered flags should be present, disabled (env vars unset in test env) - expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) expect(flags['pii-granular-redaction']).toEqual({ enabled: false }) expect(flags['trigger-eu-region']).toEqual({ enabled: false }) @@ -103,7 +102,6 @@ describe('getFeatureFlags', () => { setEnvFlags({ isAppConfigEnabled: true }) mockFetch.mockResolvedValue(null) const flags = await getFeatureFlags() - expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) expect(flags['pii-granular-redaction']).toEqual({ enabled: false }) expect(flags['trigger-eu-region']).toEqual({ enabled: false }) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 69c32290589..b8a9e807e08 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -56,14 +56,6 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { - 'mothership-beta': { - description: - 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler. ' + - 'Note: userId/orgId targeting only works for WorkspaceVfs (resolved in materialize). ' + - 'getE2BDocFormat, resolveInputFiles, and resolveWorkflowAliasForWorkspace evaluate without ' + - 'user context — use enabled:true for global rollout rather than per-user targeting.', - fallback: 'MOTHERSHIP_BETA_FEATURES', - }, 'table-snapshot-cache': { description: 'Mount Sim tables into code sandboxes by reference via a version-keyed CSV snapshot in ' + diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 02c44a1ed97..b6fec51bb23 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,6 +5,7 @@ import http from 'http' import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' +import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' @@ -31,49 +32,6 @@ export interface AsyncValidationResult extends ValidationResult { originalHostname?: string } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated) - * - Various edge cases that regex patterns miss - */ -export function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - if (range !== 'unicast') { - return true - } - - if (addr.kind() === 'ipv6') { - const v6 = addr as ipaddr.IPv6 - const parts = v6.parts - const firstSixZero = parts.slice(0, 6).every((p) => p === 0) - if (firstSixZero) { - const embedded = ipaddr.fromByteArray([ - (parts[6] >> 8) & 0xff, - parts[6] & 0xff, - (parts[7] >> 8) & 0xff, - parts[7] & 0xff, - ]) - return embedded.range() !== 'unicast' - } - } - - return false - } catch { - return true - } -} - /** * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding * @@ -101,18 +59,10 @@ export async function validateUrlWithDNS( const hostname = parsedUrl.hostname const hostnameLower = hostname.toLowerCase() - const cleanHostname = - hostnameLower.startsWith('[') && hostnameLower.endsWith(']') - ? hostnameLower.slice(1, -1) - : hostnameLower - - let isLocalhost = cleanHostname === 'localhost' - if (ipaddr.isValid(cleanHostname)) { - const processedIP = ipaddr.process(cleanHostname).toString() - if (processedIP === '127.0.0.1' || processedIP === '::1') { - isLocalhost = true - } - } + const cleanHostname = unwrapIpv6Brackets(hostnameLower) + + // Whole loopback range — see the matching note in input-validation.ts. + const isLocalhost = cleanHostname === 'localhost' || isLoopbackIp(cleanHostname) try { // Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs @@ -121,14 +71,9 @@ export async function validateUrlWithDNS( const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true }) const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] - const resolvedIsLoopback = - ipaddr.isValid(address) && - (() => { - const ip = ipaddr.process(address).toString() - return ip === '127.0.0.1' || ip === '::1' - })() + const resolvedIsLoopback = isLoopbackIp(address) - if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { + if (isPrivateIp(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { logger.warn('URL resolves to blocked IP address', { paramName, hostname, @@ -213,7 +158,7 @@ export async function validateAndPinProxyUrl( // validateUrlWithDNS permits loopback for self-hosted dev targets; a proxy governs // egress, so loopback/private proxy hosts stay blocked unconditionally. - if (isPrivateOrReservedIP(resolvedIP)) { + if (isPrivateIp(resolvedIP)) { return { isValid: false, error: 'proxyUrl resolves to a blocked IP address' } } @@ -250,19 +195,13 @@ export async function validateDatabaseHost( return { isValid: false, error: `${paramName} is required` } } - const lowerHost = host.toLowerCase() - const cleanHost = - lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost + const cleanHost = unwrapIpv6Brackets(host.toLowerCase()) if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be localhost` } } - if ( - ipaddr.isValid(cleanHost) && - isPrivateOrReservedIP(cleanHost) && - !isPrivateDatabaseHostsAllowed - ) { + if (isPrivateIpHost(cleanHost) && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be a private IP address` } } @@ -272,7 +211,7 @@ export async function validateDatabaseHost( const resolved = await dns.lookup(cleanHost, { all: true, verbatim: true }) const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] - if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { + if (isPrivateIp(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', { paramName, hostname: host, @@ -519,7 +458,7 @@ export function createSsrfGuardedLookup(): LookupFunction { dns .lookup(hostname, { all: true, verbatim: false }) .then((addresses) => { - const usable = addresses.filter((entry) => !isPrivateOrReservedIP(entry.address)) + const usable = addresses.filter((entry) => !isPrivateIp(entry.address)) if (usable.length === 0) { callback( new Error(`Blocked by SSRF policy: ${hostname} has no publicly routable address`), @@ -552,7 +491,7 @@ function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void { url.hostname.startsWith('[') && url.hostname.endsWith(']') ? url.hostname.slice(1, -1) : url.hostname - if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) { + if (ipaddr.isValid(host) && isPrivateIp(host)) { // The pinned-private carve-out permits exactly its own validated IP as a target (a // self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing // else private (a redirect to e.g. the cloud metadata IP is still blocked). diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index e6d90a1518e..df0200af3a6 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -27,7 +27,6 @@ import { validateWorkdayTenantUrl, } from '@/lib/core/security/input-validation' import { - isPrivateOrReservedIP, validateAndPinProxyUrl, validateDatabaseHost, validateUrlWithDNS, @@ -567,147 +566,6 @@ describe('sanitizeForLogging', () => { }) }) -describe('isPrivateOrReservedIP', () => { - describe('IPv4 private/reserved ranges', () => { - it.concurrent.each([ - ['192.168.1.1'], - ['192.168.0.0'], - ['10.0.0.1'], - ['10.255.255.255'], - ['172.16.0.1'], - ['172.31.255.255'], - ['127.0.0.1'], - ['127.255.255.255'], - ['169.254.169.254'], - ['0.0.0.0'], - ['224.0.0.1'], - ])('blocks IPv4 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv6 reserved ranges', () => { - it.concurrent.each([ - ['::1'], - ['::'], - ['fe80::1'], - ['fc00::1'], - ['fd00::1'], - ['ff02::1'], - ['2001:db8::1'], - ])('blocks IPv6 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { - it.concurrent.each([ - ['::ffff:192.168.1.1'], - ['::ffff:127.0.0.1'], - ['::ffff:169.254.169.254'], - ['::ffff:c0a8:101'], - ['::ffff:0:0'], - ])('blocks mapped private/reserved %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent('allows mapped public IPv4 ::ffff:8.8.8.8', () => { - expect(isPrivateOrReservedIP('::ffff:8.8.8.8')).toBe(false) - }) - }) - - describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { - it.concurrent('blocks NAT64-encoded private IPv4', () => { - expect(isPrivateOrReservedIP('64:ff9b::192.168.1.1')).toBe(true) - }) - }) - - describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { - it.concurrent.each([ - ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], - ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], - ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], - ['::7f00:1', '127.0.0.1 (loopback)'], - ['::7f00:0001', '127.0.0.1 (zero-padded)'], - ['::a00:1', '10.0.0.1 (RFC1918)'], - ['::ac10:1', '172.16.0.1 (RFC1918)'], - ['::e000:1', '224.0.0.1 (multicast)'], - ['::192.168.1.1', 'dotted form ::192.168.1.1'], - ['::169.254.169.254', 'dotted form ::169.254.169.254'], - ['::127.0.0.1', 'dotted form ::127.0.0.1'], - ['::10.0.0.1', 'dotted form ::10.0.0.1'], - ])('blocks %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent.each([ - ['::8.8.8.8', 'dotted form embedding public IPv4'], - ['::808:808', 'hex form embedding 8.8.8.8'], - ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], - ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - - it.concurrent.each([ - ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], - ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], - ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], - ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], - ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { - it.concurrent.each([ - ['2606:4700:4700::1111'], - ['2001:4860:4860::8888'], - ['::1:c0a8:101'], - ['1::c0a8:101'], - ['1:2:3:4:5:6:c0a8:101'], - ])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 public addresses', () => { - it.concurrent.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 alternate notations', () => { - it.concurrent.each([['0177.0.0.1'], ['0x7f000001']])('blocks loopback notation %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('invalid input', () => { - it.concurrent.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g']])('rejects %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) -}) - -describe('URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)', () => { - it.concurrent('Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it', () => { - const url = new URL('http://[::192.168.1.1]/') - const cleanHostname = - url.hostname.startsWith('[') && url.hostname.endsWith(']') - ? url.hostname.slice(1, -1) - : url.hostname - expect(cleanHostname).toBe('::c0a8:101') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) - - it.concurrent('Node normalizes [::169.254.169.254] and validator blocks the metadata IP', () => { - const url = new URL('http://[::169.254.169.254]/') - const cleanHostname = url.hostname.slice(1, -1) - expect(cleanHostname).toBe('::a9fe:a9fe') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) -}) - describe('validateUrlWithDNS', () => { describe('basic validation', () => { it('should reject invalid URLs', async () => { @@ -1239,6 +1097,20 @@ describe('validateExternalUrl', () => { expect(result.isValid).toBe(true) }) + /** + * The whole 127.0.0.0/8 range is the same machine. Matching only the + * 127.0.0.1 literal made this validator disagree with MCP's domain-check, + * which has always used the shared range helper — so the same self-hosted + * address was localhost to one caller and a plain http URL to the other. + */ + it.concurrent('should treat the rest of the loopback range as localhost too', () => { + expect(validateExternalUrl('http://127.0.0.2/api').isValid).toBe(true) + expect(validateExternalUrl('http://127.1.2.3/api').isValid).toBe(true) + // Still only loopback — neighbouring private ranges stay rejected. + expect(validateExternalUrl('http://10.0.0.1/api').isValid).toBe(false) + expect(validateExternalUrl('http://192.168.1.1/api').isValid).toBe(false) + }) + it.concurrent('should accept https IPv6 loopback', () => { const result = validateExternalUrl('https://[::1]/api') expect(result.isValid).toBe(true) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index fb73b300560..823fa3b2ef3 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isLoopbackIp, isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' @@ -401,7 +402,7 @@ export function validateHostname( } if (ipaddr.isValid(lowerHostname)) { - if (isPrivateOrReservedIP(lowerHostname)) { + if (isPrivateIp(lowerHostname)) { logger.warn('Hostname matches blocked IP range', { paramName, hostname: hostname.substring(0, 100), @@ -721,16 +722,13 @@ export function validateExternalUrl( const protocol = parsedUrl.protocol const hostname = parsedUrl.hostname.toLowerCase() - const cleanHostname = - hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const cleanHostname = unwrapIpv6Brackets(hostname) - let isLocalhost = cleanHostname === 'localhost' - if (ipaddr.isValid(cleanHostname)) { - const processedIP = ipaddr.process(cleanHostname).toString() - if (processedIP === '127.0.0.1' || processedIP === '::1') { - isLocalhost = true - } - } + // The whole loopback range, not just 127.0.0.1: 127.0.0.2 is the same + // machine, and matching two literals made this validator disagree with MCP's + // domain-check about what "localhost" means. Both directions stay coherent — + // hosted rejects the wider set, self-hosted permits http on it. + const isLocalhost = cleanHostname === 'localhost' || isLoopbackIp(cleanHostname) if (isLocalhost && isHosted) { return { @@ -754,7 +752,7 @@ export function validateExternalUrl( } if (!isLocalhost && ipaddr.isValid(cleanHostname)) { - if (isPrivateOrReservedIP(cleanHostname)) { + if (isPrivateIp(cleanHostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`, @@ -797,29 +795,6 @@ export function validateProxyUrl( return validateExternalUrl(url, paramName) } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - Various edge cases that regex patterns miss - */ -function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - return range !== 'unicast' - } catch { - return true - } -} - /** * Validates an Airtable ID (base, table, or webhook ID) * diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 166094c6273..743725c5587 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,3 +1,4 @@ +import { isLoopbackHostname } from '@sim/security/hostnames' import { env, getEnv } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/env-flags' @@ -128,17 +129,6 @@ export function getEmailDomain(): string { const DEFAULT_SOCKET_URL = 'http://localhost:3002' const DEFAULT_OLLAMA_URL = 'http://localhost:11434' -export const LOCALHOST_HOSTNAMES: ReadonlySet = new Set([ - 'localhost', - '127.0.0.1', - '[::1]', - '::1', -]) - -export function isLoopbackHostname(hostname: string): boolean { - return LOCALHOST_HOSTNAMES.has(hostname) -} - /** * Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env * var) into a deduped array of normalized origins. Invalid entries are dropped. @@ -177,7 +167,7 @@ export function parseOriginList( export function isLocalhostUrl(url: string): boolean { try { const { hostname } = new URL(url) - return LOCALHOST_HOSTNAMES.has(hostname) + return isLoopbackHostname(hostname) } catch { return false } @@ -233,7 +223,7 @@ export function getSocketUrl(): string { if (explicit) return explicit const browserOrigin = getBrowserOrigin() - if (browserOrigin && !LOCALHOST_HOSTNAMES.has(new URL(browserOrigin).hostname)) { + if (browserOrigin && !isLoopbackHostname(new URL(browserOrigin).hostname)) { return browserOrigin } diff --git a/apps/sim/lib/data-drains/sources/copilot-chats.ts b/apps/sim/lib/data-drains/sources/copilot-chats.ts index 11b008ee7dc..773b5f2257d 100644 --- a/apps/sim/lib/data-drains/sources/copilot-chats.ts +++ b/apps/sim/lib/data-drains/sources/copilot-chats.ts @@ -15,8 +15,12 @@ import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/typ * The transcript no longer lives on `copilot_chats.messages` — it is assembled * per page from the normalized `copilot_messages` table, so `messages` is the * ordered list of message `content` objects rather than the DB column. + * + * `planArtifact` is omitted too: the column still exists only until a + * follow-up migration can safely drop it, and nothing writes it any more, so + * draining it would ship a dead field to every export consumer. */ -type CopilotChatRow = Omit & { +type CopilotChatRow = Omit & { messages: unknown[] } @@ -31,10 +35,10 @@ const chatColumns = { model: copilotChats.model, conversationId: copilotChats.conversationId, previewYaml: copilotChats.previewYaml, - planArtifact: copilotChats.planArtifact, config: copilotChats.config, resources: copilotChats.resources, lastSeenAt: copilotChats.lastSeenAt, + autoAllowedTools: copilotChats.autoAllowedTools, pinned: copilotChats.pinned, deletedAt: copilotChats.deletedAt, createdAt: copilotChats.createdAt, @@ -118,7 +122,6 @@ export const copilotChatsSource: DrainSource = { model: row.model, conversationId: row.conversationId, previewYaml: row.previewYaml, - planArtifact: row.planArtifact, config: row.config, resources: row.resources, lastSeenAt: row.lastSeenAt ? row.lastSeenAt.toISOString() : null, diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts new file mode 100644 index 00000000000..050c1faa484 --- /dev/null +++ b/apps/sim/lib/desktop/index.ts @@ -0,0 +1,200 @@ +/** + * THE single detection point for the Sim desktop app. + * + * The web app is served identically to browsers and to the desktop shell; the + * only difference is the preload bridge the shell injects + * (`window.simDesktop`, typed in `@sim/desktop-bridge`). Desktop features are + * progressive enhancements: feature-detect a bridge surface here, never + * assume it. + * + * Rules for scaling desktop features without scattering gates: + * - Shared code must not touch `window.simDesktop` directly — add an accessor + * here (or a feature-scoped wrapper like `lib/browser-agent/transport.ts` + * that builds on {@link getDesktopBridge}) so "everything desktop" stays + * greppable from one module. + * - Gate on the specific bridge surface a feature needs (e.g. + * {@link hasLocalFilesystem}), not on "is desktop" — older shells may lack + * newer surfaces. + * - The browser and terminal additionally have a device switch the user can + * turn off. `has*` answers whether the shell ships the surface (what the + * settings pages need, so the switch can be turned back on); `is*Enabled` + * answers whether it may actually run (what everything else needs). + * - Anything advertised to the copilot backend must flow through + * {@link getDesktopChatCapabilities} so every chat surface reports + * capabilities consistently. + */ +import type { BrowserKnownSession } from '@sim/browser-protocol' +import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' + +/** The preload bridge, or undefined outside the desktop app (and on the server). */ +export function getDesktopBridge(): SimDesktopApi | undefined { + if (typeof window === 'undefined') return undefined + return window.simDesktop +} + +/** True when running inside the Sim desktop app. */ +export function isDesktopApp(): boolean { + return getDesktopBridge() !== undefined +} + +/** True when the shell can serve read-only local-directory grants. */ +export function hasLocalFilesystem(): boolean { + return Boolean(getDesktopBridge()?.localFilesystem) +} + +/** True when the shell hosts the embedded agent browser. */ +export function hasBrowserAgent(): boolean { + return Boolean(getDesktopBridge()?.browserAgent) +} + +/** True when the shell can run an interactive local shell for the agent. */ +export function hasTerminal(): boolean { + return Boolean(getDesktopBridge()?.terminal) +} + +/** True when the shell exposes device-level desktop preferences. */ +export function hasDesktopSettings(): boolean { + return Boolean(getDesktopBridge()?.settings) +} + +/** + * The device switches for the browser and terminal, cached because the chat UI + * reads availability synchronously while the shell only answers over async + * IPC. An unread or absent value means enabled: both surfaces predate the + * preference, and both default to on. + * + * The cache is authoritative at call time, which is what tool execution and + * capability reporting need. React trees that read it in a memo settle on the + * next mount — flipping a switch happens on a settings route, so the chat view + * has unmounted by then anyway. + */ +let devicePreferences: DesktopPreferences | null = null +let devicePreferencesLoad: Promise | null = null + +function loadDevicePreferences(): Promise { + devicePreferencesLoad ??= + getDesktopBridge() + ?.settings?.getPreferences() + .then((preferences) => { + devicePreferences = preferences + }) + .catch(() => {}) ?? Promise.resolve() + return devicePreferencesLoad +} + +function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean { + void loadDevicePreferences() + return devicePreferences?.[key] !== false +} + +/** + * Publishes a preference change from the settings pages so the synchronous + * gates below stop lagging a round trip behind the shell. + */ +export function setDesktopPreferencesSnapshot(preferences: DesktopPreferences): void { + devicePreferences = preferences + devicePreferencesLoad = Promise.resolve() +} + +/** True when the agent browser is installed and switched on for this device. */ +export function isBrowserAgentEnabled(): boolean { + return hasBrowserAgent() && isSurfaceSwitchedOn('browserEnabled') +} + +/** True when the agent terminal is installed and switched on for this device. */ +export function isTerminalEnabled(): boolean { + return hasTerminal() && isSurfaceSwitchedOn('terminalEnabled') +} + +/** + * The installed shell's semver, or undefined in a browser and on shells that + * predate version reporting. Input to the minimum-shell-version gate (see + * `lib/desktop/min-version.ts`). + */ +export function getDesktopShellVersion(): string | undefined { + return getDesktopBridge()?.version +} + +/** The shell updater surface, when the installed shell provides one. */ +export function getDesktopUpdates(): SimDesktopApi['updates'] { + return getDesktopBridge()?.updates +} + +/** + * Summary of one open shell, sent with each request so the agent knows what is + * already running without having to ask. No output and no environment: only + * enough to pick a terminal and notice when one is occupied. + */ +export interface DesktopTerminalHint { + id: string + cwd?: string + running?: string + interactive?: boolean + active?: boolean +} + +export interface DesktopChatCapabilities { + desktopCapabilities?: { + localFilesystem?: true + browser?: true + terminal?: true + browserSessions?: BrowserKnownSession[] + terminals?: DesktopTerminalHint[] + } + /** Compatibility for mothership deployments predating desktopCapabilities.browser. */ + browserCapable?: true +} + +/** + * The capability fragment spread into chat request payloads. Mothership gates + * user-local VFS guidance/routing and the browser subagent on these flags, so + * in a plain web browser the model never sees the features. + */ +export async function getDesktopChatCapabilities(): Promise { + const bridge = getDesktopBridge() + // Never advertise a surface the user switched off, even on the first + // request after launch, before the cached preferences have arrived. + await loadDevicePreferences() + const localFilesystem = hasLocalFilesystem() + const browser = isBrowserAgentEnabled() + const terminal = isTerminalEnabled() + // Sent every request so the agent knows what is already running without + // spending a tool call to ask — and, more importantly, so it notices a + // terminal that is occupied instead of launching a second copy into it. + const terminals: DesktopTerminalHint[] = + terminal && bridge?.terminal?.getTabs + ? await bridge.terminal + .getTabs() + .then((state) => + state.tabs.map((tab) => ({ + id: tab.terminalId, + ...(tab.cwd ? { cwd: tab.cwd } : {}), + ...(tab.running ? { running: tab.running } : {}), + ...(tab.interactive ? { interactive: true as const } : {}), + ...(tab.active ? { active: true as const } : {}), + })) + ) + .catch(() => []) + : [] + const browserSessions = + browser && bridge?.browserAgent?.getKnownSessions + ? await bridge.browserAgent + .getKnownSessions() + .then((state) => state.sessions) + .catch(() => []) + : [] + return { + ...(localFilesystem || browser || terminal + ? { + desktopCapabilities: { + ...(localFilesystem ? { localFilesystem: true as const } : {}), + ...(browser ? { browser: true as const } : {}), + ...(terminal ? { terminal: true as const } : {}), + ...(terminals.length > 0 ? { terminals } : {}), + ...(browserSessions.length > 0 ? { browserSessions } : {}), + }, + } + : {}), + ...(browser ? { browserCapable: true } : {}), + } +} diff --git a/apps/sim/lib/desktop/min-version.test.ts b/apps/sim/lib/desktop/min-version.test.ts new file mode 100644 index 00000000000..97bf3b73903 --- /dev/null +++ b/apps/sim/lib/desktop/min-version.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { compareVersions, isShellOutdated } from '@/lib/desktop/min-version' + +describe('compareVersions', () => { + it('orders release cores numerically', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + expect(compareVersions('1.2.3', '1.2.4')).toBe(-1) + expect(compareVersions('1.10.0', '1.9.9')).toBe(1) + expect(compareVersions('v0.5.24', '0.5.24')).toBe(0) + }) + + it('ranks prereleases below their release', () => { + expect(compareVersions('1.2.3-beta.1', '1.2.3')).toBe(-1) + expect(compareVersions('1.2.3', '1.2.3-rc.9')).toBe(1) + }) + + it('compares prerelease identifiers per semver', () => { + expect(compareVersions('1.4.0-beta.2', '1.4.0-beta.10')).toBe(-1) + expect(compareVersions('1.4.0-rc.1', '1.4.0-beta.9')).toBe(1) + expect(compareVersions('1.4.0-beta.2', '1.4.0-beta.2')).toBe(0) + expect(compareVersions('1.4.0-alpha', '1.4.0-alpha.1')).toBe(-1) + }) + + it('returns null for unparseable input', () => { + expect(compareVersions('nightly', '1.2.3')).toBeNull() + expect(compareVersions('1.2', '1.2.3')).toBeNull() + }) +}) + +describe('isShellOutdated', () => { + it('never gates while the floor is 0.0.0', () => { + expect(isShellOutdated(undefined, '0.0.0')).toBe(false) + expect(isShellOutdated('0.0.1', '0.0.0')).toBe(false) + expect(isShellOutdated('garbage', '0.0.0')).toBe(false) + }) + + it('gates shells below the floor and accepts the floor and newer', () => { + expect(isShellOutdated('0.2.9', '0.3.0')).toBe(true) + expect(isShellOutdated('0.3.0', '0.3.0')).toBe(false) + expect(isShellOutdated('0.4.0', '0.3.0')).toBe(false) + }) + + it('treats a prerelease of the floor as below it', () => { + expect(isShellOutdated('0.3.0-beta.2', '0.3.0')).toBe(true) + }) + + it('fails closed for missing or unparseable shell versions once a floor is set', () => { + expect(isShellOutdated(undefined, '0.3.0')).toBe(true) + expect(isShellOutdated('nightly', '0.3.0')).toBe(true) + }) +}) diff --git a/apps/sim/lib/desktop/min-version.ts b/apps/sim/lib/desktop/min-version.ts new file mode 100644 index 00000000000..59c94bce12c --- /dev/null +++ b/apps/sim/lib/desktop/min-version.ts @@ -0,0 +1,96 @@ +/** + * The minimum desktop shell version this web deployment supports. + * + * The desktop shell is an installed binary that users update on their own + * schedule, while the web app it loads is deployed continuously — so the + * preload bridge contract (`@sim/desktop-bridge` + `@sim/browser-protocol`) + * must stay backward compatible by default. CI enforces that with a contract + * snapshot audit (`bun run check:desktop-bridge`). + * + * When a change genuinely cannot be additive, this floor is the escape + * hatch: bump it to the desktop release the breaking shell change ships in + * and regenerate the snapshot (`bun run desktop-bridge-contract:update`). + * Shells older than the floor get a blocking "update to continue" takeover + * (see `app/_shell/desktop-update-gate.tsx`) instead of silently broken + * features. + * + * `0.0.0` means no floor — every shell is accepted. + */ +export const MIN_DESKTOP_VERSION = '0.0.0' + +interface ParsedVersion { + major: number + minor: number + patch: number + prerelease: string +} + +const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?$/ + +function parseVersion(version: string): ParsedVersion | null { + const match = SEMVER_PATTERN.exec(version) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? '', + } +} + +function comparePrerelease(a: string, b: string): number { + // A release (no prerelease) outranks any prerelease of the same core. + if (a === '' || b === '') { + return a === b ? 0 : a === '' ? 1 : -1 + } + const aParts = a.split('.') + const bParts = b.split('.') + const length = Math.max(aParts.length, bParts.length) + for (let i = 0; i < length; i++) { + const aPart = aParts[i] + const bPart = bParts[i] + if (aPart === undefined) return -1 + if (bPart === undefined) return 1 + const aNumeric = /^\d+$/.test(aPart) + const bNumeric = /^\d+$/.test(bPart) + if (aNumeric && bNumeric) { + const diff = Number(aPart) - Number(bPart) + if (diff !== 0) return diff < 0 ? -1 : 1 + } else if (aNumeric !== bNumeric) { + // Numeric identifiers rank below alphanumeric ones (semver §11). + return aNumeric ? -1 : 1 + } else if (aPart !== bPart) { + return aPart < bPart ? -1 : 1 + } + } + return 0 +} + +/** Standard semver ordering; null when either version is unparseable. */ +export function compareVersions(a: string, b: string): number | null { + const left = parseVersion(a) + const right = parseVersion(b) + if (!left || !right) return null + if (left.major !== right.major) return left.major < right.major ? -1 : 1 + if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1 + if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1 + return comparePrerelease(left.prerelease, right.prerelease) +} + +/** + * Whether a desktop shell is below the supported floor and must update. + * + * Fails closed for shells inside the desktop app: an absent version means the + * shell predates version reporting (older than any real floor), and an + * unparseable one can't be vouched for. Both count as outdated whenever a + * floor is set. With the floor at `0.0.0` nothing is ever gated. + */ +export function isShellOutdated( + shellVersion: string | undefined, + minVersion: string = MIN_DESKTOP_VERSION +): boolean { + if (minVersion === '0.0.0') return false + if (shellVersion === undefined) return true + const comparison = compareVersions(shellVersion, minVersion) + return comparison === null || comparison < 0 +} diff --git a/apps/sim/lib/desktop/panel-focus.test.ts b/apps/sim/lib/desktop/panel-focus.test.ts new file mode 100644 index 00000000000..a215dee16ae --- /dev/null +++ b/apps/sim/lib/desktop/panel-focus.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it, vi } from 'vitest' +import { trackPanelFocus } from '@/lib/desktop/panel-focus' + +describe('trackPanelFocus', () => { + it('follows interaction and releases focus outside the panel or on cleanup', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + const outsideButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel, outsideButton) + const reportFocus = vi.fn() + + const cleanup = trackPanelFocus(panel, reportFocus) + // Appearing is not a claim: the agent opens these panels on the user's + // behalf, so a Cmd-W aimed at the chat must not reach a panel they have + // not touched. + expect(reportFocus).not.toHaveBeenCalled() + + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + outsideButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(false) + + panelButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + cleanup() + expect(reportFocus).toHaveBeenLastCalledWith(false) + reportFocus.mockClear() + panelButton.dispatchEvent(new Event('pointerdown', { bubbles: true })) + expect(reportFocus).not.toHaveBeenCalled() + + panel.remove() + outsideButton.remove() + }) + + /** + * The regression that motivated hoisting this out of the browser panel: a + * panel with N children must report as ONE owner. Two trackers over the same + * shell flag mean the second one's cleanup erases the first one's live claim, + * which is what let Cmd-W fall through to closing the window. + */ + it('keeps the panel claim when a sibling tracker over the same panel tears down', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel) + let focused = false + const reportFocus = (next: boolean) => { + focused = next + } + + const cleanup = trackPanelFocus(panel, reportFocus) + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(focused).toBe(true) + + // One tracker per panel: the only teardown that drops the claim is the + // panel's own. + cleanup() + expect(focused).toBe(false) + + panel.remove() + }) + + /** + * The terminal panel is opened FOR the user by the agent, so merely + * appearing must not claim the accelerator: a Cmd-W aimed at the chat would + * close a shell the user never touched. It has a real signal to wait for — + * xterm focuses its textarea once the panel is on screen. + */ + it('waits for real interaction before claiming, unless told to claim on appear', () => { + const panel = document.createElement('div') + const panelButton = document.createElement('button') + panel.appendChild(panelButton) + document.body.append(panel) + const reportFocus = vi.fn() + + const cleanup = trackPanelFocus(panel, reportFocus) + expect(reportFocus).not.toHaveBeenCalled() + + panelButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(reportFocus).toHaveBeenLastCalledWith(true) + + cleanup() + panel.remove() + }) +}) diff --git a/apps/sim/lib/desktop/panel-focus.ts b/apps/sim/lib/desktop/panel-focus.ts new file mode 100644 index 00000000000..f884771b491 --- /dev/null +++ b/apps/sim/lib/desktop/panel-focus.ts @@ -0,0 +1,50 @@ +'use client' + +/** + * Reports which renderer-owned panel currently owns the user's interaction + * context, so the desktop shell can route global menu accelerators — a Cmd-W + * typed into a shell should close that shell, not the window. + * + * Tracking belongs to the PANEL, not to the widgets inside it. A panel that + * renders N children (terminal tabs) must not let each child report on its own + * unmount: the shell holds a single flag per panel, so one child tearing down + * would erase a sibling's live claim, and the accelerator would fall through to + * closing the window. + * + * The claim follows real interaction, and only that: a document-level + * `pointerdown` or `focusin` landing inside the panel. Appearing on screen is + * NOT a claim — the agent opens these panels on the user's behalf, and a panel + * that claimed on appearance would answer a Cmd-W aimed at the chat by closing + * something the user never touched. + * + * This covers only what the shell cannot see for itself — renderer-owned + * chrome. Content rendered in a native view above the renderer (the browser's + * page) emits no DOM events here, and needs none: the shell reads that focus + * directly from the view's own `webContents`. + */ +export function trackPanelFocus( + panel: HTMLElement, + reportFocus: (focused: boolean) => void +): () => void { + // Deduped: these listeners see every pointerdown and focus move in the whole + // app, and each report is an IPC hop whose handler re-registers listeners on + // the main thread. Only transitions are worth sending. + let reported: boolean | null = null + const updateFocusOwner = (target: EventTarget | null) => { + const owns = target instanceof Node && panel.contains(target) + if (owns === reported) return + reported = owns + reportFocus(owns) + } + const handlePointerDown = (event: PointerEvent) => updateFocusOwner(event.target) + const handleFocusIn = (event: FocusEvent) => updateFocusOwner(event.target) + document.addEventListener('pointerdown', handlePointerDown, true) + document.addEventListener('focusin', handleFocusIn, true) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + document.removeEventListener('focusin', handleFocusIn, true) + // Unconditional: the panel is going away, so the shell must not be left + // holding a claim for it even if nothing inside it was ever focused. + reportFocus(false) + } +} diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts new file mode 100644 index 00000000000..6d1cb2b2e8a --- /dev/null +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + channelForHostname, + channelOfVersion, + MANIFEST_ASSET_NAME, + rewriteManifestUrls, + selectReleaseForChannel, +} from '@/lib/desktop/update-feed' + +function release( + tag: string, + options?: { draft?: boolean; prerelease?: boolean; assets?: Array<{ name: string }> } +) { + return { + tag_name: tag, + draft: options?.draft ?? false, + prerelease: options?.prerelease ?? tag.includes('-'), + assets: (options?.assets ?? [{ name: MANIFEST_ASSET_NAME }]).map((asset) => ({ + ...asset, + browser_download_url: `https://example.com/${asset.name}`, + })), + } +} + +describe('channelForHostname', () => { + it('maps hosted environments to their channels', () => { + expect(channelForHostname('dev.sim.ai')).toBe('alpha') + expect(channelForHostname('www.dev.sim.ai')).toBe('alpha') + expect(channelForHostname('staging.sim.ai')).toBe('beta') + expect(channelForHostname('www.staging.sim.ai')).toBe('beta') + expect(channelForHostname('sim.ai')).toBe('latest') + expect(channelForHostname('www.sim.ai')).toBe('latest') + }) + + it('defaults self-hosted and local deployments to stable', () => { + expect(channelForHostname('sim.example.com')).toBe('latest') + expect(channelForHostname('localhost')).toBe('latest') + }) +}) + +describe('channelOfVersion', () => { + it('classifies versions by prerelease tag', () => { + expect(channelOfVersion('0.5.24')).toBe('latest') + expect(channelOfVersion('0.5.25-beta.3')).toBe('beta') + expect(channelOfVersion('0.5.25-alpha.412')).toBe('alpha') + }) +}) + +describe('selectReleaseForChannel', () => { + const releases = [ + release('v0.5.25-alpha.412'), + release('v0.5.24'), + release('v0.5.25-beta.2'), + release('v0.5.23'), + release('v0.5.26-alpha.1', { draft: true }), + ] + + it('offers stable-only to the latest channel', () => { + expect(selectReleaseForChannel(releases, 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('offers only beta builds to the beta channel', () => { + expect(selectReleaseForChannel(releases, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + }) + + it('offers only alpha builds to the alpha channel, never beta builds', () => { + // Dev and staging both cut prereleases of the same next core version; + // semver ranks beta above alpha there, so cross-channel leakage would + // put staging builds on dev clients. + expect(selectReleaseForChannel(releases, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + }) + + it('never serves stable prod-identity builds to prerelease channels', () => { + // Alpha/beta are internal channels with their own app identity (Sim Dev / + // Sim Staging); a stable Sim.app artifact can't be applied by those + // shells, so a newer stable must not shadow the channel's own builds. + const withNewStable = [...releases, release('v0.5.25')] + expect(selectReleaseForChannel(withNewStable, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + expect(selectReleaseForChannel(withNewStable, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + expect(selectReleaseForChannel(withNewStable, 'latest')?.tag_name).toBe('v0.5.25') + }) + + it('skips stable-tagged releases flagged prerelease on the latest channel', () => { + const flagged = [release('v0.5.25', { prerelease: true }), release('v0.5.24')] + expect(selectReleaseForChannel(flagged, 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('skips releases missing the updater manifest asset', () => { + // A release whose build failed (or is mid-upload) must not take the + // channel down; the previous good release keeps serving. + const withBrokenNewest = [ + release('v0.5.25-alpha.413', { assets: [{ name: 'Sim-0.5.25-alpha.413-universal.dmg' }] }), + release('v0.5.25-alpha.412'), + ] + expect(selectReleaseForChannel(withBrokenNewest, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + }) + + it('tolerates release listings without asset data', () => { + const bare = { tag_name: 'v0.5.24', draft: false, prerelease: false } + expect(selectReleaseForChannel([bare], 'latest')?.tag_name).toBe('v0.5.24') + }) + + it('skips drafts and unparseable tags', () => { + expect(selectReleaseForChannel([release('v0.5.26-alpha.1', { draft: true })], 'alpha')).toBe( + null + ) + expect(selectReleaseForChannel([release('nightly')], 'alpha')).toBe(null) + }) +}) + +describe('rewriteManifestUrls', () => { + it('rewrites relative url and path entries to absolute asset URLs', () => { + const manifest = [ + 'version: 0.5.24', + 'files:', + ' - url: Sim-0.5.24-universal-mac.zip', + ' sha512: abc', + ' size: 123', + 'path: Sim-0.5.24-universal-mac.zip', + 'sha512: abc', + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') + const rewritten = rewriteManifestUrls(manifest, 'v0.5.24') + expect(rewritten).toContain( + ' - url: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + ) + expect(rewritten).toContain( + 'path: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + ) + expect(rewritten).toContain('sha512: abc') + }) + + it('leaves already-absolute URLs alone', () => { + const manifest = ' - url: https://cdn.example.com/Sim.zip' + expect(rewriteManifestUrls(manifest, 'v0.5.24')).toBe(manifest) + }) +}) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts new file mode 100644 index 00000000000..969477db124 --- /dev/null +++ b/apps/sim/lib/desktop/update-feed.ts @@ -0,0 +1,119 @@ +/** + * Per-environment desktop update feed resolution. + * + * Installed desktop shells ask the Sim deployment they are pointed at — + * `GET /api/desktop/update/latest-mac.yml` — instead of a global + * GitHub feed, so each environment independently controls which shell build + * its clients are offered. The environment IS the channel: + * + * - dev.sim.ai → `alpha` (per-push prerelease builds from `dev`) + * - staging.sim.ai → `beta` (per-push prerelease builds from `staging`) + * - sim.ai + self-hosted/unknown → `latest` (stable vX.Y.Z releases only) + * + * Artifacts stay on GitHub Releases (dumb storage); the feed route picks the + * right release for its channel and serves that release's electron-updater + * manifest with download URLs rewritten to absolute GitHub asset URLs. + * + * Channels are strictly isolated: alpha serves only `-alpha.` prereleases, + * beta only `-beta.` prereleases, and `latest` only stable releases. Builds + * carry per-channel app identity (Sim Dev / Sim Staging / Sim), so serving a + * stable prod-identity artifact to a dev shell would offer an update + * Squirrel.Mac cannot apply (bundle-id mismatch) — each channel only ever + * moves forward on its own artifacts. + */ +import { compareVersions } from '@/lib/desktop/min-version' + +export const DESKTOP_RELEASE_REPO = 'simstudioai/sim' + +export type DesktopUpdateChannel = 'alpha' | 'beta' | 'latest' + +/** Maps a deployment hostname to its desktop update channel. */ +export function channelForHostname(hostname: string): DesktopUpdateChannel { + const host = hostname.toLowerCase() + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) { + return 'alpha' + } + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) { + return 'beta' + } + return 'latest' +} + +/** The channel a specific version belongs to, from its prerelease tag. */ +export function channelOfVersion(version: string): DesktopUpdateChannel { + if (version.includes('-alpha.')) return 'alpha' + if (version.includes('-beta.')) return 'beta' + return 'latest' +} + +/** + * The manifest asset every desktop build uploads. electron-builder's GitHub + * provider always names it `latest-mac.yml` regardless of the version's + * prerelease tag (channels are a generic-provider concept); which channel a + * release belongs to is carried entirely by its tag. + */ +export const MANIFEST_ASSET_NAME = 'latest-mac.yml' + +/** The subset of the GitHub releases API the feed needs. */ +export interface DesktopReleaseCandidate { + tag_name: string + draft: boolean + prerelease: boolean + assets?: Array<{ name: string; browser_download_url: string }> +} + +/** + * Picks the newest release of the channel's own kind. Channels never see + * another channel's artifacts (see module docs). Releases without their + * updater manifest asset are skipped — a release created before its build + * finished (or whose build failed) must not take the channel down. Returns + * null when nothing qualifies. + */ +export function selectReleaseForChannel( + releases: DesktopReleaseCandidate[], + channel: DesktopUpdateChannel +): DesktopReleaseCandidate | null { + let best: DesktopReleaseCandidate | null = null + let bestVersion = '' + for (const release of releases) { + if (release.draft) continue + const version = release.tag_name.replace(/^v/, '') + if (channelOfVersion(version) !== channel) continue + // Defense in depth: a bare vX.Y.Z tag manually marked "pre-release" on + // GitHub must not reach stable clients. + if (channel === 'latest' && release.prerelease) continue + if (release.assets && !release.assets.some((asset) => asset.name === MANIFEST_ASSET_NAME)) { + continue + } + if (best === null) { + const valid = compareVersions(version, '0.0.0') + if (valid === null) continue + best = release + bestVersion = version + continue + } + const comparison = compareVersions(version, bestVersion) + if (comparison !== null && comparison > 0) { + best = release + bestVersion = version + } + } + return best +} + +/** + * Rewrites the manifest's relative artifact references (`url:` entries and + * the legacy top-level `path:`) to absolute GitHub release asset URLs, so + * the shell downloads artifacts (and their `.blockmap`s, resolved relative + * to the file URL) straight from GitHub while the feed itself stays served + * by this deployment. + */ +export function rewriteManifestUrls(manifest: string, tag: string): string { + const base = `https://github.com/${DESKTOP_RELEASE_REPO}/releases/download/${tag}/` + return manifest.replace(/^(\s*(?:-\s*)?(?:url|path):\s*)(\S+)\s*$/gm, (line, prefix, value) => { + if (value.startsWith('http://') || value.startsWith('https://')) { + return line + } + return `${prefix}${base}${encodeURIComponent(value)}` + }) +} diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a743765f9f4..3e7bd38b196 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -799,6 +799,28 @@ export async function cancelInvitation(invitationId: string): Promise { return result.length > 0 } +/** + * Pending, unexpired invitations addressed to an email — the invitee-facing + * list (workspace-switcher Invitations section). Session-bound callers accept + * without a token, so the rows returned here must never need one. + */ +export async function listPendingInvitationsForEmail( + email: string +): Promise { + const rows = await db + .select() + .from(invitation) + .where( + and( + sql`lower(${invitation.email}) = ${normalizeEmail(email)}`, + eq(invitation.status, 'pending'), + sql`${invitation.expiresAt} > now()` + ) + ) + .orderBy(invitation.createdAt) + return Promise.all(rows.map((row) => hydrateInvitation(row))) +} + export async function listPendingInvitationsForOrganization(organizationId: string) { return db .select({ diff --git a/apps/sim/lib/invitations/error-messages.ts b/apps/sim/lib/invitations/error-messages.ts new file mode 100644 index 00000000000..629356c6518 --- /dev/null +++ b/apps/sim/lib/invitations/error-messages.ts @@ -0,0 +1,32 @@ +/** + * Human-readable copy for invitation accept/decline failures. The accept and + * reject routes return `{ error: }` where `` is a machine code + * (e.g. `no-seats-available`); `requestJson` surfaces that raw code as the + * thrown error's `message`. In-app surfaces (the workspace-switcher + * invitations modal) map the code through here so users see a sentence, not a + * kebab-case token. The full-page `/invite` flow has its own richer map (with + * retry/auth affordances); this is the message-only slice for the in-app path. + */ +const INVITATION_ERROR_MESSAGES: Record = { + 'not-found': 'This invitation is invalid or no longer exists.', + 'invalid-token': 'This invitation link is invalid or has already been used.', + expired: 'This invitation has expired. Ask for a new one.', + 'already-processed': 'This invitation has already been accepted or declined.', + 'email-mismatch': 'This invitation was sent to a different email address.', + 'already-in-organization': + 'You are already in an organization. Leave it before accepting a new invitation.', + 'no-seats-available': + 'This organization has reached its seat limit. Ask an admin to add seats, then try again.', + 'upgrade-required': + 'The workspace owner needs an active paid plan before you can join. Ask them to update it, then try again.', + 'server-error': 'Something went wrong processing the invitation. Please try again.', +} + +/** + * Maps an invitation error code (the thrown message from a failed accept/reject + * request) to friendly copy, falling back to `fallback` for unknown codes such + * as network errors. + */ +export function getInvitationErrorMessage(code: string, fallback: string): string { + return INVITATION_ERROR_MESSAGES[code] ?? fallback +} diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 3e22087c8cf..194755c9a05 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -8,9 +8,9 @@ import { ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js' import { createLogger } from '@sim/logger' +import { isPrivateIp } from '@sim/security/ssrf' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch' @@ -102,7 +102,7 @@ export class McpClient { // permits it) — the guarded lookup would filter it, so that case keeps the legacy pin // to the validated address (old behavior + its anti-rebinding property). const guarded = resolvedIP - ? isPrivateOrReservedIP(resolvedIP) + ? isPrivateIp(resolvedIP) ? createPinnedPrivateMcpFetch(resolvedIP) : createGuardedMcpFetch() : undefined diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index b30b2d373fd..aca421d7127 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -1,33 +1,13 @@ /** * @vitest-environment node */ -import { - envFlagsMockFns, - inputValidationMock, - inputValidationMockFns, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn(), })) -vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) - -inputValidationMockFns.mockIsPrivateOrReservedIP.mockImplementation((ip: string) => { - if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true - if (ip.startsWith('172.')) { - const second = Number.parseInt(ip.split('.')[1], 10) - if (second >= 16 && second <= 31) return true - } - if (ip.startsWith('169.254.')) return true - if (ip.startsWith('127.') || ip === '::1') return true - if (ip === '0.0.0.0') return true - return false -}) - vi.mock('dns/promises', () => ({ default: { lookup: mockDnsLookup }, })) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 1c4c40e3ac1..94c66a17335 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -1,9 +1,8 @@ import dns from 'dns/promises' import { createLogger } from '@sim/logger' +import { isIpLiteral, isLoopbackIp, isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' -import * as ipaddr from 'ipaddr.js' import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags' -import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('McpDomainCheck') @@ -99,25 +98,13 @@ export function validateMcpDomain(url: string | undefined): void { } /** - * Returns true if the IP is a loopback address (full 127.0.0.0/8 range, or ::1). - */ -function isLoopbackIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) return false - return ipaddr.process(ip).range() === 'loopback' - } catch { - return false - } -} - -/** - * Returns true if the hostname is localhost or a loopback IP literal. - * Expects IPv6 brackets to already be stripped. + * Returns true if the hostname is localhost or a loopback IP literal (full + * 127.0.0.0/8 range, or ::1). Expects IPv6 brackets to already be stripped. */ function isLocalhostHostname(hostname: string): boolean { const clean = hostname.toLowerCase() if (clean === 'localhost') return true - return ipaddr.isValid(clean) && isLoopbackIP(clean) + return isLoopbackIp(clean) } /** @@ -165,8 +152,7 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise ({ - isPrivateOrReservedIP: (ip: string) => - ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', createSsrfGuardedFetchWithDispatcher: vi.fn(() => ({ fetch: mockUndiciFetch, dispatcher: { destroy: vi.fn(() => Promise.resolve()) }, })), })) +/** + * Stubbed so the suite's `203.0.113.10` reads as an ordinary public address. + * The real classifier treats TEST-NET-3 as reserved, which would route every + * "public IP" case down the pinned-private branch instead. + */ +vi.mock('@sim/security/ssrf', () => ({ + isPrivateIp: (ip: string) => ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', +})) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, })) diff --git a/apps/sim/lib/mcp/oauth/url-validation.ts b/apps/sim/lib/mcp/oauth/url-validation.ts index 81292bbe630..bae6b9aa2a4 100644 --- a/apps/sim/lib/mcp/oauth/url-validation.ts +++ b/apps/sim/lib/mcp/oauth/url-validation.ts @@ -1,4 +1,4 @@ -import { isLoopbackHostname } from '@/lib/core/utils/urls' +import { isLoopbackHostname } from '@sim/security/hostnames' export class McpOauthInsecureUrlError extends Error { constructor(url: string) { diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 32f15a96351..3d9d8518b8b 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -21,8 +21,14 @@ const { vi.mock('@/lib/core/security/input-validation.server', () => ({ createSsrfGuardedFetchWithDispatcher: mockCreateGuardedFetchWithDispatcher, createPinnedFetchWithDispatcher: mockCreatePinnedFetchWithDispatcher, - isPrivateOrReservedIP: (ip: string) => - ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', +})) +/** + * Stubbed so the suite's `203.0.113.10` reads as an ordinary public address. + * The real classifier treats TEST-NET-3 as reserved, which would route every + * "public IP" case down the pinned-private branch instead. + */ +vi.mock('@sim/security/ssrf', () => ({ + isPrivateIp: (ip: string) => ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1', })) vi.mock('@/lib/mcp/domain-check', () => ({ validateMcpServerSsrf: mockValidateMcpServerSsrf, diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index e739b2055f6..d5169a2b696 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,10 +1,10 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' +import { isPrivateIp } from '@sim/security/ssrf' import type { Agent } from 'undici' import { createPinnedFetchWithDispatcher, createSsrfGuardedFetchWithDispatcher, - isPrivateOrReservedIP, } from '@/lib/core/security/input-validation.server' import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' import { McpError } from '@/lib/mcp/types' @@ -292,7 +292,7 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU const resolvedIP = await withDeadline(validateMcpServerSsrf(target), signal) logger.info('OAuth guarded fetch: requesting', { host, guarded: Boolean(resolvedIP) }) let response: Response - if (resolvedIP && isPrivateOrReservedIP(resolvedIP)) { + if (resolvedIP && isPrivateIp(resolvedIP)) { // Self-hosted private/loopback resolution (policy-permitted): the guarded lookup // would filter the address, and an unguarded fallback would reopen rebinding — // keep the legacy pin to the validated address for exactly this case. diff --git a/apps/sim/lib/terminal/transport.ts b/apps/sim/lib/terminal/transport.ts new file mode 100644 index 00000000000..2328c53f8a3 --- /dev/null +++ b/apps/sim/lib/terminal/transport.ts @@ -0,0 +1,161 @@ +/** + * Transport for the agent terminals: real PTYs in the Sim desktop app, reached + * through the preload bridge (`window.simDesktop.terminal`). + * + * The shell processes live in the Electron main process; the renderer paints + * their bytes with xterm.js and forwards keystrokes back, so the user and the + * agent share the same terminals. Availability of this bridge, plus the device + * switch on the Terminal settings page, is what gates advertising + * `terminalCapable` to the copilot — in a regular web browser there is no + * bridge and the terminal tools are never offered. + */ +import type { SimDesktopTerminalApi } from '@sim/desktop-bridge' +import type { + TerminalOperation, + TerminalStartOptions, + TerminalTabsState, + TerminalToolArgs, +} from '@sim/terminal-protocol' +import { getDesktopBridge, isTerminalEnabled } from '@/lib/desktop' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +let initialized = false + +function bridge(): SimDesktopTerminalApi | null { + return getDesktopBridge()?.terminal ?? null +} + +/** True when terminal tools can run (gates the copilot's terminalCapable flag). */ +export function isTerminalAvailable(): boolean { + return isTerminalEnabled() +} + +/** + * Idempotently wires tab and command pushes into the store. Output is + * deliberately not routed here: each xterm subscribes to it directly so bytes + * never pass through React state. + */ +export function initTerminalTransport(): void { + if (initialized) return + const terminal = bridge() + if (!terminal) return + initialized = true + + // Every surface below is optional-called. The same web app is served to + // shells of any age, and these arrived after the terminal first shipped: a + // bare call on an older shell threw a TypeError out of the mount effect that + // invokes this, taking the whole chat view to the error boundary instead of + // degrading to "no terminal". + terminal.onTabs?.((tabs) => { + useCopilotTerminalStore.getState().setTabs(tabs) + }) + terminal.onCommand?.((event) => { + useCopilotTerminalStore.getState().applyCommandEvent(event) + }) + void terminal + .getTabs?.() + ?.then((tabs) => useCopilotTerminalStore.getState().setTabs(tabs)) + .catch(() => {}) +} + +/** + * One bridge subscription for all terminals, fanned out by id. + * + * Every mounted terminal view wants only its own bytes, but each PTY message + * crosses the context bridge once per registered listener — so a listener per + * view made a single terminal's output cost O(open tabs) crossings a message, + * most of them discarded by an id check. This keeps exactly one bridge + * listener and routes each message to the view that asked for that id. + */ +const dataHandlers = new Map void>() +let dataBridgeUnsubscribe: (() => void) | null = null + +function ensureDataBridge(): void { + if (dataBridgeUnsubscribe) return + // Only latch once a real subscription exists. Caching a no-op because the + // bridge happened to be absent on the first call left every terminal in the + // session with no output and no way to recover. + const unsubscribe = bridge()?.onData?.((id, data) => dataHandlers.get(id)?.(data)) + if (unsubscribe) dataBridgeUnsubscribe = unsubscribe +} + +/** Subscribes to raw PTY output for one terminal. */ +export function onTerminalData(terminalId: string, callback: (data: string) => void): () => void { + ensureDataBridge() + dataHandlers.set(terminalId, callback) + return () => { + if (dataHandlers.get(terminalId) === callback) dataHandlers.delete(terminalId) + } +} + +/** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Empty when the desktop bridge is unavailable, which leaves the view + * blank rather than failing the mount. + */ +/** + * Tells the desktop app whether a terminal owns keyboard focus. Menu + * accelerators are global, so Cmd-W has to know whether the user is typing in + * a shell before it decides what to close. + */ +export function reportTerminalFocused(focused: boolean): void { + bridge()?.setFocused?.(focused) +} + +/** Tells a waiting handoff that the user is done in the terminal. */ +export function finishTerminalHandoff(terminalId: string): void { + bridge()?.finishHandoff?.(terminalId) +} + +export async function getTerminalScrollback(terminalId: string): Promise { + return (await bridge()?.getScrollback(terminalId)) ?? '' +} + +export async function startTerminalSession( + options: TerminalStartOptions +): Promise { + const terminal = bridge() + if (!terminal) { + throw new Error('The Sim desktop terminal is unavailable.') + } + return terminal.start(options) +} + +export function writeToTerminal(terminalId: string, data: string): void { + bridge()?.write(terminalId, data) +} + +export function resizeTerminal(terminalId: string, cols: number, rows: number): void { + bridge()?.resize(terminalId, cols, rows) +} + +export async function openTerminal(cwd?: string): Promise { + await bridge()?.openTerminal(cwd) +} + +export async function switchTerminal(terminalId: string): Promise { + await bridge()?.switchTerminal(terminalId) +} + +export async function closeTerminal(terminalId: string): Promise { + await bridge()?.closeTerminal(terminalId) +} + +/** Executes one terminal operation in the desktop main process. */ +export async function executeTerminalTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs +): Promise { + const terminal = bridge() + if (!terminal) { + throw new Error('The Sim desktop terminal is unavailable.') + } + const response = await terminal.executeTool(toolCallId, operation, args) + if (!response.ok) { + const error = new Error(response.error || 'The terminal reported an error') + if (response.code) error.name = response.code + throw error + } + return response.result +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 6e67c22657b..12daaef648c 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -258,16 +257,8 @@ export async function getWorkspaceFileFolderPath( export async function findWorkspaceFileFolderIdByPath( workspaceId: string, - pathSegments: string[], - options?: { includeReservedSystemFolders?: boolean } + pathSegments: string[] ): Promise { - if ( - !options?.includeReservedSystemFolders && - isReservedWorkflowAliasBackingDisplayPath(pathSegments.join('/')) - ) { - return null - } - let parentId: string | null = null for (const rawSegment of pathSegments) { @@ -288,9 +279,9 @@ export async function findWorkspaceFileFolderIdByPath( export async function listWorkspaceFileFolders( workspaceId: string, - options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean } + options?: { scope?: WorkspaceFileFolderScope } ): Promise { - const { scope = 'active', includeReservedSystemFolders = false } = options ?? {} + const { scope = 'active' } = options ?? {} const rows = await db .select() .from(workspaceFileFolder) @@ -310,12 +301,7 @@ export async function listWorkspaceFileFolders( .orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt)) const paths = buildWorkspaceFileFolderPathMap(rows) - return rows - .map((row) => mapFolder(row, paths)) - .filter( - (folder) => - includeReservedSystemFolders || !isReservedWorkflowAliasBackingDisplayPath(folder.path) - ) + return rows.map((row) => mapFolder(row, paths)) } export async function getWorkspaceFileFolder( @@ -456,9 +442,7 @@ export async function ensureWorkspaceFileFolderPath(params: { // Fast path: the whole chain already exists (the common case for repeated // writes into known folders) — per-segment indexed lookups instead of // loading the workspace's entire folder table. - const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { - includeReservedSystemFolders: true, - }) + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments) if (existing) return existing // Load all active folders once and build a lookup keyed by "name|parentId" diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index b1ae9b45da2..93be39d6561 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,8 +19,6 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' -import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -83,7 +81,6 @@ interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean - includeReservedSystemFiles?: boolean /** Propagate storage errors when an incomplete list would be unsafe. */ throwOnError?: boolean } @@ -756,11 +753,7 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { - scope = 'active', - hydrateFolderPaths = true, - includeReservedSystemFiles = false, - } = options ?? {} + const { scope = 'active', hydrateFolderPaths = true } = options ?? {} const files = await db .select() .from(workspaceFiles) @@ -784,24 +777,13 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = - files.some((file) => file.folderId) && (hydrateFolderPaths || !includeReservedSystemFiles) + const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths const folders = needsFolderPaths - ? includeReservedSystemFiles && options?.folders - ? options.folders - : await listWorkspaceFileFolders(workspaceId, { - scope: 'all', - includeReservedSystemFolders: true, - }) + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) : [] const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - return files - .map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) - .filter((file) => { - if (includeReservedSystemFiles) return true - return !isReservedWorkflowAliasBackingDisplayPath(file.folderPath) - }) + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -895,9 +877,7 @@ async function getWorkspaceFileByExactReference( return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null }) } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1), { - includeReservedSystemFolders: true, - }) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null } @@ -908,12 +888,6 @@ export async function resolveWorkspaceFileReference( workspaceId: string, fileReference: string ): Promise { - const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: fileReference }) - if (alias) { - if (alias.kind === 'plans_dir') return null - return resolveWorkspaceFileReference(workspaceId, alias.backingPath) - } - const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { const file = await getWorkspaceFile(workspaceId, normalizedReference) @@ -926,7 +900,7 @@ export async function resolveWorkspaceFileReference( ) if (exactReferenceFile) return exactReferenceFile - const files = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) + const files = await listWorkspaceFiles(workspaceId) return findWorkspaceFileRecord(files, fileReference) } diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 1bc82ac6261..6543dd45095 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -23,10 +23,21 @@ export const defaultUserSettings: UserSettingsApi = { errorNotificationsEnabled: true, snapToGridSize: 0, showActionBar: true, + copilotAutoAllowedTools: [], timezone: null, lastActiveWorkspaceId: null, } +/** + * The auto-allowed tool list is a jsonb column, so the driver hands back + * `unknown`. Anything that is not an array of strings is treated as an empty + * list: a malformed value must not widen what the copilot may run unprompted. + */ +function normalizeAutoAllowedTools(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === 'string') +} + /** * Loads a user's settings, falling back to {@link defaultUserSettings} when the * user is unauthenticated or has no persisted settings row. @@ -49,6 +60,7 @@ export async function getUserSettings(userId: string | null): Promise 0) { diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 682a736d951..ed25cd352d6 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -6,7 +6,6 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server' import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -459,8 +458,6 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { throw new Error(saveResult.error || 'Failed to save workflow state') } - await ensureWorkflowAliasBacking({ workspaceId, userId, workflowId, workflowName: name }) - return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now } } diff --git a/apps/sim/package.json b/apps/sim/package.json index 2de8ab923ef..d2f851743a2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -106,12 +106,15 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", @@ -132,6 +135,11 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "ajv": "8.18.0", "archiver": "8.0.0", "better-auth": "1.6.23", diff --git a/apps/sim/stores/browser-session/store.test.ts b/apps/sim/stores/browser-session/store.test.ts new file mode 100644 index 00000000000..03629366ba9 --- /dev/null +++ b/apps/sim/stores/browser-session/store.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +describe('browser session store', () => { + beforeEach(() => { + useBrowserSessionStore.setState({ + pageState: null, + tabs: [], + activeTabId: null, + tabsSupported: false, + panelSnapshot: null, + sessionAlive: true, + }) + }) + + it('restores the active page summary from an initial tab-list read', () => { + useBrowserSessionStore.getState().setTabsState({ + activeTabId: '2', + tabs: [ + { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + active: false, + }, + { + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: true, + active: true, + }, + ], + }) + + expect(useBrowserSessionStore.getState().pageState).toEqual({ + tabId: '2', + title: 'Dashboard', + url: 'https://sim.ai/workspace', + loading: true, + canGoBack: false, + canGoForward: false, + }) + }) + + it('clears page state when the last tab closes', () => { + useBrowserSessionStore.setState({ + pageState: { + tabId: '1', + title: 'Docs', + url: 'https://docs.sim.ai', + loading: false, + canGoBack: false, + canGoForward: false, + }, + }) + + useBrowserSessionStore.getState().setTabsState({ tabs: [], activeTabId: null }) + + expect(useBrowserSessionStore.getState().pageState).toBeNull() + expect(useBrowserSessionStore.getState().sessionAlive).toBe(false) + }) +}) diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts new file mode 100644 index 00000000000..7dc70aa11b8 --- /dev/null +++ b/apps/sim/stores/browser-session/store.ts @@ -0,0 +1,151 @@ +import type { + BrowserPageState, + BrowserPanelSnapshot, + BrowserTabState, + BrowserTabsState, +} from '@sim/browser-protocol' +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface BrowserSessionState { + /** Live state of the agent browser's active page, pushed by the desktop app. */ + pageState: BrowserPageState | null + /** All live tabs, available on desktop versions with multi-tab support. */ + tabs: BrowserTabState[] + activeTabId: string | null + tabsSupported: boolean + /** Last browser frame captured for display beneath renderer overlays. */ + panelSnapshot: BrowserPanelSnapshot | null + /** False after the browser session ends; true again when a new one starts. */ + sessionAlive: boolean + setPageState: (state: BrowserPageState) => void + setTabsState: (state: BrowserTabsState) => void + setTabsSupported: (supported: boolean) => void + setPanelSnapshot: (snapshot: BrowserPanelSnapshot) => void + setSessionAlive: (alive: boolean) => void +} + +function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean { + return ( + a.tabId === b.tabId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.active === b.active && + a.pinned === b.pinned + ) +} + +/** True when two tab lists carry the same values, so the old array can be kept. */ +function tabsEqual(a: BrowserTabState[], b: BrowserTabState[]): boolean { + return a.length === b.length && a.every((tab, index) => tabFieldsEqual(tab, b[index])) +} + +function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null): boolean { + if (a === b) return true + if (!a || !b) return false + return ( + a.tabId === b.tabId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward + ) +} + +const initialState = { + pageState: null as BrowserPageState | null, + tabs: [] as BrowserTabState[], + activeTabId: null as string | null, + tabsSupported: false, + panelSnapshot: null as BrowserPanelSnapshot | null, + sessionAlive: true, +} + +export const useBrowserSessionStore = create()( + devtools( + (set) => ({ + ...initialState, + setPageState: (pageState) => + set((state) => { + if (!pageState.tabId) { + // No tab dimension to fold in; only touch pageState if it changed. + return pageStateEqual(state.pageState, pageState) + ? { sessionAlive: true } + : { pageState, sessionAlive: true } + } + const nextTabs = state.tabs.map((tab) => + tab.tabId === pageState.tabId + ? { + ...tab, + url: pageState.url, + title: pageState.title, + loading: pageState.loading, + active: true, + } + : tab.active + ? { ...tab, active: false } + : tab + ) + // Keep the old array when nothing moved, so subscribers keyed on tab + // identity do not re-render on a page-state push that changed nothing. + const tabs = tabsEqual(state.tabs, nextTabs) ? state.tabs : nextTabs + if ( + tabs === state.tabs && + state.activeTabId === pageState.tabId && + pageStateEqual(state.pageState, pageState) + ) { + return { sessionAlive: true } + } + return { pageState, sessionAlive: true, activeTabId: pageState.tabId, tabs } + }), + setTabsState: ({ tabs: incomingTabs, activeTabId }) => + set((state) => { + // Reuse the existing array when the values match, so an identical + // push (a background tab's title event, say) is inert for React. + const tabs = tabsEqual(state.tabs, incomingTabs) ? state.tabs : incomingTabs + const activeTab = tabs.find((tab) => tab.tabId === activeTabId) + const hasCurrentPageState = + state.pageState?.tabId !== undefined && state.pageState.tabId === activeTabId + return { + tabs, + activeTabId, + // A tabs-capable shell reporting an empty list is authoritative: + // there is no page to snapshot or act on. Older single-tab shells + // never call this setter, so their compatibility default remains. + sessionAlive: tabs.length > 0, + ...(!activeTab + ? { pageState: null } + : hasCurrentPageState + ? {} + : { + pageState: { + tabId: activeTab.tabId, + url: activeTab.url, + title: activeTab.title, + loading: activeTab.loading, + canGoBack: false, + canGoForward: false, + }, + }), + } + }), + setTabsSupported: (tabsSupported) => set({ tabsSupported }), + setPanelSnapshot: (panelSnapshot) => set({ panelSnapshot }), + setSessionAlive: (alive) => + set( + alive + ? { sessionAlive: true } + : { + sessionAlive: false, + pageState: null, + tabs: [], + activeTabId: null, + panelSnapshot: null, + } + ), + }), + { name: 'browser-session-store' } + ) +) diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index e905109dcd8..1046d3d2315 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -65,6 +65,23 @@ export const MOTHERSHIP_WIDTH = { MIN: 280, /** Maximum is 65% of viewport, enforced dynamically */ MAX_PERCENTAGE: 0.65, + /** + * Narrowest the chat column beside the panel may be laid out at — the + * `min-w-[320px]` class on that column in home.tsx, so the two must agree. + * + * The panel is what yields to it: the chat's flex-basis is 0, so the whole of + * any negative free space is taken out of the panel. A width written past + * `container - CHAT_MIN` therefore renders clamped while the inline style + * still reads what was asked for, and anything deriving the divider from that + * value follows an edge that is not on screen. + */ + CHAT_MIN: 320, + /** + * Share of the viewport the panel takes while unpinned — the `w-1/2` class in + * mothership-view. Also reported to the desktop shell so it can re-derive the + * panel rect mid-resize, so the two must agree. + */ + DEFAULT_RATIO: 0.5, } as const /** Terminal block column width - minimum width for the logs column */ diff --git a/apps/sim/stores/copilot-terminal/store.test.ts b/apps/sim/stores/copilot-terminal/store.test.ts new file mode 100644 index 00000000000..33fbd0b846c --- /dev/null +++ b/apps/sim/stores/copilot-terminal/store.test.ts @@ -0,0 +1,96 @@ +import type { TerminalTabState } from '@sim/terminal-protocol' +import { beforeEach, describe, expect, it } from 'vitest' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +function tab(overrides: Partial = {}): TerminalTabState { + return { + terminalId: 't1', + title: 'code', + cwd: '/Users/me/code', + running: null, + interactive: false, + active: true, + ...overrides, + } +} + +describe('copilot terminal store', () => { + beforeEach(() => { + useCopilotTerminalStore.setState({ + tabs: { tabs: [], activeTerminalId: null }, + agentCommandIds: [], + }) + }) + + /** + * The desktop app re-pushes the whole tab list on a timer, so an identical + * push has to keep its identity or the panel and every terminal in it + * re-render once a second for nothing. + */ + it('keeps state identity when a push says nothing new', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).toBe(first) + }) + + it.each([ + ['a command starts', { running: 'bun test' }], + ['the directory changes', { cwd: '/tmp' }], + ['the title changes', { title: 'tmp' }], + ['a full-screen program takes over', { interactive: true }], + ['the tab stops being active', { active: false }], + ['tmux attaches', { tmuxSession: 'main' }], + ])('takes the update when %s', (_case, change) => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab(change)], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + expect(useCopilotTerminalStore.getState().tabs.tabs[0]).toMatchObject(change) + }) + + it('takes the update when the active terminal changes', () => { + const { setTabs } = useCopilotTerminalStore.getState() + const tabs = [tab(), tab({ terminalId: 't2', active: false })] + setTabs({ tabs, activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs, activeTerminalId: 't2' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + }) + + it('takes the update when a tab opens or closes', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ tabs: [tab(), tab({ terminalId: 't2' })], activeTerminalId: 't1' }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + expect(useCopilotTerminalStore.getState().tabs.tabs).toHaveLength(2) + }) + + /** + * The comparator walks keys rather than a written-out field list precisely so + * that a field added to the protocol cannot quietly stop reaching the UI. + */ + it('takes the update when a tab carries a field the comparator never named', () => { + const { setTabs } = useCopilotTerminalStore.getState() + setTabs({ tabs: [tab()], activeTerminalId: 't1' }) + const first = useCopilotTerminalStore.getState().tabs + + setTabs({ + tabs: [{ ...tab(), somethingNew: true } as TerminalTabState], + activeTerminalId: 't1', + }) + + expect(useCopilotTerminalStore.getState().tabs).not.toBe(first) + }) +}) diff --git a/apps/sim/stores/copilot-terminal/store.ts b/apps/sim/stores/copilot-terminal/store.ts new file mode 100644 index 00000000000..7008718e11b --- /dev/null +++ b/apps/sim/stores/copilot-terminal/store.ts @@ -0,0 +1,75 @@ +import type { + TerminalCommandEvent, + TerminalTabState, + TerminalTabsState, +} from '@sim/terminal-protocol' +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** + * Renderer-side view of the agent terminals. Deliberately holds no PTY output: + * each xterm instance owns its own byte stream and scrollback, and pushing + * hundreds of chunks a second through React state would stall the UI. + * + * Named `copilot-terminal` because `stores/terminal` is the workflow editor's + * execution-log panel, which is unrelated. + */ +interface CopilotTerminalState { + tabs: TerminalTabsState + /** Tool call ids whose commands the agent is currently running. */ + agentCommandIds: string[] + setTabs: (tabs: TerminalTabsState) => void + applyCommandEvent: (event: TerminalCommandEvent) => void + reset: () => void +} + +const initialState = { + tabs: { tabs: [], activeTerminalId: null } as TerminalTabsState, + agentCommandIds: [] as string[], +} + +/** + * The desktop app pushes the whole tab list whenever any one tab's metadata + * moves — the cwd poll alone repeats it once a second. Storing a fresh object + * each time re-renders the panel and every terminal in it for a push that + * usually says nothing new, so unchanged state keeps its identity. + */ +function tabsEqual(a: TerminalTabsState, b: TerminalTabsState): boolean { + if (a.activeTerminalId !== b.activeTerminalId) return false + if (a.tabs.length !== b.tabs.length) return false + return a.tabs.every((tab, index) => tabEqual(tab, b.tabs[index])) +} + +/** + * Compares by key rather than by a written-out field list, so a field added to + * the protocol cannot quietly stop reaching the UI. Every field is a primitive, + * which makes this total. + */ +function tabEqual(a: TerminalTabState, b: TerminalTabState): boolean { + const keys = Object.keys(a) as Array + if (keys.length !== Object.keys(b).length) return false + return keys.every((key) => a[key] === b[key]) +} + +export const useCopilotTerminalStore = create()( + devtools( + (set) => ({ + ...initialState, + setTabs: (tabs) => set((state) => (tabsEqual(state.tabs, tabs) ? {} : { tabs })), + applyCommandEvent: (event) => + set((state) => { + if (!event.toolCallId) return {} + const toolCallId = event.toolCallId + return event.phase === 'start' + ? { + agentCommandIds: state.agentCommandIds.includes(toolCallId) + ? state.agentCommandIds + : [...state.agentCommandIds, toolCallId], + } + : { agentCommandIds: state.agentCommandIds.filter((id) => id !== toolCallId) } + }), + reset: () => set({ ...initialState }), + }), + { name: 'copilot-terminal-store' } + ) +) diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index a46bc93adc1..dbf91a502a4 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -29,6 +29,14 @@ export type ChatContext = | { kind: 'filefolder'; fileFolderId: string; label: string } | { kind: 'scheduledtask'; scheduleId: string; label: string } | { kind: 'docs'; label: string } + /** + * A tab in the desktop browser or terminal panel, dragged into the input to + * say "this one". A pointer rather than a snapshot: the agent reads the tab + * with its own tools, so what it sees is the live state at the moment it + * looks rather than whatever was on screen when the message was sent. + */ + | { kind: 'browser_tab'; tabId: string; label: string } + | { kind: 'terminal_tab'; terminalId: string; label: string } | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index 9d78900c496..bf63a663d3e 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -24,6 +24,15 @@ function applySidebarWidth(width: number) { document.documentElement.style.setProperty('--sidebar-width', `${value}px`) } +/** Reads the host-specific collapsed width established by the pre-paint layout script. */ +function getCollapsedSidebarWidth(): number { + if (typeof document === 'undefined') return SIDEBAR_WIDTH.COLLAPSED + const value = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-collapsed-width') + ) + return Number.isFinite(value) ? value : SIDEBAR_WIDTH.COLLAPSED +} + /** * The `sidebar_collapsed` cookie is the single source of truth for collapse: the * server layout reads it to render the correct structure on the first paint @@ -61,12 +70,14 @@ export const useSidebarStore = create()( const nextCollapsed = !isCollapsed set({ isCollapsed: nextCollapsed }) applyCollapsedCookie(nextCollapsed) - applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth)) + applySidebarWidth( + nextCollapsed ? getCollapsedSidebarWidth() : clampSidebarWidth(sidebarWidth) + ) }, syncWidth: () => { const { isCollapsed, sidebarWidth } = get() if (isCollapsed) { - applySidebarWidth(SIDEBAR_WIDTH.COLLAPSED) + applySidebarWidth(getCollapsedSidebarWidth()) return } const clampedWidth = clampSidebarWidth(sidebarWidth) @@ -89,7 +100,7 @@ export const useSidebarStore = create()( if (state) { state.setHasHydrated(true) const width = state.isCollapsed - ? SIDEBAR_WIDTH.COLLAPSED + ? getCollapsedSidebarWidth() : clampSidebarWidth(state.sidebarWidth) applySidebarWidth(width) } diff --git a/apps/sim/stores/tool-permission/store.ts b/apps/sim/stores/tool-permission/store.ts new file mode 100644 index 00000000000..9ad81774af2 --- /dev/null +++ b/apps/sim/stores/tool-permission/store.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** + * Tracks which tool calls are currently sitting on a permission prompt. + * + * The cards themselves are rendered independently down the message tree, so + * they need somewhere shared to see each other: that is what makes an + * "Allow all" affordance possible when a turn gates several tools at once, and + * what stops every card from rendering its own copy of that affordance. + * + * Decisions are recorded here too, so a card stays settled during the round + * trip rather than flickering back to actionable before the stream catches up. + */ +interface ToolPermissionState { + /** Awaiting tool call ids, in the order their cards mounted. */ + awaitingIds: string[] + /** Ids with an answer already sent, cleared when the card unmounts. */ + submittedIds: string[] + register: (toolCallId: string) => void + unregister: (toolCallId: string) => void + markSubmitted: (toolCallIds: string[]) => void + clearSubmitted: (toolCallIds: string[]) => void + reset: () => void +} + +const initialState = { + awaitingIds: [] as string[], + submittedIds: [] as string[], +} + +export const useToolPermissionStore = create()( + devtools( + (set) => ({ + ...initialState, + register: (toolCallId) => + set((state) => + state.awaitingIds.includes(toolCallId) + ? {} + : { awaitingIds: [...state.awaitingIds, toolCallId] } + ), + unregister: (toolCallId) => + set((state) => ({ + awaitingIds: state.awaitingIds.filter((id) => id !== toolCallId), + submittedIds: state.submittedIds.filter((id) => id !== toolCallId), + })), + markSubmitted: (toolCallIds) => + set((state) => { + const next = toolCallIds.filter((id) => !state.submittedIds.includes(id)) + return next.length === 0 ? {} : { submittedIds: [...state.submittedIds, ...next] } + }), + clearSubmitted: (toolCallIds) => + set((state) => ({ + submittedIds: state.submittedIds.filter((id) => !toolCallIds.includes(id)), + })), + reset: () => set({ ...initialState }), + }), + { name: 'tool-permission-store' } + ) +) diff --git a/apps/sim/types/sim-desktop.d.ts b/apps/sim/types/sim-desktop.d.ts new file mode 100644 index 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/biome.json b/biome.json index 271e701c8b4..9249402d969 100644 --- a/biome.json +++ b/biome.json @@ -27,10 +27,13 @@ "!**/public/worker-*.js", "!**/public/fallback-*.js", "!**/apps/docs/.source", + "!**/apps/desktop/release", "!**/venv", "!**/.venv", "!**/uploads", - "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs" + "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", + "!**/test-results", + "!**/playwright-report" ] }, "formatter": { diff --git a/bun.lock b/bun.lock index 79db5a4d446..3322e254811 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,36 @@ "@next/swc-linux-x64-gnu": "16.2.11", }, }, + "apps/desktop": { + "name": "@sim/desktop", + "version": "0.0.0", + "dependencies": { + "@lydell/node-pty": "1.2.0-beta.12", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", + "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", + "@sim/utils": "workspace:*", + "@xterm/headless": "6.0.0", + "electron-updater": "6.8.9", + "micromatch": "4.0.8", + "safe-regex2": "5.1.0", + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@sim/tsconfig": "workspace:*", + "@types/micromatch": "4.0.10", + "@types/node": "24.2.1", + "electron": "43.1.1", + "electron-builder": "26.15.3", + "esbuild": "0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.0", + }, + }, "apps/docs": { "name": "docs", "version": "0.0.0", @@ -180,12 +210,15 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/browser-protocol": "workspace:*", + "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", + "@sim/terminal-protocol": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", @@ -206,6 +239,11 @@ "@tiptap/suggestion": "3.26.1", "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "ajv": "8.18.0", "archiver": "8.0.0", "better-auth": "1.6.23", @@ -361,6 +399,15 @@ "typescript": "^7.0.2", }, }, + "packages/browser-protocol": { + "name": "@sim/browser-protocol", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + }, + }, "packages/cli": { "name": "simstudio", "version": "0.1.19", @@ -394,6 +441,18 @@ "typescript": "^7.0.2", }, }, + "packages/desktop-bridge": { + "name": "@sim/desktop-bridge", + "version": "0.1.0", + "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/terminal-protocol": "workspace:*", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^7.0.2", + }, + }, "packages/emcn": { "name": "@sim/emcn", "version": "0.1.0", @@ -511,6 +570,9 @@ "packages/security": { "name": "@sim/security", "version": "0.1.0", + "dependencies": { + "ipaddr.js": "2.3.0", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", @@ -518,6 +580,15 @@ "vitest": "^4.1.0", }, }, + "packages/terminal-protocol": { + "name": "@sim/terminal-protocol", + "version": "0.1.0", + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + }, + }, "packages/testing": { "name": "@sim/testing", "version": "0.1.0", @@ -1004,6 +1075,24 @@ "@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], + + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + + "@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="], + + "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="], + + "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -1014,57 +1103,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -1186,6 +1275,24 @@ "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], + + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="], + + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="], + + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="], + + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="], + + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="], + + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="], + + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], @@ -1370,10 +1477,20 @@ "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@posthog/core": ["@posthog/core@1.24.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg=="], "@posthog/types": ["@posthog/types@1.364.4", "", {}, "sha512-U7NpIy9XWrzz1q/66xyDu8Wm12a7avNRKRn5ISPT5kuCJQRaeAaHuf+dpgrFnuqjCCgxg+oIY/ReJdlZ+8/z4Q=="], @@ -1618,8 +1735,14 @@ "@sim/auth": ["@sim/auth@workspace:packages/auth"], + "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/db": ["@sim/db@workspace:packages/db"], + "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], + + "@sim/desktop-bridge": ["@sim/desktop-bridge@workspace:packages/desktop-bridge"], + "@sim/emcn": ["@sim/emcn@workspace:packages/emcn"], "@sim/logger": ["@sim/logger@workspace:packages/logger"], @@ -1636,6 +1759,8 @@ "@sim/security": ["@sim/security@workspace:packages/security"], + "@sim/terminal-protocol": ["@sim/terminal-protocol@workspace:packages/terminal-protocol"], + "@sim/testing": ["@sim/testing@workspace:packages/testing"], "@sim/tsconfig": ["@sim/tsconfig@workspace:packages/tsconfig"], @@ -1648,6 +1773,8 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="], "@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], @@ -1742,6 +1869,8 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.4", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-zVOiYO0+CF7EnBScz8s0O5JnJLPTU0lrUi8qhKXfIxIJXvI/jcppSiXXsEJwfB4A6XZawY/Wg/EQGKANi/aPmQ=="], "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.4", "", { "dependencies": { "@t3-oss/env-core": "0.13.4" }, "peerDependencies": { "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-6ecXR7SH7zJKVcBODIkB7wV9QLMU23uV8D9ec6P+ULHJ5Ea/YXEHo+Z/2hSYip5i9ptD/qZh8VuOXyldspvTTg=="], @@ -1894,6 +2023,8 @@ "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1974,18 +2105,24 @@ "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/html-to-text": ["@types/html-to-text@9.0.4", "", {}, "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -2014,6 +2151,8 @@ "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], @@ -2132,8 +2271,22 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], + + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -2158,7 +2311,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -2166,6 +2319,8 @@ "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], + "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], @@ -2184,6 +2339,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2192,16 +2349,22 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], "axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="], @@ -2250,6 +2413,8 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -2274,12 +2439,22 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], + + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -2314,6 +2489,10 @@ "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -2330,6 +2509,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], @@ -2350,6 +2531,8 @@ "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], @@ -2392,6 +2575,8 @@ "cronstrue": ["cronstrue@3.3.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], @@ -2520,6 +2705,12 @@ "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], @@ -2536,6 +2727,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], @@ -2548,8 +2741,12 @@ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], "docs": ["docs@workspace:apps/docs"], @@ -2574,6 +2771,8 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], @@ -2582,6 +2781,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], "e2b": ["e2b@2.36.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@connectrpc/connect": "^2.1.2", "@connectrpc/connect-web": "^2.1.2", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.19", "undici": "^7.28.0" }, "optionalDependencies": { "undici8": "npm:undici@8.8.0" } }, "sha512-ERDTKfIM14mQX0cizRpfLCWDX4A83rGvbAbnUISDExM26WuMS4Jd9v2EWrY03LXfEKBst954SzFvKIZZEiKwng=="], @@ -2596,8 +2797,22 @@ "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + + "electron": ["electron@43.1.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-I5c5vfuVvaXpWx3IZdwvXgxQW44+e7OP1wXGVQkogLeSFSkUZ6sLCcWV05AdEcs65AO5tAIJJwbp7ixw+LdarA=="], + + "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="], + + "electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.373", "", {}, "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA=="], + "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], + + "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], @@ -2624,6 +2839,8 @@ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -2636,11 +2853,13 @@ "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -2692,6 +2911,8 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -2744,6 +2965,8 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -2772,6 +2995,10 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fumadocs-core": ["fumadocs-core@16.8.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-4MRqh/KWtR5Q5+LJd2SFv3nLDHtuZw3q8rwApd9nAWkunHVU30U17fUVq6nY+IDoLs7bSLnvDGvoE+Ynelrn3A=="], @@ -2816,6 +3043,10 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], @@ -2824,6 +3055,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphql": ["graphql@15.10.2", "", {}, "sha512-1PRqdDPAmViWr4h1GVBT8RoPZfWSGZa7kDzleTilOfVIslsgf+cia3Nl95v1KDmR4iERPaT7WzQ+tN4MJmbg3w=="], @@ -2838,6 +3071,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -2900,10 +3135,14 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2936,6 +3175,8 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -2968,7 +3209,7 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -2994,6 +3235,8 @@ "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isolated-vm": ["isolated-vm@6.0.2", "", { "dependencies": { "prebuild-install": "^7.1.3" } }, "sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg=="], @@ -3010,6 +3253,8 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -3028,6 +3273,8 @@ "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -3038,8 +3285,12 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], @@ -3052,6 +3303,8 @@ "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -3062,6 +3315,8 @@ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -3118,10 +3373,14 @@ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], @@ -3144,6 +3403,8 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], @@ -3170,6 +3431,8 @@ "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -3322,6 +3585,8 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -3378,6 +3643,8 @@ "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-ensure": ["node-ensure@0.0.0", "", {}, "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="], @@ -3386,16 +3653,24 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], "nodemailer": ["nodemailer@9.0.1", "", {}, "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="], "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], @@ -3414,6 +3689,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -3448,6 +3725,8 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], @@ -3496,6 +3775,8 @@ "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], @@ -3528,8 +3809,16 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -3554,6 +3843,8 @@ "posthog-node": ["posthog-node@5.28.9", "", { "dependencies": { "@posthog/core": "1.24.4" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-iZWyAYkIAq5QqcYz4q2nXOX+Ivn04Yh8AuKqfFVw0SvBpfli49bNAjyE97qbRTLr+irrzRUELgGIkDC14NgugA=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], @@ -3564,14 +3855,20 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], @@ -3616,6 +3913,10 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3626,6 +3927,8 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -3660,6 +3963,8 @@ "reactflow": ["reactflow@11.11.4", "", { "dependencies": { "@reactflow/background": "11.3.14", "@reactflow/controls": "11.2.14", "@reactflow/core": "11.11.4", "@reactflow/minimap": "11.7.14", "@reactflow/node-resizer": "2.2.14", "@reactflow/node-toolbar": "1.3.14" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -3728,14 +4033,22 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], @@ -3746,6 +4059,8 @@ "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], @@ -3772,12 +4087,16 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], + "satori": ["satori@0.12.2", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-wasm-web": "^0.3.3" } }, "sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA=="], "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], @@ -3798,10 +4117,14 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], @@ -3838,6 +4161,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "simstudio": ["simstudio@workspace:packages/cli"], "simstudio-ts-sdk": ["simstudio-ts-sdk@workspace:packages/ts-sdk"], @@ -3886,6 +4211,8 @@ "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -3906,7 +4233,7 @@ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -3952,6 +4279,8 @@ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -3986,6 +4315,10 @@ "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + + "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -3998,10 +4331,14 @@ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -4014,6 +4351,10 @@ "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + + "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -4028,6 +4369,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], @@ -4104,10 +4447,14 @@ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -4116,6 +4463,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], @@ -4154,6 +4503,8 @@ "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], @@ -4324,6 +4675,24 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@electron/fuses/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/rebuild/node-abi": ["node-abi@4.33.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA=="], + + "@electron/universal/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], @@ -4332,6 +4701,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4538,6 +4909,10 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/browser-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@sim/terminal-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4608,8 +4983,14 @@ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@types/cacheable-request/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + + "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4620,6 +5001,8 @@ "@types/request/form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="], + "@types/responselike/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4632,6 +5015,22 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -4646,6 +5045,8 @@ "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -4656,14 +5057,18 @@ "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -4690,6 +5095,12 @@ "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "e2b/@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], @@ -4698,6 +5109,18 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "engine.io/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4728,8 +5151,6 @@ "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], - "fumadocs-mdx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], @@ -4826,6 +5247,12 @@ "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], @@ -4842,6 +5269,12 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -4854,6 +5287,8 @@ "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "pptxgenjs/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], @@ -4868,8 +5303,6 @@ "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "react-email/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -4886,12 +5319,20 @@ "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4902,9 +5343,11 @@ "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4924,12 +5367,14 @@ "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tsconfck/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "twilio/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "twilio/xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], @@ -4938,11 +5383,15 @@ "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "unzipper/bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + + "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4950,8 +5399,6 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -5070,6 +5517,8 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -5172,8 +5621,14 @@ "@types/archiver/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5184,26 +5639,32 @@ "@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@types/responselike/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -5214,63 +5675,73 @@ "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=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "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/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "fumadocs-mdx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "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-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "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-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "fumadocs-mdx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "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-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "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-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "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-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "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-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "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-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "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/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "fumadocs-mdx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "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-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "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-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "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-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "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-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "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/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "fumadocs-mdx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "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-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "fumadocs-mdx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "fumadocs-mdx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "fumadocs-mdx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "fumadocs-mdx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "fumadocs-mdx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -5294,6 +5765,8 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -5350,6 +5823,8 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -5370,58 +5845,6 @@ "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "react-email/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "react-email/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -5432,80 +5855,22 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], - - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "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/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "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=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], @@ -5548,6 +5913,12 @@ "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "giget/nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], @@ -5562,8 +5933,6 @@ "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], diff --git a/package.json b/package.json index 29b3d72db2f..3367e07051f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "check:bare-icons": "bun run scripts/check-bare-icons.ts", "check:icon-paths": "bun run scripts/check-icon-paths.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", + "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", + "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", + "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "billing-protocol-contract:generate": "bun run scripts/sync-billing-protocol-contract.ts", diff --git a/packages/browser-protocol/package.json b/packages/browser-protocol/package.json new file mode 100644 index 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..7367aa00ba5 --- /dev/null +++ b/packages/browser-protocol/src/index.ts @@ -0,0 +1,248 @@ +/** + * Shared types for the Sim browser agent — the agent browser built into the + * Sim desktop app. + * + * The Sim web app (renderer) invokes browser tools through the desktop + * preload bridge (`window.simDesktop.browserAgent`); the Electron main + * process executes them against a dedicated, persistent-profile browser view + * that is embedded INSIDE the main Sim window, positioned exactly over the + * chat's browser panel. The panel is therefore natively interactive — the + * user clicks and types into the real page, no frame streaming or synthetic + * input. Both sides consume this package so tool names, parameter shapes, + * and result shapes cannot drift. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/browser` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +export const BROWSER_TOOL_NAMES = [ + 'browser_navigate', + 'browser_open_url', + 'browser_go_back', + 'browser_go_forward', + 'browser_open_tab', + 'browser_switch_tab', + 'browser_close_tab', + 'browser_list_tabs', + 'browser_list_sessions', + 'browser_wait_for', + 'browser_snapshot', + 'browser_read_text', + 'browser_screenshot', + 'browser_extract', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_option', + 'browser_hover', + 'browser_request_takeover', +] as const + +export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] + +/** Hard cap shared by the desktop browser session and its renderer chrome. */ +export const MAX_BROWSER_TABS = 8 + +export const BROWSER_THEMES = ['system', 'light', 'dark'] as const + +/** Sim appearance preference mirrored into browser-tab media queries. */ +export type BrowserTheme = (typeof BROWSER_THEMES)[number] + +/** How a native browser shortcut should focus Sim's renderer-owned omnibox. */ +export type BrowserOmniboxFocusMode = 'select' | 'clear' + +const BROWSER_TOOL_NAME_SET: ReadonlySet = new Set(BROWSER_TOOL_NAMES) +const BROWSER_THEME_SET: ReadonlySet = new Set(BROWSER_THEMES) + +export function isBrowserToolName(name: string): name is BrowserToolName { + return BROWSER_TOOL_NAME_SET.has(name) +} + +export function isBrowserTheme(value: unknown): value is BrowserTheme { + return typeof value === 'string' && BROWSER_THEME_SET.has(value) +} + +/** The result of one browser tool invocation, as returned over the bridge. */ +export interface BrowserToolResponse { + ok: boolean + result?: unknown + error?: string +} + +/** + * Where the browser panel currently sits inside the Sim window, in CSS + * pixels relative to the page viewport. The main process positions the + * embedded browser view over this rect; null means the panel is not visible + * and the view should be hidden. + */ +export interface BrowserPanelBounds { + x: number + y: number + width: number + height: number +} + +/** + * How the panel's rect derives from the window's viewport, so the shell can + * re-evaluate it during a live window resize instead of holding a measured + * rect that is one frame stale. + * + * The renderer declares the rule; the shell only evaluates it. That direction + * matters: the shell once *assumed* a rule (right-anchored at constant width) + * and was wrong by half the window's travel whenever the panel was fractional. + * + * `widthRatio` is the only thing the shell cannot work out for itself, so it is + * the only rule carried here. Everything else — the width residual, the right + * inset, the top and bottom insets — the shell derives from the rect reported + * alongside this, measured at exactly the viewport below. + */ +export interface BrowserPanelAnchor { + /** Viewport size (CSS px) the companion rect was measured at. */ + viewportWidth: number + viewportHeight: number + /** + * How much the panel's width changes per pixel of viewport width: 0.5 while a + * half-width class governs it, 0 once a divider drag pins a fixed width. + * + * A rate, deliberately, not a share of the viewport — the panel is half of a + * parent box that excludes the sidebar, so its width is not 0.5 * viewport. + * The rate is what holds regardless, because that sidebar is a constant across + * a window resize, and the residual the shell derives absorbs the difference. + */ + widthRatio: number +} + +/** Last captured frame used while renderer overlays occlude the native view. */ +export interface BrowserPanelSnapshot { + dataUrl: string + tabId: string +} + +/** + * Browser-chrome commands from the panel header (URL bar, back/forward, + * reload) plus `takeover-done`, sent by the Done chip on the chat's + * `browser_request_takeover` tool row when the user finishes a + * hand-control-back request. Page interactions need no protocol — the user + * acts on the real embedded page directly, and its right-click menu is native + * and lives entirely in the shell. + */ +export interface BrowserPanelAction { + action: + | 'navigate' + | 'reload' + | 'back' + | 'forward' + | 'new-tab' + | 'duplicate-tab' + | 'switch-tab' + | 'close-tab' + | 'takeover-done' + /** Absolute URL for `navigate` (typed into the panel's URL bar). */ + url?: string + /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + tabId?: string +} + +/** Live state of the active page, pushed to the panel header. */ +export interface BrowserPageState { + /** Present on desktop versions with multi-tab UI support. */ + tabId?: string + url: string + title: string + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +/** + * One find-in-page request against the active tab. Backed by Chromium's own + * find, so behaviour matches Chrome exactly — this only carries the query and + * which way to step. + */ +export interface BrowserFindRequest { + query: string + /** + * False starts a fresh search and highlights every match; true steps to the + * next/previous match of the search already running. Typing re-searches; + * Enter steps. + */ + findNext: boolean + /** Direction for a `findNext` step. Ignored when starting a fresh search. */ + forward: boolean +} + +/** + * Match counts for the running find, pushed as Chromium resolves them. The + * counts are asynchronous and arrive in several updates per request, so the + * renderer must not expect one reply per {@link BrowserFindRequest}. + */ +export interface BrowserFindResult { + /** 1-based index of the highlighted match, or 0 before one is chosen. */ + activeMatchOrdinal: number + /** Total matches on the page; 0 means the query is not present. */ + matches: number + /** + * Whether the find has settled. Chromium streams provisional counts while a + * long page is still being scanned; only a final update is worth showing as + * a definitive "no results". + */ + final: boolean +} + +/** Summary of one live page in the desktop agent browser. */ +export interface BrowserTabState { + tabId: string + url: string + title: string + loading: boolean + active: boolean + /** Pinned tabs are ordered before regular tabs and cannot be closed. */ + pinned?: boolean +} + +/** Complete live tab list pushed by the desktop shell. */ +export interface BrowserTabsState { + tabs: BrowserTabState[] + activeTabId: string | null +} + +/** + * Why the desktop shell believes a website may have an authenticated session. + * Neither signal is proof: the live page must always be checked before acting. + */ +export type BrowserSessionEvidence = 'sign-in-completed' | 'cookies' + +/** + * Privacy-preserving summary of one possible authenticated website. Cookie + * names, values, paths, account identifiers, and page history never cross the + * desktop bridge. + */ +export interface BrowserKnownSession { + hostname: string + evidence: BrowserSessionEvidence + lastObservedAt: string +} + +export interface BrowserKnownSessionsState { + sessions: BrowserKnownSession[] +} + +export const BROWSER_DATA_KINDS = ['cookies', 'site-data', 'cache'] as const + +/** + * A kind of browsing data the user can clear independently. + * + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear and offering the option would be a lie. + * Saved passwords are absent too — they are a separate, explicit action. + */ +export type BrowserDataKind = (typeof BROWSER_DATA_KINDS)[number] + +const BROWSER_DATA_KIND_SET: ReadonlySet = new Set(BROWSER_DATA_KINDS) + +export function isBrowserDataKind(value: unknown): value is BrowserDataKind { + return typeof value === 'string' && BROWSER_DATA_KIND_SET.has(value) +} diff --git a/packages/browser-protocol/tsconfig.json b/packages/browser-protocol/tsconfig.json new file mode 100644 index 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/db/migrations/0273_copilot_tool_permission_decision.sql b/packages/db/migrations/0273_copilot_tool_permission_decision.sql new file mode 100644 index 00000000000..5fdc3ebab04 --- /dev/null +++ b/packages/db/migrations/0273_copilot_tool_permission_decision.sql @@ -0,0 +1,4 @@ +CREATE TYPE "public"."copilot_tool_permission_decision" AS ENUM('allow', 'allow_chat', 'always_allow', 'skip');--> statement-breakpoint +ALTER TABLE "copilot_async_tool_calls" ADD COLUMN "permission_decision" "copilot_tool_permission_decision";--> statement-breakpoint +ALTER TABLE "copilot_async_tool_calls" ADD COLUMN "permission_decided_at" timestamp;--> statement-breakpoint +ALTER TABLE "copilot_chats" ADD COLUMN "auto_allowed_tools" jsonb DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0273_snapshot.json b/packages/db/migrations/meta/0273_snapshot.json new file mode 100644 index 00000000000..d52dff07434 --- /dev/null +++ b/packages/db/migrations/meta/0273_snapshot.json @@ -0,0 +1,18236 @@ +{ + "id": "8e11cbd9-0014-4685-b6eb-4aa80bc847d4", + "prevId": "b1418eb5-f9bb-4788-9414-f7269a514acd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 02a53a0df14..f36d0c740c1 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1905,6 +1905,13 @@ "when": 1785267616623, "tag": "0272_generic_folders_and_pinning", "breakpoints": true + }, + { + "idx": 273, + "version": "7", + "when": 1785281897201, + "tag": "0273_copilot_tool_permission_decision", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d5f083cd009..614157916f6 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2582,9 +2582,18 @@ export const copilotChats = pgTable( model: text('model').notNull().default('claude-3-7-sonnet-latest'), conversationId: text('conversation_id'), previewYaml: text('preview_yaml'), + /** + * @deprecated Nothing reads or writes this any more — the plan artifact + * moved into the message transcript. Kept only so the column survives the + * deploy that removes its last readers; drop it in a follow-up migration + * once that deploy has fully rolled out (expand/contract). + */ planArtifact: text('plan_artifact'), config: jsonb('config'), resources: jsonb('resources').notNull().default('[]'), + // Copilot tool ids the user allowed for the rest of this chat only, as + // opposed to the account-wide list on `settings.copilotAutoAllowedTools`. + autoAllowedTools: jsonb('auto_allowed_tools').notNull().default('[]'), lastSeenAt: timestamp('last_seen_at'), pinned: boolean('pinned').notNull().default(false), deletedAt: timestamp('deleted_at'), @@ -2745,8 +2754,17 @@ export const copilotAsyncToolStatusEnum = pgEnum('copilot_async_tool_status', [ 'delivered', ]) +export const copilotToolPermissionDecisionEnum = pgEnum('copilot_tool_permission_decision', [ + 'allow', + 'allow_chat', + 'always_allow', + 'skip', +]) + export type CopilotRunStatus = (typeof copilotRunStatusEnum.enumValues)[number] export type CopilotAsyncToolStatus = (typeof copilotAsyncToolStatusEnum.enumValues)[number] +export type CopilotToolPermissionDecision = + (typeof copilotToolPermissionDecisionEnum.enumValues)[number] export const copilotRuns = pgTable( 'copilot_runs', @@ -2838,6 +2856,11 @@ export const copilotAsyncToolCalls = pgTable( status: copilotAsyncToolStatusEnum('status').notNull().default('pending'), result: jsonb('result'), error: text('error'), + // Set only for tools declaring requiresApproval in the mothership tool + // catalog. A null decision on such a tool means the prompt is still + // outstanding, which is what lets it survive a reload. + permissionDecision: copilotToolPermissionDecisionEnum('permission_decision'), + permissionDecidedAt: timestamp('permission_decided_at'), claimedAt: timestamp('claimed_at'), claimedBy: text('claimed_by'), completedAt: timestamp('completed_at'), diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts new file mode 100644 index 00000000000..80e1bd143b5 --- /dev/null +++ b/packages/desktop-bridge/contract-snapshot.ts @@ -0,0 +1,1365 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Frozen snapshot of the desktop preload bridge type surface + * (@sim/browser-protocol + @sim/terminal-protocol inlined into + * @sim/desktop-bridge) as of the last accepted contract change. + * CI type-checks that a shell built from this + * snapshot still satisfies the current SimDesktopApi, so bridge changes + * stay backward compatible with already-installed shells. + * + * Regenerate with: bun run desktop-bridge-contract:update + * Full rules: scripts/check-desktop-bridge-contract.ts + * + * min-desktop-version: 0.0.0 + */ +/** + * Shared types for the Sim browser agent — the agent browser built into the + * Sim desktop app. + * + * The Sim web app (renderer) invokes browser tools through the desktop + * preload bridge (`window.simDesktop.browserAgent`); the Electron main + * process executes them against a dedicated, persistent-profile browser view + * that is embedded INSIDE the main Sim window, positioned exactly over the + * chat's browser panel. The panel is therefore natively interactive — the + * user clicks and types into the real page, no frame streaming or synthetic + * input. Both sides consume this package so tool names, parameter shapes, + * and result shapes cannot drift. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/browser` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +export const BROWSER_TOOL_NAMES = [ + 'browser_navigate', + 'browser_open_url', + 'browser_go_back', + 'browser_go_forward', + 'browser_open_tab', + 'browser_switch_tab', + 'browser_close_tab', + 'browser_list_tabs', + 'browser_list_sessions', + 'browser_wait_for', + 'browser_snapshot', + 'browser_read_text', + 'browser_screenshot', + 'browser_extract', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_option', + 'browser_hover', + 'browser_request_takeover', +] as const + +export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] + +/** Hard cap shared by the desktop browser session and its renderer chrome. */ +export const MAX_BROWSER_TABS = 8 + +export const BROWSER_THEMES = ['system', 'light', 'dark'] as const + +/** Sim appearance preference mirrored into browser-tab media queries. */ +export type BrowserTheme = (typeof BROWSER_THEMES)[number] + +/** How a native browser shortcut should focus Sim's renderer-owned omnibox. */ +export type BrowserOmniboxFocusMode = 'select' | 'clear' + +const BROWSER_TOOL_NAME_SET: ReadonlySet = new Set(BROWSER_TOOL_NAMES) +const BROWSER_THEME_SET: ReadonlySet = new Set(BROWSER_THEMES) + +export function isBrowserToolName(name: string): name is BrowserToolName { + return BROWSER_TOOL_NAME_SET.has(name) +} + +export function isBrowserTheme(value: unknown): value is BrowserTheme { + return typeof value === 'string' && BROWSER_THEME_SET.has(value) +} + +/** The result of one browser tool invocation, as returned over the bridge. */ +export interface BrowserToolResponse { + ok: boolean + result?: unknown + error?: string +} + +/** + * Where the browser panel currently sits inside the Sim window, in CSS + * pixels relative to the page viewport. The main process positions the + * embedded browser view over this rect; null means the panel is not visible + * and the view should be hidden. + */ +export interface BrowserPanelBounds { + x: number + y: number + width: number + height: number +} + +/** + * How the panel's rect derives from the window's viewport, so the shell can + * re-evaluate it during a live window resize instead of holding a measured + * rect that is one frame stale. + * + * The renderer declares the rule; the shell only evaluates it. That direction + * matters: the shell once *assumed* a rule (right-anchored at constant width) + * and was wrong by half the window's travel whenever the panel was fractional. + * + * `widthRatio` is the only thing the shell cannot work out for itself, so it is + * the only rule carried here. Everything else — the width residual, the right + * inset, the top and bottom insets — the shell derives from the rect reported + * alongside this, measured at exactly the viewport below. + */ +export interface BrowserPanelAnchor { + /** Viewport size (CSS px) the companion rect was measured at. */ + viewportWidth: number + viewportHeight: number + /** + * How much the panel's width changes per pixel of viewport width: 0.5 while a + * half-width class governs it, 0 once a divider drag pins a fixed width. + * + * A rate, deliberately, not a share of the viewport — the panel is half of a + * parent box that excludes the sidebar, so its width is not 0.5 * viewport. + * The rate is what holds regardless, because that sidebar is a constant across + * a window resize, and the residual the shell derives absorbs the difference. + */ + widthRatio: number +} + +/** Last captured frame used while renderer overlays occlude the native view. */ +export interface BrowserPanelSnapshot { + dataUrl: string + tabId: string +} + +/** + * Browser-chrome commands from the panel header (URL bar, back/forward, + * reload) plus `takeover-done`, sent by the Done chip on the chat's + * `browser_request_takeover` tool row when the user finishes a + * hand-control-back request. Page interactions need no protocol — the user + * acts on the real embedded page directly, and its right-click menu is native + * and lives entirely in the shell. + */ +export interface BrowserPanelAction { + action: + | 'navigate' + | 'reload' + | 'back' + | 'forward' + | 'new-tab' + | 'duplicate-tab' + | 'switch-tab' + | 'close-tab' + | 'takeover-done' + /** Absolute URL for `navigate` (typed into the panel's URL bar). */ + url?: string + /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + tabId?: string +} + +/** Live state of the active page, pushed to the panel header. */ +export interface BrowserPageState { + /** Present on desktop versions with multi-tab UI support. */ + tabId?: string + url: string + title: string + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +/** + * One find-in-page request against the active tab. Backed by Chromium's own + * find, so behaviour matches Chrome exactly — this only carries the query and + * which way to step. + */ +export interface BrowserFindRequest { + query: string + /** + * False starts a fresh search and highlights every match; true steps to the + * next/previous match of the search already running. Typing re-searches; + * Enter steps. + */ + findNext: boolean + /** Direction for a `findNext` step. Ignored when starting a fresh search. */ + forward: boolean +} + +/** + * Match counts for the running find, pushed as Chromium resolves them. The + * counts are asynchronous and arrive in several updates per request, so the + * renderer must not expect one reply per {@link BrowserFindRequest}. + */ +export interface BrowserFindResult { + /** 1-based index of the highlighted match, or 0 before one is chosen. */ + activeMatchOrdinal: number + /** Total matches on the page; 0 means the query is not present. */ + matches: number + /** + * Whether the find has settled. Chromium streams provisional counts while a + * long page is still being scanned; only a final update is worth showing as + * a definitive "no results". + */ + final: boolean +} + +/** Summary of one live page in the desktop agent browser. */ +export interface BrowserTabState { + tabId: string + url: string + title: string + loading: boolean + active: boolean + /** Pinned tabs are ordered before regular tabs and cannot be closed. */ + pinned?: boolean +} + +/** Complete live tab list pushed by the desktop shell. */ +export interface BrowserTabsState { + tabs: BrowserTabState[] + activeTabId: string | null +} + +/** + * Why the desktop shell believes a website may have an authenticated session. + * Neither signal is proof: the live page must always be checked before acting. + */ +export type BrowserSessionEvidence = 'sign-in-completed' | 'cookies' + +/** + * Privacy-preserving summary of one possible authenticated website. Cookie + * names, values, paths, account identifiers, and page history never cross the + * desktop bridge. + */ +export interface BrowserKnownSession { + hostname: string + evidence: BrowserSessionEvidence + lastObservedAt: string +} + +export interface BrowserKnownSessionsState { + sessions: BrowserKnownSession[] +} + +export const BROWSER_DATA_KINDS = ['cookies', 'site-data', 'cache'] as const + +/** + * A kind of browsing data the user can clear independently. + * + * Download history is deliberately absent: the built-in browser cancels every + * download, so there is none to clear and offering the option would be a lie. + * Saved passwords are absent too — they are a separate, explicit action. + */ +export type BrowserDataKind = (typeof BROWSER_DATA_KINDS)[number] + +const BROWSER_DATA_KIND_SET: ReadonlySet = new Set(BROWSER_DATA_KINDS) + +export function isBrowserDataKind(value: unknown): value is BrowserDataKind { + return typeof value === 'string' && BROWSER_DATA_KIND_SET.has(value) +} + +/** + * Shared types for the Sim agent terminal — the interactive shells built into + * the Sim desktop app. + * + * The Sim web app (renderer) drives real PTYs through the desktop preload + * bridge (`window.simDesktop.terminal`); the Electron main process owns the + * `node-pty` processes and streams their bytes back for xterm.js to render. + * The user and the agent share the same shells, so `cd`, exported variables, + * and scrollback are common to both. + * + * Several terminals can be open at once, each its own shell with its own + * working directory and scrollback, exactly like tabs in a terminal app. One is + * active at a time; agent tools act on the active one unless they name another. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/terminal` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +/** The single tool the model calls; what it does is in `operation`. */ +export const TERMINAL_TOOL_NAME = 'terminal' + +/** + * Names this surface used to expose, one tool per operation. Kept so rows in + * conversations recorded before the consolidation still render with a real + * title instead of a humanized tool name. + */ +export const LEGACY_TERMINAL_TOOL_NAMES = [ + 'terminal_run', + 'terminal_input', + 'terminal_read', + 'terminal_kill', + 'terminal_cwd', + 'terminal_list', + 'terminal_new', + 'terminal_switch', + 'terminal_close', +] as const + +export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] + +/** + * What one `terminal` call does. + * + * The first group acts on a shell — or, when that shell has tmux attached, on + * a pane inside it. The second group manages Sim's own tabs. `panes` is the + * one tmux-only operation: tmux owns its windows and splits, so the agent + * inspects them rather than Sim mirroring them into the tab strip. `handoff` + * gives the terminal to the user and waits. + */ +export const TERMINAL_OPERATIONS = [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', +] as const + +export type TerminalOperation = (typeof TERMINAL_OPERATIONS)[number] + +const TERMINAL_OPERATION_SET: ReadonlySet = new Set(TERMINAL_OPERATIONS) + +export function isTerminalOperation(value: unknown): value is TerminalOperation { + return typeof value === 'string' && TERMINAL_OPERATION_SET.has(value) +} + +export function isTerminalToolName(name: string): boolean { + return name === TERMINAL_TOOL_NAME +} + +/** + * Ceiling on concurrently open terminals. Each is a live shell process with its + * own emulator and scrollback, so the cap bounds both memory and the number of + * things the user has to keep track of. + */ +export const MAX_TERMINALS = 8 + +/** + * Largest command output handed back to the model, in characters. Output past + * this is middle-elided (head and tail kept) because the interesting parts of + * a long build log are the command echo and the failure at the end. + */ +export const MAX_TOOL_OUTPUT_CHARS = 30_000 + +/** Scrollback the main process retains per terminal for reads and repaints. */ +export const MAX_SCROLLBACK_CHARS = 256_000 + +/** + * Ceiling on the raw bytes buffered while capturing one command's output. A + * full-screen program repaints continuously and can emit megabytes a second, + * so capture keeps a capped head plus a rolling tail rather than growing until + * the command ends. + */ +export const MAX_CAPTURE_CHARS = 512_000 + +/** + * How long `terminal_run` waits for a command before handing control back. + * + * Deliberately short. A long blocking call would leave the user watching + * nothing and the agent unable to react, so anything still running comes back + * as `running` with the output so far; the agent then polls it with `wait` and + * `terminal_read`. Successive reads are also how it tells progress from a + * stall — output that stops changing is a command waiting on input or wedged. + */ +export const DEFAULT_RUN_WAIT_MS = 30_000 + +export const MAX_RUN_WAIT_MS = 120_000 + +/** + * How long output must be silent, with the cursor left mid-line, before the + * command is treated as sitting on a prompt and handed straight back. + * + * Waiting out the full window for something as obvious as `[y/n]` reads as a + * hang. A command that stops mid-line has written a prompt and is waiting for + * an answer; one that is merely working either keeps printing or has ended its + * last line properly, so neither trips this. + */ +export const PROMPT_IDLE_MS = 2_500 + +/** + * Ceiling on one batch of keystrokes. Long enough to cross a menu, short + * enough that a mistaken batch cannot run away with the program — every key + * after the first is sent without seeing what the last one did. + */ +export const MAX_INPUT_KEYS = 20 + +/** Control keys the agent may send to a running foreground process. */ +export const TERMINAL_CONTROL_KEYS = [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', +] as const + +export type TerminalControlKey = (typeof TERMINAL_CONTROL_KEYS)[number] + +const TERMINAL_CONTROL_KEY_SET: ReadonlySet = new Set(TERMINAL_CONTROL_KEYS) + +export function isTerminalControlKey(value: unknown): value is TerminalControlKey { + return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) +} + +export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' + +/** + * Arguments for every operation, flattened into one object. + * + * A flat bag rather than a discriminated union because it has to survive a + * round trip through a JSON tool schema, where the model supplies whichever + * fields its chosen operation needs. Each operation validates the ones it + * requires and ignores the rest. + */ +export interface TerminalToolArgs { + /** `run`: the command line to execute. */ + command?: string + /** `input`: literal text to type. */ + text?: string + /** `input`: a key to press instead of text. */ + key?: TerminalControlKey + /** + * `input`: several keys pressed in order, for stepping through a menu + * ("down", "down", "enter") without a round trip per keystroke. Each is a + * real keypress with a pause between, so the program redraws as it would + * under a person's hands. Capped at {@link MAX_INPUT_KEYS}. + */ + keys?: TerminalControlKey[] + /** `read`: trailing lines to return. */ + lines?: number + /** `kill`: which signal. Defaults to SIGINT. */ + signal?: TerminalSignal + /** `new`: directory to open in. Defaults to the active terminal's cwd. */ + cwd?: string + /** `run`: how long to wait before handing back a still-running command. */ + waitSeconds?: number + /** + * Which terminal to act on. Omitting it targets the active one, which is + * what the user is looking at and what a single-terminal conversation + * always means. + */ + terminalId?: string + /** + * Which tmux pane to act on, as a tmux target (`session:window.pane`), for + * a terminal that has tmux attached. Omitting it uses that session's active + * pane. Ignored when the terminal is a plain shell. + */ + pane?: string + /** `handoff`: what the user needs to do, shown on the chip they click. */ + reason?: string +} + +export interface TerminalToolCall { + operation: TerminalOperation + args?: TerminalToolArgs +} + +/** + * How a `terminal_run` ended. Only `completed` means the command is finished + * and the terminal is free; in every other case it is still running and still + * holds the foreground. + */ +export type TerminalRunStatus = + /** Exited on its own. `exitCode` is set. */ + | 'completed' + /** + * Still going when the wait window elapsed. Not an error and not a stall — + * poll it rather than re-running or giving up. + */ + | 'running' + /** + * Took over the screen (an editor, pager, or interactive CLI). Its output is + * redraws rather than text and it will not exit unaided. + */ + | 'interactive' + +export interface TerminalRunResult { + command: string + output: string + status: TerminalRunStatus + /** Null unless `status` is `completed`. */ + exitCode: number | null + durationMs: number + cwd: string | null + terminalId: string + /** Set when the command ran in tmux: the target it ran under. */ + pane?: string + /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ + truncated: boolean + /** + * Set when the command looks like it is blocked on a prompt: it printed + * something, stopped mid-line, and went quiet. Answer it with terminal_input + * rather than waiting — it will not proceed on its own. + */ + awaitingInput?: boolean +} + +export interface TerminalReadResult { + /** + * The screen as text. When a row is highlighted the way a menu marks its + * selection, it is prefixed `[selected] ` — a TUI that indicates the current + * row with colour alone is otherwise invisible in plain text, leaving the + * agent unable to tell where it is before it starts pressing keys. + */ + output: string + cwd: string | null + terminalId: string + /** Set when the read came from tmux: the pane it captured. */ + pane?: string + truncated: boolean + /** + * The command still holding the terminal, or null when the shell is back at + * a prompt. This is the definitive "is it done" signal for a poll loop — + * seeing expected text in the output is not, because a command can print its + * last line well before it exits. + */ + running: string | null +} + +export interface TerminalCwdResult { + cwd: string | null + shellName: string | null + home: string | null + terminalId: string +} + +/** One open terminal, as shown in the tab strip. */ +export interface TerminalTabState { + terminalId: string + /** Short label for the tab: the running command, else the cwd's basename. */ + title: string + cwd: string | null + /** Command holding the foreground, when one is running. */ + running: string | null + /** + * True while a full-screen program owns the terminal. Distinct from merely + * `running`: a build is transient work, an editor or coding agent is an open + * application that will sit there until it is quit. + */ + interactive: boolean + active: boolean + /** + * The tmux session attached in this terminal, when one is. Its windows and + * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a + * count of the shells Sim opened. + */ + tmuxSession?: string | null +} + +/** One tmux pane, as reported by the `panes` operation. */ +export interface TerminalPaneState { + /** tmux target (`session:window.pane`), usable as the `pane` argument. */ + target: string + windowName: string + /** The process tmux reports in the pane; a bare shell means it is idle. */ + command: string + cwd: string | null + active: boolean +} + +/** + * The outcome of handing a terminal to the user. + * + * Resolves when the command that was blocking finishes, so the agent picks up + * where it left off rather than having to guess whether the user is done. A + * command still going after the user says they have finished comes back with + * `running` set, which is the same poll-it signal a long `run` returns. + */ +export interface TerminalHandoffResult { + terminalId: string + reason: string + /** True when the user pressed the hand-back button rather than the command just ending. */ + handedBack: boolean + /** The command still holding the terminal, or null when it is back at a prompt. */ + running: string | null + /** The screen as it stands now. */ + output: string + cwd: string | null +} + +export interface TerminalPanesResult { + terminalId: string + session: string + panes: TerminalPaneState[] +} + +export interface TerminalTabsState { + tabs: TerminalTabState[] + activeTerminalId: string | null +} + +/** The result of one terminal tool invocation, as returned over the bridge. */ +export interface TerminalToolResponse { + ok: boolean + result?: unknown + error?: string + code?: TerminalErrorCode +} + +export type TerminalErrorCode = + | 'SESSION_CLOSED' + /** Another command already holds the foreground in that terminal. */ + | 'BUSY' + | 'TIMEOUT' + /** + * The shell never emitted integration markers, so command boundaries and + * exit codes are unknowable and `terminal_run` must refuse rather than guess. + */ + | 'NO_SHELL_INTEGRATION' + | 'SPAWN_FAILED' + /** No terminal with that id — the ids come from terminal_list. */ + | 'NO_SUCH_TERMINAL' + /** Already at {@link MAX_TERMINALS}. */ + | 'TOO_MANY_TERMINALS' + /** The operation needs tmux, and this terminal has no tmux attached. */ + | 'NO_TMUX' + /** No pane with that target — the targets come from the `panes` operation. */ + | 'NO_SUCH_PANE' + | 'INVALID_REQUEST' + +export interface TerminalStartOptions { + cols: number + rows: number +} + +/** One batch of PTY bytes, tagged with the terminal that produced it. */ +export interface TerminalOutputEvent { + terminalId: string + data: string +} + +/** + * Command lifecycle, used by the panel to attribute rows to the agent and to + * show a running indicator. Emitted for user-typed commands too (no + * `toolCallId`), so the agent's `terminal_read` and the user's view agree. + */ +export interface TerminalCommandEvent { + terminalId: string + phase: 'start' | 'end' + command: string + /** Set when the agent initiated this command rather than the user. */ + toolCallId?: string + exitCode?: number + durationMs?: number +} + +/** + * The agent-terminal surface of the preload bridge. Real PTYs run in the + * Electron main process; the renderer paints their bytes with xterm.js and + * forwards keystrokes back. Several terminals can be open at once, each its own + * shell, and the user and the agent share them — so working directory and + * environment stay consistent between the two. + */ +/** + * The agent-terminal surface of the preload bridge. + * + * Members added after this surface first shipped are `optional?`, matching the + * browser-agent surface beside it and the "feature-detect, never assume" rule + * in apps/desktop/README.md: one web app is served to shells of every age, and + * `MIN_DESKTOP_VERSION` is still `0.0.0` (no floor), so an older shell reaches + * this code. Declaring such a member required makes the type disagree with the + * renderer, which has to `?.` it anyway — and the contract audit cannot catch + * that, because it compares against a snapshot the same PR regenerates. + */ +export interface SimDesktopTerminalApi { + /** Open the first terminal, or adopt the ones already running. */ + start(options: TerminalStartOptions): Promise + /** + * Execute one terminal operation. Resolves with the outcome; never rejects + * for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise + /** Forward the user's keystrokes to one terminal's PTY. */ + write(terminalId: string, data: string): void + resize(terminalId: string, cols: number, rows: number): void + /** Open an additional terminal and make it active. */ + openTerminal(cwd?: string): Promise + switchTerminal(terminalId: string): Promise + closeTerminal(terminalId: string): Promise + getTabs?(): Promise + /** End every shell. A new one starts on the next `start`. */ + dispose(): void + /** Subscribe to PTY output batches. Returns an unsubscribe function. */ + onData?(callback: (terminalId: string, data: string) => void): () => void + /** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Pulled per view so the repaint cannot be aimed at the wrong set of + * subscribers, or at none at all. + */ + getScrollback(terminalId: string): Promise + /** + * Reports whether the terminal panel owns keyboard focus, so global menu + * accelerators can tell a Cmd-W meant for a terminal from one meant for the + * window. + */ + setFocused?(focused: boolean): void + /** + * The user finishing a handoff — the hand-back chip on the waiting tool row. + */ + finishHandoff?(terminalId: string): void + /** Subscribe to the open-terminal list and which one is active. */ + onTabs?(callback: (state: TerminalTabsState) => void): () => void + /** Subscribe to command start/end, used for agent attribution in the panel. */ + onCommand?(callback: (event: TerminalCommandEvent) => void): () => void +} + +/** + * The browser-agent surface of the preload bridge. Tools execute in the + * Electron main process against the desktop app's built-in agent browser — a + * persistent-profile browser view embedded in the main Sim window, positioned + * over the chat's browser panel so the user interacts with the real page. + */ +export interface SimDesktopBrowserAgentApi { + /** + * Execute one browser tool. Resolves with the tool's outcome; never + * rejects for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise + /** Browser-chrome commands from the panel (URL bar, back, reload, takeover Done). */ + panelAction(action: BrowserPanelAction): void + /** + * Pin or unpin a live browser tab. Optional for compatibility with desktop + * builds predating durable pinned tabs. + */ + setTabPinned?(tabId: string, pinned: boolean): void + /** + * Move a live tab to a final list index. Optional for compatibility with + * desktop builds predating tab reordering. + */ + reorderTab?(tabId: string, targetIndex: number): void + /** + * Report where the browser panel sits in the window (CSS pixels relative + * to the viewport), or null when the panel is hidden/unmounted. The main + * process keeps the embedded view glued to this rect. + * + * `anchor` declares how that rect derives from the viewport so the shell can + * re-evaluate it mid-resize rather than hold a stale rect; omit it and the + * shell falls back to the measured rect alone. Shells predating it ignore the + * argument. + */ + setPanelBounds(bounds: BrowserPanelBounds | null, anchor?: BrowserPanelAnchor | null): void + /** + * Report whether renderer-owned browser chrome currently owns the user's + * interaction context. Optional for compatibility with older desktop builds. + */ + setPanelFocused?(focused: boolean): void + /** + * Hide or reveal the native browser surface without detaching it. Optional + * so newer web deployments remain compatible with older desktop builds. + */ + setPanelOccluded?(occluded: boolean): void + /** + * Mirror Sim's light/dark/system preference into the embedded pages. + * Optional for compatibility with desktop builds predating theme sync. + */ + setTheme?(theme: BrowserTheme): void + /** + * Focus requests emitted by native tabs for browser-level keyboard + * shortcuts such as Mod+L and Mod+T. + */ + onFocusOmnibox?(callback: (mode: BrowserOmniboxFocusMode) => void): () => void + /** + * Run Chromium's find-in-page against the active tab. Results do not come + * back from this call — they stream through {@link onFindResult}. Optional + * for compatibility with desktop builds predating find-in-page. + */ + find?(request: BrowserFindRequest): void + /** + * Stop the running find and clear its highlights. `focusPage` hands keyboard + * focus back to the page, for the user dismissing the bar; omit it when the + * bar is going away because the panel is. + */ + stopFind?(focusPage?: boolean): void + /** + * Mod+F pressed while the embedded page had focus, which the renderer never + * sees as a key event. Opening the find bar is the renderer's job either + * way, so both entry paths land on the same handler. + */ + onOpenFind?(callback: () => void): () => void + /** + * The shell dismissing the find bar — the active tab navigated away from the + * document the find was run against, or the user switched tabs. + */ + onCloseFind?(callback: () => void): () => void + /** Match counts for the running find, as Chromium resolves them. */ + onFindResult?(callback: (result: BrowserFindResult) => void): () => void + /** + * Subscribe to captured browser frames used beneath renderer overlays. + * Optional for compatibility with desktop builds predating occlusion. + */ + onPanelSnapshot?(callback: (snapshot: BrowserPanelSnapshot) => void): () => void + /** Subscribe to live page state for the panel header. Returns an unsubscribe function. */ + onPageState(callback: (state: BrowserPageState) => void): () => void + /** + * Read the current live tab list. Optional so a newer web deployment remains + * compatible with installed desktop versions that only support one visible tab. + */ + getTabsState?(): Promise + /** + * Read a privacy-preserving hint of websites that may have a usable session + * in the dedicated profile. Optional for compatibility with older shells. + */ + getKnownSessions?(): Promise + /** + * Erase browsing data from the dedicated profile and resolve the resulting + * session list. Pass the kinds to clear; omit for all of them. Saved + * passwords are never included — deleting those is a separate action. + * Optional for compatibility with older shells, which ignore the argument + * and clear everything. + */ + clearBrowsingData?(kinds?: readonly BrowserDataKind[]): Promise + /** + * Subscribe to live tab-list changes. Optional for compatibility with older + * installed desktop versions. + */ + onTabsState?(callback: (state: BrowserTabsState) => void): () => void + /** + * Subscribe to session liveness changes (false when the browser session + * ends). Returns an unsubscribe function. + */ + onSessionStatus(callback: (alive: boolean) => void): () => void +} + +/** + * One browser profile found on this device. + * + * `id` is an opaque handle used to name the same profile back to the shell; + * the shell resolves it against the profiles it discovered rather than + * building a path from it. Host paths never cross this bridge. + * + * `browserId` and `browserLabel` are optional because shells that only + * supported Chrome did not report them — treat their absence as Chrome. + */ +export interface BrowserImportProfile { + id: string + /** Browser and profile together, e.g. `Arc · Microtrades`. */ + label: string + /** Stable browser identifier, e.g. `chrome`, `arc`, `brave`. */ + browserId?: string + /** The browser's product name, e.g. `Arc`. */ + browserLabel?: string + /** The profile on its own, e.g. `Microtrades` or `Default`. */ + profileLabel?: string +} + +/** + * Why an import could not run, as a coarse category. Deliberately free of + * specifics: no host paths, profile paths, domains, or underlying OS errors. + */ +export type BrowserImportError = + | 'unsupported-platform' + | 'chrome-not-found' + | 'keychain-unavailable' + | 'profile-unreadable' + | 'unsupported-schema' + | 'nothing-imported' + | 'vault-unavailable' + | 'unknown' + +/** + * Outcome of a Chrome import: counts and a coarse error category only. Cookie + * names, values, domains, and full URLs never cross the bridge — they never + * leave the Electron main process at all. + */ +export interface BrowserImportResult { + cookiesImported: number + cookiesSkipped: number + /** Present only when the import could not complete. */ + error?: BrowserImportError +} + +/** + * Local, user-initiated import of Chrome data into the built-in browser's + * dedicated profile. macOS-only today, and optional in two senses: older + * shells lack the surface entirely, and shells on platforms without a + * supported importer omit it too — so always feature-detect before rendering. + * + * The agent cannot reach this. Both methods are gated in the main process to + * the Sim app origin, `importChromeCookies` additionally requires a live user + * gesture, and no browser tool maps to either channel. Reading Chrome is + * strictly read-only, and decrypted material stays in the main process. + */ +/** A site a previous import brought over, keyed by the hostname visited there. */ +export interface BrowserSiteInfo { + hostname: string + /** Learned from the source browser's own page titles, not a built-in list. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from — an aggregate for + * ordering suggestions, never a visit time, a URL, or a sequence. Absent on + * a record written before imports started counting. + */ + visits?: number + /** When Sim imported it; never the source browser's own last-visit time. */ + importedAt?: string +} + +export interface SimDesktopBrowserImportApi { + /** Chrome profiles detected on this device; empty when none are readable. */ + listChromeProfiles(): Promise + /** + * The sites previous imports brought over, so the omnibox has somewhere to + * start on a browser that keeps no history of its own — and can offer + * "Gmail" instead of `mail.google.com`. + * + * Optional — feature-detect before calling, so a newer web deployment keeps + * working against an installed shell that predates it. + */ + listSites?(): Promise + /** + * Copy one Chrome profile's cookies into the built-in browser, preserving + * each cookie's security attributes. Requires an active user gesture in the + * calling page. Resolves a count-only report; never rejects for import-level + * failures (those ride the `error` category). + */ + importChromeCookies(profileId?: string): Promise + /** + * Copy cookies and saved passwords in one action. + * + * A single call rather than two, because the macOS Keychain prompt can + * outlive the page's transient user activation and a second gated call would + * then be refused for a user who did nothing wrong. Each half reports its + * own outcome, so one failing does not hide the other. + * + * Optional: shells that predate saved passwords expose only + * {@link importChromeCookies}, so feature-detect before offering it. + */ + importFromChrome?( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise +} + +/** Both halves of a combined Chrome import, each with its own outcome. */ +export interface BrowserChromeImportResult { + cookies: BrowserImportResult + passwords: BrowserPasswordImportResult +} + +/** How an import should treat a credential that already exists for a site. */ +export type BrowserCredentialConflictPolicy = 'keep-existing' | 'replace' + +/** + * Outcome of a password import. Counts and a coarse category only, exactly + * like the cookie import — no origins, usernames, or passwords. + */ +export interface BrowserPasswordImportResult { + passwordsAdded: number + passwordsUpdated: number + passwordsSkipped: number + error?: BrowserImportError +} + +/** + * One saved credential as the management UI sees it. The password is + * deliberately absent, and there is no bridge method that can produce it: + * plaintext only ever travels from the vault to an authorized fill, inside the + * main process. + */ +export interface BrowserCredentialMetadata { + id: string + origin: string + username: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' + /** + * The site's icon as a `data:` URL, copied from the source browser's own + * favicon store during import. Absent when that browser had no icon for the + * site. Never fetched over the network — doing so would disclose the list of + * sites the user has passwords for. + */ + icon?: string +} + +/** + * Whether the active browser tab is showing a login form that Sim holds a + * credential for — just enough to decide whether to offer the fill affordance. + * + * Intentionally a bare boolean. The renderer learns nothing about which + * accounts exist, and the chooser itself is a native main-process surface, so + * no credential identifier crosses this bridge on the fill path at all. + */ +export interface BrowserFillAvailability { + available: boolean +} + +/** + * The saved-password surface for the built-in browser: an OS-encrypted local + * vault plus a user-driven fill. + * + * Optional so newer web deployments keep working against shells that lack it, + * and absent where secure storage is unavailable — there is no plaintext + * fallback. The agent has no path to any of it: management calls require the + * Sim app origin, filling additionally requires a real user gesture and is + * completed by a native menu the renderer cannot drive, and no browser tool + * maps to these channels. + */ +export interface SimDesktopBrowserCredentialsApi { + /** False when OS-backed encryption is unavailable and passwords are disabled. */ + isAvailable(): Promise + /** Saved credentials, without passwords. */ + list(): Promise + /** Forget one credential; resolves the remaining list. */ + forget(id: string): Promise + /** + * Delete every saved password. Requires an active user gesture; resolves the + * resulting (empty) list. Optional — feature-detect before offering it. + */ + forgetAll?(): Promise + /** + * Reveal one saved password so the user can read it. + * + * This is the only method on the entire bridge that can produce password + * plaintext, and it is heavily conditioned: it requires an active user + * gesture, the shell prompts for Touch ID (or a native confirmation where + * Touch ID is unavailable) on every call, and it returns exactly one + * password. Resolves null when the user declines or the credential is gone. + * + * Optional — shells that predate the password manager omit it. + */ + reveal?(id: string): Promise + /** + * Copy one saved password to the clipboard. Same authorization as + * {@link reveal}, but the password never enters the renderer: the shell + * writes the clipboard itself and clears it again shortly after. + */ + copy?(id: string): Promise + /** Copy saved passwords out of a Chrome profile into the vault. */ + importFromChrome( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise + /** + * Ask the shell to show its native credential chooser near a point in the + * window. Requires a user gesture. The shell performs the fill itself when + * the user picks an account — no password or credential id comes back here. + */ + showChooser(anchor: { x: number; y: number }): Promise + /** Subscribe to whether the active tab can be filled. */ + onFillAvailability(callback: (state: BrowserFillAvailability) => void): () => void +} + +export interface LocalFilesystemMount { + id: string + name: string + uri: string + /** True when the encrypted grant will be restored after restarting the desktop app. */ + remembered: boolean +} + +export type LocalFilesystemEntryKind = 'file' | 'directory' | 'symlink' | 'other' + +export interface LocalFilesystemEntry { + name: string + uri: string + kind: LocalFilesystemEntryKind + size?: number + modifiedAt?: string +} + +export interface LocalFilesystemStat { + name: string + uri: string + kind: LocalFilesystemEntryKind + size: number + modifiedAt: string +} + +export interface LocalFilesystemReadResult { + uri: string + content: string + startLine: number + endLine: number + totalLines: number +} + +export interface LocalFilesystemGrepMatch { + uri: string + line: number + text: string +} + +export type LocalFilesystemRequest = + | { operation: 'mount_directory' } + | { operation: 'list_mounts' } + | { operation: 'forget_mount'; uri: string } + | { operation: 'reveal_mount'; uri: string } + | { operation: 'list'; uri: string; requestId?: string } + | { + operation: 'glob' + uri: string + pattern: string + pathPrefix?: string + requestId?: string + } + | { + operation: 'read' + uri: string + startLine?: number + lineCount?: number + requestId?: string + } + | { + operation: 'grep' + uri: string + query?: string + pattern?: string + include?: string + caseSensitive?: boolean + maxResults?: number + outputMode?: 'content' | 'files_with_matches' | 'count' + lineNumbers?: boolean + context?: number + requestId?: string + } + | { operation: 'stat'; uri: string; requestId?: string } + | { operation: 'cancel'; requestId: string } + +export type LocalFilesystemData = + | { mount: LocalFilesystemMount | null; cancelled: boolean } + | { mounts: LocalFilesystemMount[] } + | { forgotten: boolean } + | { revealed: boolean } + | { entries: LocalFilesystemEntry[]; truncated: boolean } + | { matches: LocalFilesystemGrepMatch[]; truncated: boolean } + | { files: string[]; truncated: boolean } + | { counts: Array<{ uri: string; count: number }>; truncated: boolean } + | { cancelled: boolean } + | LocalFilesystemReadResult + | LocalFilesystemStat + +export type LocalFilesystemResponse = + | { ok: true; data: LocalFilesystemData } + | { + ok: false + code: + | 'INVALID_REQUEST' + | 'INVALID_URI' + | 'MOUNT_NOT_FOUND' + | 'NOT_FOUND' + | 'NOT_A_FILE' + | 'NOT_A_DIRECTORY' + | 'FILE_TOO_LARGE' + | 'BINARY_FILE' + | 'ACCESS_DENIED' + | 'CANCELLED' + | 'IO_ERROR' + error: string + } + +/** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ +export interface DesktopOAuthConnectResult { + ok: boolean + /** OAuth error slug forwarded from the provider callback, when the flow failed. */ + error?: string +} + +/** + * Optional scope for an OAuth connect handoff. Chip-initiated connects carry + * the workspace (the browser flow creates the workspace connect draft + * server-side) and, for reconnects, the credential to rebind. Modal-initiated + * connects omit both — the app already created the draft. + */ +export interface DesktopOAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface DesktopPreferences { + notificationsEnabled: boolean + notificationSounds: boolean + notificationsOnlyWhenUnfocused: boolean + launchAtLogin: boolean + autoDownloadUpdates: boolean + /** + * Show the Sim status item (recent chats menu) in the macOS menu bar. + * Optional because shells predating the preference don't report it. + */ + trayEnabled?: boolean + /** + * Let Chat drive the built-in agent browser on this device. Optional + * because shells predating the preference don't report it; absent means the + * surface is simply always on, which is how those shells behave. + */ + browserEnabled?: boolean + /** Let Chat run commands in local shells. Same compatibility caveat. */ + terminalEnabled?: boolean +} + +/** + * The keys settable through {@link SimDesktopSettingsApi.setPreference}. A + * closed union frozen at the first shell release: widening it would demand a + * capability installed shells lack (their setPreference is typed over fewer + * keys), which the bridge contract audit rejects. Preferences added later get + * their own optional setter (e.g. {@link SimDesktopSettingsApi.setTrayEnabled}) + * so the web app can feature-detect them — and must be excluded here, or they + * widen this union right back. + */ +export type DesktopPreferenceKey = Exclude< + keyof DesktopPreferences, + 'trayEnabled' | 'browserEnabled' | 'terminalEnabled' +> + +export interface DesktopNotificationPayload { + title: string + body: string + /** Optional in-app route opened when the notification is clicked. */ + route?: string +} + +/** + * Device-level settings owned by the desktop shell. This surface is optional + * so a newer web deployment remains compatible with older installed shells. + */ +export interface SimDesktopSettingsApi { + getPreferences(): Promise + setPreference( + key: K, + value: DesktopPreferences[K] + ): Promise + notify(payload: DesktopNotificationPayload): Promise + /** + * Shows or hides the Sim menu-bar status item. Optional: only shells that + * support the tray preference expose it — feature-detect before rendering + * a toggle. + */ + setTrayEnabled?(enabled: boolean): Promise + /** + * Turns the agent browser on or off for this device; disabling it also ends + * the running session. Optional — feature-detect before rendering a toggle. + */ + setBrowserEnabled?(enabled: boolean): Promise + /** + * Turns the agent terminal on or off for this device; disabling it also + * ends every open shell. Optional, like {@link setBrowserEnabled}. + */ + setTerminalEnabled?(enabled: boolean): Promise +} + +/** + * Where the shell's update pipeline currently is. `available` only occurs + * when automatic downloads are disabled; with them enabled the shell moves + * straight to `downloading`. + */ +export type DesktopUpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' + +export interface DesktopUpdateState { + status: DesktopUpdateStatus + /** Version of the update being offered/downloaded/ready, when known. */ + version?: string + /** Whole-number download progress (0-100) while `downloading`. */ + percent?: number + /** + * True when this shell cannot apply updates in place (a build without a + * Developer ID signature — local installs and pre-signing CI prereleases; + * Squirrel.Mac refuses to swap unsigned bundles). `available` is then the + * pipeline's terminal state and the advance action opens the download in + * the browser instead of downloading in the background. + */ + manual?: boolean +} + +/** + * The shell updater surface. Optional so a newer web deployment remains + * compatible with older installed shells. + */ +export interface SimDesktopUpdatesApi { + getState(): Promise + /** + * Advance the pipeline: checks for an update, or starts the download when + * one is already known to be available (auto-download off). + */ + check(): void + /** Quit and install a `ready` update. No-op in any other state. */ + install(): void + /** Subscribe to pipeline state changes. Returns an unsubscribe function. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +export type DesktopCommand = 'toggle-sidebar' + +export interface DesktopWindowState { + isFullScreen: boolean +} + +export interface SimDesktopWindowStateApi { + getState(): Promise + onStateChange(callback: (state: DesktopWindowState) => void): () => void +} + +export interface SimDesktopApi { + /** + * Installed shell version (plain semver, e.g. `0.3.1`). Optional because + * shells predating version reporting don't set it — the web app's minimum + * shell version gate treats an absent version as older than any floor. + */ + version?: string + openExternal(url: string): Promise + /** + * Start the OAuth connect handoff for a provider: the whole flow runs in + * the system browser and returns via loopback. Resolves false when the + * browser could not be opened. + */ + beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** + * Subscribe to connect-handoff completions (the app is refocused just + * before this fires). Returns an unsubscribe function. + */ + onOAuthConnectComplete(callback: (result: DesktopOAuthConnectResult) => void): () => void + offlineRetry(): void + localFilesystem(request: LocalFilesystemRequest): Promise + /** Subscribe to commands initiated by the native application menu. */ + onCommand?(callback: (command: DesktopCommand) => void): () => void + windowState?: SimDesktopWindowStateApi + settings?: SimDesktopSettingsApi + updates?: SimDesktopUpdatesApi + browserAgent?: SimDesktopBrowserAgentApi + /** + * Local Chrome import for the built-in browser. Absent on shells predating + * it and on platforms without a supported importer. + */ + browserImport?: SimDesktopBrowserImportApi + /** + * Saved passwords and user-driven fill for the built-in browser. Absent on + * older shells and wherever OS-backed encryption is unavailable. + */ + browserCredentials?: SimDesktopBrowserCredentialsApi + /** + * Optional so a newer web deployment stays compatible with installed shells + * that predate the agent terminal. + */ + terminal?: SimDesktopTerminalApi +} diff --git a/packages/desktop-bridge/package.json b/packages/desktop-bridge/package.json new file mode 100644 index 00000000000..cbff5f8d599 --- /dev/null +++ b/packages/desktop-bridge/package.json @@ -0,0 +1,37 @@ +{ + "name": "@sim/desktop-bridge", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./local-filesystem-limits": { + "types": "./src/local-filesystem-limits.ts", + "default": "./src/local-filesystem-limits.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@sim/browser-protocol": "workspace:*", + "@sim/terminal-protocol": "workspace:*" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^7.0.2" + } +} diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts new file mode 100644 index 00000000000..dd2e3cecaa6 --- /dev/null +++ b/packages/desktop-bridge/src/index.ts @@ -0,0 +1,726 @@ +import type { + BrowserDataKind, + BrowserFindRequest, + BrowserFindResult, + BrowserKnownSessionsState, + BrowserOmniboxFocusMode, + BrowserPageState, + BrowserPanelAction, + BrowserPanelAnchor, + BrowserPanelBounds, + BrowserPanelSnapshot, + BrowserTabsState, + BrowserTheme, + BrowserToolName, + BrowserToolResponse, +} from '@sim/browser-protocol' +import type { + TerminalCommandEvent, + TerminalOperation, + TerminalStartOptions, + TerminalTabsState, + TerminalToolArgs, + TerminalToolResponse, +} from '@sim/terminal-protocol' + +/** + * The agent-terminal surface of the preload bridge. Real PTYs run in the + * Electron main process; the renderer paints their bytes with xterm.js and + * forwards keystrokes back. Several terminals can be open at once, each its own + * shell, and the user and the agent share them — so working directory and + * environment stay consistent between the two. + * + * Members added after this surface first shipped are `optional?`, matching the + * browser-agent surface beside it and the "feature-detect, never assume" rule + * in apps/desktop/README.md: one web app is served to shells of every age, and + * `MIN_DESKTOP_VERSION` is still `0.0.0` (no floor), so an older shell reaches + * this code. Declaring such a member required makes the type disagree with the + * renderer, which has to `?.` it anyway — and the contract audit cannot catch + * that, because it compares against a snapshot the same PR regenerates. + */ +export interface SimDesktopTerminalApi { + /** Open the first terminal, or adopt the ones already running. */ + start(options: TerminalStartOptions): Promise + /** + * Execute one terminal operation. Resolves with the outcome; never rejects + * for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + operation: TerminalOperation, + args: TerminalToolArgs + ): Promise + /** Forward the user's keystrokes to one terminal's PTY. */ + write(terminalId: string, data: string): void + resize(terminalId: string, cols: number, rows: number): void + /** Open an additional terminal and make it active. */ + openTerminal(cwd?: string): Promise + switchTerminal(terminalId: string): Promise + closeTerminal(terminalId: string): Promise + getTabs?(): Promise + /** End every shell. A new one starts on the next `start`. */ + dispose(): void + /** Subscribe to PTY output batches. Returns an unsubscribe function. */ + onData?(callback: (terminalId: string, data: string) => void): () => void + /** + * Everything already on a terminal's screen, for a new view to paint itself + * from. Pulled per view so the repaint cannot be aimed at the wrong set of + * subscribers, or at none at all. + */ + getScrollback(terminalId: string): Promise + /** + * Reports whether the terminal panel owns keyboard focus, so global menu + * accelerators can tell a Cmd-W meant for a terminal from one meant for the + * window. + */ + setFocused?(focused: boolean): void + /** + * The user finishing a handoff — the hand-back chip on the waiting tool row. + */ + finishHandoff?(terminalId: string): void + /** Subscribe to the open-terminal list and which one is active. */ + onTabs?(callback: (state: TerminalTabsState) => void): () => void + /** Subscribe to command start/end, used for agent attribution in the panel. */ + onCommand?(callback: (event: TerminalCommandEvent) => void): () => void +} + +/** + * The browser-agent surface of the preload bridge. Tools execute in the + * Electron main process against the desktop app's built-in agent browser — a + * persistent-profile browser view embedded in the main Sim window, positioned + * over the chat's browser panel so the user interacts with the real page. + */ +export interface SimDesktopBrowserAgentApi { + /** + * Execute one browser tool. Resolves with the tool's outcome; never + * rejects for tool-level failures (those ride `ok: false`). + */ + executeTool( + toolCallId: string, + tool: BrowserToolName, + params: Record + ): Promise + /** Browser-chrome commands from the panel (URL bar, back, reload, takeover Done). */ + panelAction(action: BrowserPanelAction): void + /** + * Pin or unpin a live browser tab. Optional for compatibility with desktop + * builds predating durable pinned tabs. + */ + setTabPinned?(tabId: string, pinned: boolean): void + /** + * Move a live tab to a final list index. Optional for compatibility with + * desktop builds predating tab reordering. + */ + reorderTab?(tabId: string, targetIndex: number): void + /** + * Report where the browser panel sits in the window (CSS pixels relative + * to the viewport), or null when the panel is hidden/unmounted. The main + * process keeps the embedded view glued to this rect. + * + * `anchor` declares how that rect derives from the viewport so the shell can + * re-evaluate it mid-resize rather than hold a stale rect; omit it and the + * shell falls back to the measured rect alone. Shells predating it ignore the + * argument. + */ + setPanelBounds(bounds: BrowserPanelBounds | null, anchor?: BrowserPanelAnchor | null): void + /** + * Report whether renderer-owned browser chrome currently owns the user's + * interaction context. Optional for compatibility with older desktop builds. + */ + setPanelFocused?(focused: boolean): void + /** + * Hide or reveal the native browser surface without detaching it. Optional + * so newer web deployments remain compatible with older desktop builds. + */ + setPanelOccluded?(occluded: boolean): void + /** + * Mirror Sim's light/dark/system preference into the embedded pages. + * Optional for compatibility with desktop builds predating theme sync. + */ + setTheme?(theme: BrowserTheme): void + /** + * Focus requests emitted by native tabs for browser-level keyboard + * shortcuts such as Mod+L and Mod+T. + */ + onFocusOmnibox?(callback: (mode: BrowserOmniboxFocusMode) => void): () => void + /** + * Run Chromium's find-in-page against the active tab. Results do not come + * back from this call — they stream through {@link onFindResult}. Optional + * for compatibility with desktop builds predating find-in-page. + */ + find?(request: BrowserFindRequest): void + /** + * Stop the running find and clear its highlights. `focusPage` hands keyboard + * focus back to the page, for the user dismissing the bar; omit it when the + * bar is going away because the panel is. + */ + stopFind?(focusPage?: boolean): void + /** + * Mod+F pressed while the embedded page had focus, which the renderer never + * sees as a key event. Opening the find bar is the renderer's job either + * way, so both entry paths land on the same handler. + */ + onOpenFind?(callback: () => void): () => void + /** + * The shell dismissing the find bar — the active tab navigated away from the + * document the find was run against, or the user switched tabs. + */ + onCloseFind?(callback: () => void): () => void + /** Match counts for the running find, as Chromium resolves them. */ + onFindResult?(callback: (result: BrowserFindResult) => void): () => void + /** + * Subscribe to captured browser frames used beneath renderer overlays. + * Optional for compatibility with desktop builds predating occlusion. + */ + onPanelSnapshot?(callback: (snapshot: BrowserPanelSnapshot) => void): () => void + /** Subscribe to live page state for the panel header. Returns an unsubscribe function. */ + onPageState(callback: (state: BrowserPageState) => void): () => void + /** + * Read the current live tab list. Optional so a newer web deployment remains + * compatible with installed desktop versions that only support one visible tab. + */ + getTabsState?(): Promise + /** + * Read a privacy-preserving hint of websites that may have a usable session + * in the dedicated profile. Optional for compatibility with older shells. + */ + getKnownSessions?(): Promise + /** + * Erase browsing data from the dedicated profile and resolve the resulting + * session list. Pass the kinds to clear; omit for all of them. Saved + * passwords are never included — deleting those is a separate action. + * Optional for compatibility with older shells, which ignore the argument + * and clear everything. + */ + clearBrowsingData?(kinds?: readonly BrowserDataKind[]): Promise + /** + * Subscribe to live tab-list changes. Optional for compatibility with older + * installed desktop versions. + */ + onTabsState?(callback: (state: BrowserTabsState) => void): () => void + /** + * Subscribe to session liveness changes (false when the browser session + * ends). Returns an unsubscribe function. + */ + onSessionStatus(callback: (alive: boolean) => void): () => void +} + +/** + * One browser profile found on this device. + * + * `id` is an opaque handle used to name the same profile back to the shell; + * the shell resolves it against the profiles it discovered rather than + * building a path from it. Host paths never cross this bridge. + * + * `browserId` and `browserLabel` are optional because shells that only + * supported Chrome did not report them — treat their absence as Chrome. + */ +export interface BrowserImportProfile { + id: string + /** Browser and profile together, e.g. `Arc · Microtrades`. */ + label: string + /** Stable browser identifier, e.g. `chrome`, `arc`, `brave`. */ + browserId?: string + /** The browser's product name, e.g. `Arc`. */ + browserLabel?: string + /** The profile on its own, e.g. `Microtrades` or `Default`. */ + profileLabel?: string +} + +/** + * Why an import could not run, as a coarse category. Deliberately free of + * specifics: no host paths, profile paths, domains, or underlying OS errors. + */ +export type BrowserImportError = + | 'unsupported-platform' + | 'chrome-not-found' + | 'keychain-unavailable' + | 'profile-unreadable' + | 'unsupported-schema' + | 'nothing-imported' + | 'vault-unavailable' + | 'unknown' + +/** + * Outcome of a Chrome import: counts and a coarse error category only. Cookie + * names, values, domains, and full URLs never cross the bridge — they never + * leave the Electron main process at all. + */ +export interface BrowserImportResult { + cookiesImported: number + cookiesSkipped: number + /** Present only when the import could not complete. */ + error?: BrowserImportError +} + +/** + * Local, user-initiated import of Chrome data into the built-in browser's + * dedicated profile. macOS-only today, and optional in two senses: older + * shells lack the surface entirely, and shells on platforms without a + * supported importer omit it too — so always feature-detect before rendering. + * + * The agent cannot reach this. Both methods are gated in the main process to + * the Sim app origin, `importChromeCookies` additionally requires a live user + * gesture, and no browser tool maps to either channel. Reading Chrome is + * strictly read-only, and decrypted material stays in the main process. + */ +/** A site a previous import brought over, keyed by the hostname visited there. */ +export interface BrowserSiteInfo { + hostname: string + /** Learned from the source browser's own page titles, not a built-in list. */ + name?: string + /** The source browser's favicon, as a `data:` URL. */ + icon?: string + /** + * How much the site was used in the browser it came from — an aggregate for + * ordering suggestions, never a visit time, a URL, or a sequence. Absent on + * a record written before imports started counting. + */ + visits?: number + /** When Sim imported it; never the source browser's own last-visit time. */ + importedAt?: string +} + +export interface SimDesktopBrowserImportApi { + /** Chrome profiles detected on this device; empty when none are readable. */ + listChromeProfiles(): Promise + /** + * The sites previous imports brought over, so the omnibox has somewhere to + * start on a browser that keeps no history of its own — and can offer + * "Gmail" instead of `mail.google.com`. + * + * Optional — feature-detect before calling, so a newer web deployment keeps + * working against an installed shell that predates it. + */ + listSites?(): Promise + /** + * Copy one Chrome profile's cookies into the built-in browser, preserving + * each cookie's security attributes. Requires an active user gesture in the + * calling page. Resolves a count-only report; never rejects for import-level + * failures (those ride the `error` category). + */ + importChromeCookies(profileId?: string): Promise + /** + * Copy cookies and saved passwords in one action. + * + * A single call rather than two, because the macOS Keychain prompt can + * outlive the page's transient user activation and a second gated call would + * then be refused for a user who did nothing wrong. Each half reports its + * own outcome, so one failing does not hide the other. + * + * Optional: shells that predate saved passwords expose only + * {@link importChromeCookies}, so feature-detect before offering it. + */ + importFromChrome?( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise +} + +/** Both halves of a combined Chrome import, each with its own outcome. */ +export interface BrowserChromeImportResult { + cookies: BrowserImportResult + passwords: BrowserPasswordImportResult +} + +/** How an import should treat a credential that already exists for a site. */ +export type BrowserCredentialConflictPolicy = 'keep-existing' | 'replace' + +/** + * Outcome of a password import. Counts and a coarse category only, exactly + * like the cookie import — no origins, usernames, or passwords. + */ +export interface BrowserPasswordImportResult { + passwordsAdded: number + passwordsUpdated: number + passwordsSkipped: number + error?: BrowserImportError +} + +/** + * One saved credential as the management UI sees it. The password is + * deliberately absent, and there is no bridge method that can produce it: + * plaintext only ever travels from the vault to an authorized fill, inside the + * main process. + */ +export interface BrowserCredentialMetadata { + id: string + origin: string + username: string + createdAt: string + updatedAt: string + source: 'chrome' | 'manual' + /** + * The site's icon as a `data:` URL, copied from the source browser's own + * favicon store during import. Absent when that browser had no icon for the + * site. Never fetched over the network — doing so would disclose the list of + * sites the user has passwords for. + */ + icon?: string +} + +/** + * Whether the active browser tab is showing a login form that Sim holds a + * credential for — just enough to decide whether to offer the fill affordance. + * + * Intentionally a bare boolean. The renderer learns nothing about which + * accounts exist, and the chooser itself is a native main-process surface, so + * no credential identifier crosses this bridge on the fill path at all. + */ +export interface BrowserFillAvailability { + available: boolean +} + +/** + * The saved-password surface for the built-in browser: an OS-encrypted local + * vault plus a user-driven fill. + * + * Optional so newer web deployments keep working against shells that lack it, + * and absent where secure storage is unavailable — there is no plaintext + * fallback. The agent has no path to any of it: management calls require the + * Sim app origin, filling additionally requires a real user gesture and is + * completed by a native menu the renderer cannot drive, and no browser tool + * maps to these channels. + */ +export interface SimDesktopBrowserCredentialsApi { + /** False when OS-backed encryption is unavailable and passwords are disabled. */ + isAvailable(): Promise + /** Saved credentials, without passwords. */ + list(): Promise + /** Forget one credential; resolves the remaining list. */ + forget(id: string): Promise + /** + * Delete every saved password. Requires an active user gesture; resolves the + * resulting (empty) list. Optional — feature-detect before offering it. + */ + forgetAll?(): Promise + /** + * Reveal one saved password so the user can read it. + * + * This is the only method on the entire bridge that can produce password + * plaintext, and it is heavily conditioned: it requires an active user + * gesture, the shell prompts for Touch ID (or a native confirmation where + * Touch ID is unavailable) on every call, and it returns exactly one + * password. Resolves null when the user declines or the credential is gone. + * + * Optional — shells that predate the password manager omit it. + */ + reveal?(id: string): Promise + /** + * Copy one saved password to the clipboard. Same authorization as + * {@link reveal}, but the password never enters the renderer: the shell + * writes the clipboard itself and clears it again shortly after. + */ + copy?(id: string): Promise + /** Copy saved passwords out of a Chrome profile into the vault. */ + importFromChrome( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise + /** + * Ask the shell to show its native credential chooser near a point in the + * window. Requires a user gesture. The shell performs the fill itself when + * the user picks an account — no password or credential id comes back here. + */ + showChooser(anchor: { x: number; y: number }): Promise + /** Subscribe to whether the active tab can be filled. */ + onFillAvailability(callback: (state: BrowserFillAvailability) => void): () => void +} + +export interface LocalFilesystemMount { + id: string + name: string + uri: string + /** True when the encrypted grant will be restored after restarting the desktop app. */ + remembered: boolean +} + +export type LocalFilesystemEntryKind = 'file' | 'directory' | 'symlink' | 'other' + +export interface LocalFilesystemEntry { + name: string + uri: string + kind: LocalFilesystemEntryKind + size?: number + modifiedAt?: string +} + +export interface LocalFilesystemStat { + name: string + uri: string + kind: LocalFilesystemEntryKind + size: number + modifiedAt: string +} + +export interface LocalFilesystemReadResult { + uri: string + content: string + startLine: number + endLine: number + totalLines: number +} + +export interface LocalFilesystemGrepMatch { + uri: string + line: number + text: string +} + +export type LocalFilesystemRequest = + | { operation: 'mount_directory' } + | { operation: 'list_mounts' } + | { operation: 'forget_mount'; uri: string } + | { operation: 'reveal_mount'; uri: string } + | { operation: 'list'; uri: string; requestId?: string } + | { + operation: 'glob' + uri: string + pattern: string + pathPrefix?: string + requestId?: string + } + | { + operation: 'read' + uri: string + startLine?: number + lineCount?: number + requestId?: string + } + | { + operation: 'grep' + uri: string + query?: string + pattern?: string + include?: string + caseSensitive?: boolean + maxResults?: number + outputMode?: 'content' | 'files_with_matches' | 'count' + lineNumbers?: boolean + context?: number + requestId?: string + } + | { operation: 'stat'; uri: string; requestId?: string } + | { operation: 'cancel'; requestId: string } + +export type LocalFilesystemData = + | { mount: LocalFilesystemMount | null; cancelled: boolean } + | { mounts: LocalFilesystemMount[] } + | { forgotten: boolean } + | { revealed: boolean } + | { entries: LocalFilesystemEntry[]; truncated: boolean } + | { matches: LocalFilesystemGrepMatch[]; truncated: boolean } + | { files: string[]; truncated: boolean } + | { counts: Array<{ uri: string; count: number }>; truncated: boolean } + | { cancelled: boolean } + | LocalFilesystemReadResult + | LocalFilesystemStat + +export type LocalFilesystemResponse = + | { ok: true; data: LocalFilesystemData } + | { + ok: false + code: + | 'INVALID_REQUEST' + | 'INVALID_URI' + | 'MOUNT_NOT_FOUND' + | 'NOT_FOUND' + | 'NOT_A_FILE' + | 'NOT_A_DIRECTORY' + | 'FILE_TOO_LARGE' + | 'BINARY_FILE' + | 'ACCESS_DENIED' + | 'CANCELLED' + | 'IO_ERROR' + error: string + } + +/** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ +export interface DesktopOAuthConnectResult { + ok: boolean + /** OAuth error slug forwarded from the provider callback, when the flow failed. */ + error?: string +} + +/** + * Optional scope for an OAuth connect handoff. Chip-initiated connects carry + * the workspace (the browser flow creates the workspace connect draft + * server-side) and, for reconnects, the credential to rebind. Modal-initiated + * connects omit both — the app already created the draft. + */ +export interface DesktopOAuthConnectScope { + workspaceId?: string + credentialId?: string +} + +export interface DesktopPreferences { + notificationsEnabled: boolean + notificationSounds: boolean + notificationsOnlyWhenUnfocused: boolean + launchAtLogin: boolean + autoDownloadUpdates: boolean + /** + * Show the Sim status item (recent chats menu) in the macOS menu bar. + * Optional because shells predating the preference don't report it. + */ + trayEnabled?: boolean + /** + * Let Chat drive the built-in agent browser on this device. Optional + * because shells predating the preference don't report it; absent means the + * surface is simply always on, which is how those shells behave. + */ + browserEnabled?: boolean + /** Let Chat run commands in local shells. Same compatibility caveat. */ + terminalEnabled?: boolean +} + +/** + * The keys settable through {@link SimDesktopSettingsApi.setPreference}. A + * closed union frozen at the first shell release: widening it would demand a + * capability installed shells lack (their setPreference is typed over fewer + * keys), which the bridge contract audit rejects. Preferences added later get + * their own optional setter (e.g. {@link SimDesktopSettingsApi.setTrayEnabled}) + * so the web app can feature-detect them — and must be excluded here, or they + * widen this union right back. + */ +export type DesktopPreferenceKey = Exclude< + keyof DesktopPreferences, + 'trayEnabled' | 'browserEnabled' | 'terminalEnabled' +> + +export interface DesktopNotificationPayload { + title: string + body: string + /** Optional in-app route opened when the notification is clicked. */ + route?: string +} + +/** + * Device-level settings owned by the desktop shell. This surface is optional + * so a newer web deployment remains compatible with older installed shells. + */ +export interface SimDesktopSettingsApi { + getPreferences(): Promise + setPreference( + key: K, + value: DesktopPreferences[K] + ): Promise + notify(payload: DesktopNotificationPayload): Promise + /** + * Shows or hides the Sim menu-bar status item. Optional: only shells that + * support the tray preference expose it — feature-detect before rendering + * a toggle. + */ + setTrayEnabled?(enabled: boolean): Promise + /** + * Turns the agent browser on or off for this device; disabling it also ends + * the running session. Optional — feature-detect before rendering a toggle. + */ + setBrowserEnabled?(enabled: boolean): Promise + /** + * Turns the agent terminal on or off for this device; disabling it also + * ends every open shell. Optional, like {@link setBrowserEnabled}. + */ + setTerminalEnabled?(enabled: boolean): Promise +} + +/** + * Where the shell's update pipeline currently is. `available` only occurs + * when automatic downloads are disabled; with them enabled the shell moves + * straight to `downloading`. + */ +export type DesktopUpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' + +export interface DesktopUpdateState { + status: DesktopUpdateStatus + /** Version of the update being offered/downloaded/ready, when known. */ + version?: string + /** Whole-number download progress (0-100) while `downloading`. */ + percent?: number + /** + * True when this shell cannot apply updates in place (a build without a + * Developer ID signature — local installs and pre-signing CI prereleases; + * Squirrel.Mac refuses to swap unsigned bundles). `available` is then the + * pipeline's terminal state and the advance action opens the download in + * the browser instead of downloading in the background. + */ + manual?: boolean +} + +/** + * The shell updater surface. Optional so a newer web deployment remains + * compatible with older installed shells. + */ +export interface SimDesktopUpdatesApi { + getState(): Promise + /** + * Advance the pipeline: checks for an update, or starts the download when + * one is already known to be available (auto-download off). + */ + check(): void + /** Quit and install a `ready` update. No-op in any other state. */ + install(): void + /** Subscribe to pipeline state changes. Returns an unsubscribe function. */ + onState(callback: (state: DesktopUpdateState) => void): () => void +} + +export type DesktopCommand = 'toggle-sidebar' + +export interface DesktopWindowState { + isFullScreen: boolean +} + +export interface SimDesktopWindowStateApi { + getState(): Promise + onStateChange(callback: (state: DesktopWindowState) => void): () => void +} + +export interface SimDesktopApi { + /** + * Installed shell version (plain semver, e.g. `0.3.1`). Optional because + * shells predating version reporting don't set it — the web app's minimum + * shell version gate treats an absent version as older than any floor. + */ + version?: string + openExternal(url: string): Promise + /** + * Start the OAuth connect handoff for a provider: the whole flow runs in + * the system browser and returns via loopback. Resolves false when the + * browser could not be opened. + */ + beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** + * Subscribe to connect-handoff completions (the app is refocused just + * before this fires). Returns an unsubscribe function. + */ + onOAuthConnectComplete(callback: (result: DesktopOAuthConnectResult) => void): () => void + offlineRetry(): void + localFilesystem(request: LocalFilesystemRequest): Promise + /** Subscribe to commands initiated by the native application menu. */ + onCommand?(callback: (command: DesktopCommand) => void): () => void + windowState?: SimDesktopWindowStateApi + settings?: SimDesktopSettingsApi + updates?: SimDesktopUpdatesApi + browserAgent?: SimDesktopBrowserAgentApi + /** + * Local Chrome import for the built-in browser. Absent on shells predating + * it and on platforms without a supported importer. + */ + browserImport?: SimDesktopBrowserImportApi + /** + * Saved passwords and user-driven fill for the built-in browser. Absent on + * older shells and wherever OS-backed encryption is unavailable. + */ + browserCredentials?: SimDesktopBrowserCredentialsApi + /** + * Optional so a newer web deployment stays compatible with installed shells + * that predate the agent terminal. + */ + terminal?: SimDesktopTerminalApi +} diff --git a/packages/desktop-bridge/src/local-filesystem-limits.ts b/packages/desktop-bridge/src/local-filesystem-limits.ts new file mode 100644 index 00000000000..a6d7eefd528 --- /dev/null +++ b/packages/desktop-bridge/src/local-filesystem-limits.ts @@ -0,0 +1,33 @@ +/** + * Bounds and defaults for local-filesystem tool calls. + * + * These are a wire agreement, not a preference. The main process authorizes a + * request by RESOLVING the model's tool args itself and comparing the result + * to the request for exact equality — so if the renderer and the shell + * disagree about what an omitted `maxResults` means, a legitimate call is + * silently DENIED rather than failing loudly. Three copies of this table + * existed and two had already drifted on the read limit. + * + * Only the values the authorizer compares live here. Caps that each side + * applies to its own output (glob result limits, scan budgets) are genuinely + * independent and stay local — hoisting them would assert a coupling that does + * not exist. + * + * Changing a value here is a shell-compatibility change. Each side compiles + * its own copy, so a continuously-deployed web app carrying a new default + * still faces installed shells authorizing against the old one. Treat an edit + * the way you would a bridge signature change: additive, or floored by + * `MIN_DESKTOP_VERSION`. + */ + +/** Ceiling on an explicit `read` limit, and the value used when it is absent. */ +export const MAX_READ_LINES = 2_000 +export const DEFAULT_READ_LINES = MAX_READ_LINES + +/** Ceiling on an explicit `grep` maxResults, and the value used when absent. */ +export const MAX_GREP_RESULTS = 200 +export const DEFAULT_GREP_RESULTS = 50 + +/** Ceiling on explicit `grep` context lines, and the value used when absent. */ +export const MAX_GREP_CONTEXT = 20 +export const DEFAULT_GREP_CONTEXT = 0 diff --git a/packages/desktop-bridge/tsconfig.json b/packages/desktop-bridge/tsconfig.json new file mode 100644 index 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"] +} diff --git a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx index 080da46cc77..42d83aa5452 100644 --- a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx +++ b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx @@ -137,6 +137,7 @@ const ChipDatePicker = forwardRef( align={align} sideOffset={6} collisionPadding={8} + data-native-surface-overlay='' className={cn( POPOVER_ANIMATION_CLASSES, 'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm' diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index eaac04246ff..87cf8595163 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -110,6 +110,7 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[280px] rounded-lg', className)} {...props} + data-native-surface-overlay='' /> )) @@ -143,6 +144,7 @@ const DropdownMenuContent = React.forwardRef< sideOffset={sideOffset} className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[220px] rounded-xl', className)} {...props} + data-native-surface-overlay='' /> )) diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index b9b3d37165c..a0ad1930f38 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -167,6 +167,13 @@ export { SecretReveal } from './secret-reveal/secret-reveal' export { Skeleton } from './skeleton/skeleton' export { Slider } from './slider/slider' export { Switch } from './switch/switch' +export { + isTabTitleTruncated, + TabStrip, + type TabStripItem, + type TabStripProps, + tabDropIndex, +} from './tab-strip/tab-strip' export { Table, TableBody, diff --git a/packages/emcn/src/components/modal/modal.tsx b/packages/emcn/src/components/modal/modal.tsx index ae78693064f..4229c3779bb 100644 --- a/packages/emcn/src/components/modal/modal.tsx +++ b/packages/emcn/src/components/modal/modal.tsx @@ -137,6 +137,7 @@ const ModalOverlay = React.forwardRef< )} style={style} {...props} + data-native-surface-overlay='' /> ) }) diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx index 6efb2c52cbb..9d332d8ebc6 100644 --- a/packages/emcn/src/components/popover/popover.tsx +++ b/packages/emcn/src/components/popover/popover.tsx @@ -605,6 +605,7 @@ const PopoverContent = React.forwardRef< onOpenAutoFocus={handleOpenAutoFocus} onCloseAutoFocus={handleCloseAutoFocus} {...restProps} + data-native-surface-overlay='' className={cn( 'z-[var(--z-popover)] flex flex-col outline-none', showArrow ? 'overflow-visible' : 'overflow-auto', @@ -1015,6 +1016,7 @@ const PopoverFolder = React.forwardRef( typeof document !== 'undefined' && createPortal( { + it('shows title help only after a meaningful amount of text is clipped', () => { + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 140 })).toBe(true) + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 131 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 199 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 200 })).toBe(true) + expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 100 })).toBe(false) + expect(isTabTitleTruncated({ clientWidth: 120, scrollWidth: 80 })).toBe(false) + }) +}) + +describe('tabDropIndex', () => { + const tabs: TabStripItem[] = [ + { id: 'pinned-1', title: 'pinned-1', pinned: true }, + { id: 'pinned-2', title: 'pinned-2', pinned: true }, + { id: 'regular-1', title: 'regular-1' }, + { id: 'regular-2', title: 'regular-2' }, + ] + + it('calculates final indices from insertion gaps', () => { + expect(tabDropIndex(tabs, 'pinned-1', 2)).toBe(1) + expect(tabDropIndex(tabs, 'regular-1', 4)).toBe(3) + expect(tabDropIndex(tabs, 'regular-1', 3)).toBeNull() + }) + + it('keeps pinned and regular tabs inside their respective groups', () => { + expect(tabDropIndex(tabs, 'pinned-1', 4)).toBe(1) + expect(tabDropIndex(tabs, 'regular-2', 0)).toBe(2) + expect(tabDropIndex(tabs, 'missing', 0)).toBeNull() + }) + + it('treats a strip with no pinned tabs as one group', () => { + const plain: TabStripItem[] = [ + { id: 'a', title: 'a' }, + { id: 'b', title: 'b' }, + { id: 'c', title: 'c' }, + ] + expect(tabDropIndex(plain, 'a', 3)).toBe(2) + expect(tabDropIndex(plain, 'c', 0)).toBe(0) + expect(tabDropIndex(plain, 'b', 1)).toBeNull() + }) +}) + +describe('tab tooltips', () => { + /** Mirrors the render condition: tooltip text, and whether it is shown. */ + function tooltipFor(tab: TabStripItem, titleTruncated: boolean) { + const shown = Boolean(tab.tooltip || tab.pinned || titleTruncated) + return shown ? (tab.tooltip ?? tab.title) : null + } + + it('prefers the fuller detail a tab supplies over its label', () => { + // A terminal labelled with a basename can say where it actually is. + const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim — bun test' } + + expect(tooltipFor(tab, false)).toBe('/Users/me/code/sim — bun test') + }) + + it('shows that detail even when the label fits', () => { + // It says something the tab cannot, so there is always a reason to hover. + const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim' } + + expect(tooltipFor(tab, false)).not.toBeNull() + }) + + it('falls back to the title, and only when the title is clipped', () => { + const tab: TabStripItem = { id: '1', title: 'a very long tab title' } + + expect(tooltipFor(tab, true)).toBe('a very long tab title') + expect(tooltipFor(tab, false)).toBeNull() + }) + + it('still explains a pinned tab, which renders with no label at all', () => { + const tab: TabStripItem = { id: '1', title: 'GitHub', pinned: true } + + expect(tooltipFor(tab, false)).toBe('GitHub') + }) +}) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx new file mode 100644 index 00000000000..d2ff02ea8f1 --- /dev/null +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -0,0 +1,378 @@ +'use client' + +import { + type DragEvent as ReactDragEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { Plus, X } from '../../icons' +import { cn } from '../../lib/cn' +import { Button } from '../button/button' +import { Tooltip } from '../tooltip/tooltip' + +/** One tab in a {@link TabStrip}. */ +export interface TabStripItem { + id: string + title: string + /** + * Leading glyph. The caller owns what this is — a favicon, a spinner, a + * status icon — because only it knows what the tab represents. + */ + icon?: ReactNode + active?: boolean + /** + * Pinned tabs render icon-only and cannot be closed. Ordering them first is + * the caller's job, since only it knows the underlying list. + */ + pinned?: boolean + /** + * Fuller detail for the hover tooltip — a path the label abbreviates, the + * command a tab is running. Shown whenever present, not only when the label + * is clipped: it says something the tab cannot, so there is always a reason + * to hover. Without it the tooltip falls back to the title, and only appears + * when the title is actually cut off. + */ + tooltip?: string +} + +export interface TabStripProps { + tabs: TabStripItem[] + onSelect: (id: string) => void + /** Omit to make tabs uncloseable. Never offered for a pinned tab. */ + onClose?: (id: string) => void + /** Omit to hide the new-tab button. */ + onNew?: () => void + /** Enables drag reordering. Receives the tab's final index. */ + onReorder?: (id: string, targetIndex: number) => void + onTabContextMenu?: (event: ReactMouseEvent, id: string) => void + /** + * Called as a tab starts being dragged, to add whatever that tab means + * outside the strip to the drag. Supplying it also makes tabs draggable in a + * strip that cannot be reordered. + */ + onTabDragStart?: (event: ReactDragEvent, id: string) => void + /** Disables the new-tab button, with a tooltip explaining why. */ + maxTabs?: number + newTabLabel?: string + /** Rendered after the new-tab button, for menus and overlays. */ + children?: ReactNode +} + +/** + * Whether a title is clipped enough to be worth a tooltip. A couple of hidden + * pixels is not, and a tooltip on every tab is noise. + */ +export function isTabTitleTruncated( + element: Pick +): boolean { + const hiddenWidth = element.scrollWidth - element.clientWidth + const tooltipThreshold = Math.max(32, element.clientWidth * 0.25) + return hiddenWidth >= tooltipThreshold +} + +/** + * Resolves a drop gap to a final index, or null when the move is a no-op. + * + * Pinned tabs occupy a leading partition: a pinned tab cannot be dragged past + * the boundary and an unpinned one cannot be dragged before it, so dropping + * across it clamps rather than reorders. + */ +export function tabDropIndex( + tabs: TabStripItem[], + draggedId: string, + gapIndex: number +): number | null { + const fromIndex = tabs.findIndex((tab) => tab.id === draggedId) + if (fromIndex < 0 || !Number.isFinite(gapIndex)) return null + + const pinnedCount = tabs.filter((tab) => tab.pinned).length + const dragged = tabs[fromIndex] + const minGapIndex = dragged.pinned ? 0 : pinnedCount + const maxGapIndex = dragged.pinned ? pinnedCount : tabs.length + const boundedGapIndex = Math.max(minGapIndex, Math.min(maxGapIndex, Math.trunc(gapIndex))) + const targetIndex = boundedGapIndex > fromIndex ? boundedGapIndex - 1 : boundedGapIndex + return targetIndex === fromIndex ? null : targetIndex +} + +interface TabProps { + tab: TabStripItem + index: number + onSelect: (id: string) => void + onClose?: (id: string) => void + onContextMenu?: (event: ReactMouseEvent, id: string) => void + draggable: boolean + dragging: boolean + showDropBefore: boolean + showDropAfter: boolean + onDragStart: (event: ReactDragEvent, id: string) => void + onDragOver: (event: ReactDragEvent, index: number) => void + onDragLeave: (event: ReactDragEvent) => void + onDragEnd: () => void +} + +function Tab({ + tab, + index, + onSelect, + onClose, + onContextMenu, + draggable, + dragging, + showDropBefore, + showDropAfter, + onDragStart, + onDragOver, + onDragLeave, + onDragEnd, +}: TabProps) { + const titleRef = useRef(null) + const [titleTruncated, setTitleTruncated] = useState(false) + const closeable = Boolean(onClose) && !tab.pinned + + useLayoutEffect(() => { + const element = titleRef.current + if (!element) return + const update = () => setTitleTruncated(isTabTitleTruncated(element)) + update() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, [tab.title]) + + return ( +
onDragStart(event, tab.id)} + onDragOver={(event) => onDragOver(event, index)} + onDragLeave={onDragLeave} + onDragEnd={onDragEnd} + onContextMenu={(event) => onContextMenu?.(event, tab.id)} + > + {showDropBefore && ( +
+ )} + {showDropAfter && ( +
+ )} + + + + + {(tab.tooltip || tab.pinned || titleTruncated) && ( + {tab.tooltip || tab.title} + )} + + {closeable && ( + + )} +
+ ) +} + +/** + * Chrome-style tab strip, shared by every panel that hosts multiple live + * surfaces (the agent browser's pages, the agent terminal's shells). + * + * The strip owns interaction — selection, closing, drag reordering, the + * new-tab affordance, tooltips on clipped titles — and nothing about what a tab + * contains. Callers map their own state onto {@link TabStripItem} and supply + * the icon, which is why a favicon and a spinning shell indicator can share + * one component. + */ +export function TabStrip({ + tabs, + onSelect, + onClose, + onNew, + onReorder, + onTabContextMenu, + onTabDragStart, + maxTabs, + newTabLabel = 'New tab', + children, +}: TabStripProps) { + const atLimit = maxTabs !== undefined && tabs.length >= maxTabs + const draggedIdRef = useRef(null) + const dropTargetIndexRef = useRef(null) + const [draggedId, setDraggedId] = useState(null) + const [dropTargetIndex, setDropTargetIndex] = useState(null) + const reorderable = Boolean(onReorder) + + const resetDrag = useCallback(() => { + draggedIdRef.current = null + dropTargetIndexRef.current = null + setDraggedId(null) + setDropTargetIndex(null) + }, []) + + const handleDragStart = useCallback( + (event: ReactDragEvent, id: string) => { + if (!reorderable && !onTabDragStart) { + event.preventDefault() + return + } + if (reorderable) { + draggedIdRef.current = id + setDraggedId(id) + // `move` while the tab can also be dropped elsewhere would forbid the + // copy that dropping outside the strip is; the owner widens it below. + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', id) + } + // The strip knows about ordering and nothing else. Anything a tab means + // outside it — the page it holds, the shell it runs — belongs to whoever + // owns the tabs, so they attach it. + onTabDragStart?.(event, id) + }, + [reorderable, onTabDragStart] + ) + + const handleDragOver = useCallback( + (event: ReactDragEvent, index: number) => { + const id = draggedIdRef.current + if (!reorderable || !id) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + const rect = event.currentTarget.getBoundingClientRect() + const gapIndex = event.clientX < rect.left + rect.width / 2 ? index : index + 1 + const targetIndex = tabDropIndex(tabs, id, gapIndex) + dropTargetIndexRef.current = targetIndex + setDropTargetIndex(targetIndex) + }, + [reorderable, tabs] + ) + + const handleDrop = useCallback( + (event: ReactDragEvent) => { + event.preventDefault() + const id = draggedIdRef.current + const targetIndex = dropTargetIndexRef.current + if (id && targetIndex !== null) onReorder?.(id, targetIndex) + resetDrag() + }, + [onReorder, resetDrag] + ) + + const draggedIndex = tabs.findIndex((tab) => tab.id === draggedId) + + return ( +
+ {/* + The row is sized by its tabs rather than filling the strip, so the new-tab + button that follows sits beside the last tab instead of against the far + edge. Once the tabs no longer fit, the row shrinks (min-w-0 permits it) + and scrolls horizontally instead of growing, which pins the button back + at the right edge rather than pushing it out of view. + */} +
{ + if (draggedIdRef.current) event.preventDefault() + }} + onDrop={handleDrop} + > + {tabs.map((tab, index) => ( + = 0 && draggedIndex > index} + showDropAfter={dropTargetIndex === index && draggedIndex >= 0 && draggedIndex < index} + onSelect={onSelect} + {...(onClose ? { onClose } : {})} + {...(onTabContextMenu ? { onContextMenu: onTabContextMenu } : {})} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDragLeave={(event) => { + if ( + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return + } + dropTargetIndexRef.current = null + setDropTargetIndex(null) + }} + onDragEnd={resetDrag} + /> + ))} +
+ {onNew && ( + + + + + + {atLimit ? `Maximum of ${maxTabs} tabs` : newTabLabel} + + + )} + {children} +
+ ) +} diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index f5778e2e687..5f8cb5a8a86 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -590,6 +590,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { key='toast-stack' aria-live='polite' aria-label='Notifications' + data-native-surface-overlay='' className='fixed z-[var(--z-toast)] m-0 list-none p-0' exit={{ opacity: 0, diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 0c600553338..6dc3401cf11 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -246,6 +246,7 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ id={id} role={role} aria-hidden={role ? undefined : 'true'} + data-native-surface-overlay='' className={cn( 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out', 'motion-reduce:transition-none', diff --git a/packages/security/package.json b/packages/security/package.json index 754e354b983..afa673f035d 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -26,6 +26,14 @@ "types": "./src/hmac.ts", "default": "./src/hmac.ts" }, + "./hostnames": { + "types": "./src/hostnames.ts", + "default": "./src/hostnames.ts" + }, + "./ssrf": { + "types": "./src/ssrf.ts", + "default": "./src/ssrf.ts" + }, "./tokens": { "types": "./src/tokens.ts", "default": "./src/tokens.ts" @@ -40,7 +48,9 @@ "test": "vitest run", "test:watch": "vitest" }, - "dependencies": {}, + "dependencies": { + "ipaddr.js": "2.3.0" + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", diff --git a/packages/security/src/hostnames.test.ts b/packages/security/src/hostnames.test.ts new file mode 100644 index 00000000000..e653c13f542 --- /dev/null +++ b/packages/security/src/hostnames.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames' + +describe('isLoopbackHostname', () => { + it('matches localhost and the loopback literals, brackets optional', () => { + expect(isLoopbackHostname('localhost')).toBe(true) + expect(isLoopbackHostname('127.0.0.1')).toBe(true) + expect(isLoopbackHostname('::1')).toBe(true) + expect(isLoopbackHostname('[::1]')).toBe(true) + }) + + it('does not match other loopback-range IPs or public hosts (exact-set only)', () => { + expect(isLoopbackHostname('127.0.0.5')).toBe(false) + expect(isLoopbackHostname('example.com')).toBe(false) + expect(isLoopbackHostname('10.0.0.1')).toBe(false) + }) +}) + +describe('unwrapIpv6Brackets', () => { + it('strips brackets from IPv6 authorities', () => { + expect(unwrapIpv6Brackets('[::1]')).toBe('::1') + expect(unwrapIpv6Brackets('[2606:4700::1111]')).toBe('2606:4700::1111') + }) + + it('leaves bare hostnames untouched', () => { + expect(unwrapIpv6Brackets('example.com')).toBe('example.com') + expect(unwrapIpv6Brackets('127.0.0.1')).toBe('127.0.0.1') + }) +}) diff --git a/packages/security/src/hostnames.ts b/packages/security/src/hostnames.ts new file mode 100644 index 00000000000..cd029da8d93 --- /dev/null +++ b/packages/security/src/hostnames.ts @@ -0,0 +1,28 @@ +/** + * Pure host-string helpers with no `ipaddr.js` dependency, so client bundles can + * share them without pulling the IP library. The ipaddr-backed classification + * lives in `./ssrf`, which re-exports these for its own consumers. + */ + +/** + * Strips the brackets the WHATWG URL parser puts around IPv6 authorities so the + * result can be matched or handed to an IP classifier directly. + */ +export function unwrapIpv6Brackets(host: string): string { + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host +} + +/** + * Loopback host identifiers permitted to use plain HTTP: `localhost` and the + * canonical loopback IP literals. Compared after stripping IPv6 brackets. + */ +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1']) + +/** + * True when a host (name or IP literal, IPv6 brackets optional) is loopback by + * exact match — `localhost`, `127.0.0.1`, or `::1`. For full-range loopback-IP + * classification (e.g. `127.0.0.5`) use `isLoopbackIp` from `./ssrf`. + */ +export function isLoopbackHostname(host: string): boolean { + return LOOPBACK_HOSTNAMES.has(unwrapIpv6Brackets(host)) +} diff --git a/packages/security/src/ssrf.test.ts b/packages/security/src/ssrf.test.ts new file mode 100644 index 00000000000..dfa7d301022 --- /dev/null +++ b/packages/security/src/ssrf.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from './ssrf' + +describe('isPrivateIp', () => { + describe('IPv4 private/reserved ranges', () => { + it.each([ + ['192.168.1.1'], + ['192.168.0.0'], + ['10.0.0.1'], + ['10.255.255.255'], + ['172.16.0.1'], + ['172.31.255.255'], + ['127.0.0.1'], + ['127.255.255.255'], + ['169.254.169.254'], + ['0.0.0.0'], + ['224.0.0.1'], + ])('blocks IPv4 %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('IPv6 reserved ranges', () => { + it.each([['::1'], ['::'], ['fe80::1'], ['fc00::1'], ['fd00::1'], ['ff02::1'], ['2001:db8::1']])( + 'blocks IPv6 %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { + it.each([ + ['::ffff:192.168.1.1'], + ['::ffff:127.0.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:c0a8:101'], + ['::ffff:0:0'], + ])('blocks mapped private/reserved %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it('allows mapped public IPv4 ::ffff:8.8.8.8', () => { + expect(isPrivateIp('::ffff:8.8.8.8')).toBe(false) + }) + }) + + describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { + it('blocks NAT64-encoded private IPv4', () => { + expect(isPrivateIp('64:ff9b::192.168.1.1')).toBe(true) + }) + }) + + describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { + it.each([ + ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], + ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], + ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], + ['::7f00:1', '127.0.0.1 (loopback)'], + ['::7f00:0001', '127.0.0.1 (zero-padded)'], + ['::a00:1', '10.0.0.1 (RFC1918)'], + ['::ac10:1', '172.16.0.1 (RFC1918)'], + ['::e000:1', '224.0.0.1 (multicast)'], + ['::192.168.1.1', 'dotted form ::192.168.1.1'], + ['::169.254.169.254', 'dotted form ::169.254.169.254'], + ['::127.0.0.1', 'dotted form ::127.0.0.1'], + ['::10.0.0.1', 'dotted form ::10.0.0.1'], + ])('blocks %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it.each([ + ['::8.8.8.8', 'dotted form embedding public IPv4'], + ['::808:808', 'hex form embedding 8.8.8.8'], + ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], + ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + + it.each([ + ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], + ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], + ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], + ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], + ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { + it.each([ + ['2606:4700:4700::1111'], + ['2001:4860:4860::8888'], + ['::1:c0a8:101'], + ['1::c0a8:101'], + ['1:2:3:4:5:6:c0a8:101'], + ])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 public addresses', () => { + it.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 alternate notations', () => { + it.each([['0177.0.0.1'], ['0x7f000001'], ['2130706433']])( + 'blocks loopback notation %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('invalid input fails closed', () => { + it.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g'], ['example.com']])( + 'rejects %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('URL-parser normalized IPv6 forms', () => { + it('blocks Node-normalized [::192.168.1.1] → ::c0a8:101', () => { + const hostname = new URL('http://[::192.168.1.1]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::c0a8:101') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + + it('blocks Node-normalized [::169.254.169.254] → ::a9fe:a9fe', () => { + const hostname = new URL('http://[::169.254.169.254]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::a9fe:a9fe') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + }) +}) + +describe('isPrivateIpHost', () => { + it('blocks private/reserved IP literals (IPv4 and IPv6, bracketed or bare)', () => { + expect(isPrivateIpHost('10.0.0.1')).toBe(true) + expect(isPrivateIpHost('169.254.169.254')).toBe(true) + expect(isPrivateIpHost('127.0.0.1')).toBe(true) + expect(isPrivateIpHost('[::1]')).toBe(true) + expect(isPrivateIpHost('[fd00:ec2::254]')).toBe(true) + expect(isPrivateIpHost('[::ffff:127.0.0.1]')).toBe(true) + }) + + it('allows public IP literals', () => { + expect(isPrivateIpHost('8.8.8.8')).toBe(false) + expect(isPrivateIpHost('[2606:4700:4700::1111]')).toBe(false) + }) + + it('fails open on DNS names (resolution handled separately)', () => { + expect(isPrivateIpHost('example.com')).toBe(false) + expect(isPrivateIpHost('api.zoominfo.com')).toBe(false) + expect(isPrivateIpHost('localhost')).toBe(false) + }) +}) + +describe('isLoopbackIp', () => { + it('matches the full loopback range and ::1', () => { + expect(isLoopbackIp('127.0.0.1')).toBe(true) + expect(isLoopbackIp('127.0.0.5')).toBe(true) + expect(isLoopbackIp('::1')).toBe(true) + }) + + it('rejects non-loopback and other private ranges', () => { + expect(isLoopbackIp('10.0.0.1')).toBe(false) + expect(isLoopbackIp('8.8.8.8')).toBe(false) + expect(isLoopbackIp('169.254.169.254')).toBe(false) + expect(isLoopbackIp('not-an-ip')).toBe(false) + expect(isLoopbackIp('localhost')).toBe(false) + }) +}) diff --git a/packages/security/src/ssrf.ts b/packages/security/src/ssrf.ts new file mode 100644 index 00000000000..930fe3eb70f --- /dev/null +++ b/packages/security/src/ssrf.ts @@ -0,0 +1,92 @@ +import * as ipaddr from 'ipaddr.js' +import { unwrapIpv6Brackets } from './hostnames' + +// Re-export the pure host helpers so existing `@sim/security/ssrf` consumers +// keep one import site; client code that must avoid ipaddr imports `./hostnames`. +export { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames' + +/** + * True when the (bracket-free) host is an IP literal rather than a DNS name — + * i.e. it can be classified with {@link isPrivateIp} directly, no DNS lookup. + */ +export function isIpLiteral(host: string): boolean { + return ipaddr.isValid(host) +} + +/** + * True when an IP address is loopback (127.0.0.0/8 or ::1). Narrower than + * {@link isPrivateIp}: callers that treat loopback differently from other + * private ranges (e.g. allowing local dev servers on self-host) use this. + */ +export function isLoopbackIp(ip: string): boolean { + try { + return ipaddr.isValid(ip) && ipaddr.process(ip).range() === 'loopback' + } catch { + return false + } +} + +/** + * Classifies an IP address as private or otherwise not routable on the public + * internet — the core SSRF primitive shared by every app that resolves a user- + * or model-supplied host before connecting to it. + * + * Uses ipaddr.js for robust handling of forms that regex checks miss: + * - Octal (`0177.0.0.1`) and hex (`0x7f000001`) IPv4 + * - IPv4-mapped IPv6 (`::ffff:127.0.0.1`) + * - IPv4-compatible IPv6 (`::a.b.c.d` / `::xxxx:xxxx`, RFC 4291 §2.5.5.1, deprecated) + * - Loopback, link-local (incl. the `169.254.169.254` cloud-metadata endpoint), + * unique-local, multicast, and every other non-`unicast` range + * + * Expects a bare IP (brackets already stripped). Returns `true` (blocked) for + * anything that is not a valid, publicly routable unicast address — including + * unparseable input, so callers fail closed. + */ +export function isPrivateIp(ip: string): boolean { + try { + if (!ipaddr.isValid(ip)) { + return true + } + + const addr = ipaddr.process(ip) + const range = addr.range() + + if (range !== 'unicast') { + return true + } + + if (addr.kind() === 'ipv6') { + const v6 = addr as ipaddr.IPv6 + const parts = v6.parts + const firstSixZero = parts.slice(0, 6).every((p) => p === 0) + if (firstSixZero) { + const embedded = ipaddr.fromByteArray([ + (parts[6] >> 8) & 0xff, + parts[6] & 0xff, + (parts[7] >> 8) & 0xff, + parts[7] & 0xff, + ]) + return embedded.range() !== 'unicast' + } + } + + return false + } catch { + return true + } +} + +/** + * Classifies a URL/host hostname string that may be an IP literal. Returns + * `true` only when the host is a **literal** IP that {@link isPrivateIp} blocks; + * a DNS name (which needs resolution to classify) returns `false`. + * + * Use this for the synchronous "is this host a private IP literal" guard — a + * pre-navigation check, or a per-request subresource filter — where hostnames + * are handled separately by a DNS-resolving check. IPv6 brackets are stripped + * automatically. + */ +export function isPrivateIpHost(host: string): boolean { + const clean = unwrapIpv6Brackets(host) + return isIpLiteral(clean) && isPrivateIp(clean) +} diff --git a/packages/terminal-protocol/package.json b/packages/terminal-protocol/package.json new file mode 100644 index 00000000000..b7ecad0f013 --- /dev/null +++ b/packages/terminal-protocol/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sim/terminal-protocol", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": {}, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts new file mode 100644 index 00000000000..320841c98d1 --- /dev/null +++ b/packages/terminal-protocol/src/index.ts @@ -0,0 +1,396 @@ +/** + * Shared types for the Sim agent terminal — the interactive shells built into + * the Sim desktop app. + * + * The Sim web app (renderer) drives real PTYs through the desktop preload + * bridge (`window.simDesktop.terminal`); the Electron main process owns the + * `node-pty` processes and streams their bytes back for xterm.js to render. + * The user and the agent share the same shells, so `cd`, exported variables, + * and scrollback are common to both. + * + * Several terminals can be open at once, each its own shell with its own + * working directory and scrollback, exactly like tabs in a terminal app. One is + * active at a time; agent tools act on the active one unless they name another. + * + * Tool names and parameter shapes mirror the mothership tool catalog + * (`copilot/internal/tools/catalog/terminal` in the mothership repo) — that + * catalog is the source of truth for what the model can call; this package is + * the source of truth for how those calls travel to the desktop main process. + */ + +/** The single tool the model calls; what it does is in `operation`. */ +export const TERMINAL_TOOL_NAME = 'terminal' + +/** + * Names this surface used to expose, one tool per operation. Kept so rows in + * conversations recorded before the consolidation still render with a real + * title instead of a humanized tool name. + */ +export const LEGACY_TERMINAL_TOOL_NAMES = [ + 'terminal_run', + 'terminal_input', + 'terminal_read', + 'terminal_kill', + 'terminal_cwd', + 'terminal_list', + 'terminal_new', + 'terminal_switch', + 'terminal_close', +] as const + +export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] + +/** + * What one `terminal` call does. + * + * The first group acts on a shell — or, when that shell has tmux attached, on + * a pane inside it. The second group manages Sim's own tabs. `panes` is the + * one tmux-only operation: tmux owns its windows and splits, so the agent + * inspects them rather than Sim mirroring them into the tab strip. `handoff` + * gives the terminal to the user and waits. + */ +export const TERMINAL_OPERATIONS = [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', +] as const + +export type TerminalOperation = (typeof TERMINAL_OPERATIONS)[number] + +const TERMINAL_OPERATION_SET: ReadonlySet = new Set(TERMINAL_OPERATIONS) + +export function isTerminalOperation(value: unknown): value is TerminalOperation { + return typeof value === 'string' && TERMINAL_OPERATION_SET.has(value) +} + +export function isTerminalToolName(name: string): boolean { + return name === TERMINAL_TOOL_NAME +} + +/** + * Ceiling on concurrently open terminals. Each is a live shell process with its + * own emulator and scrollback, so the cap bounds both memory and the number of + * things the user has to keep track of. + */ +export const MAX_TERMINALS = 8 + +/** + * Largest command output handed back to the model, in characters. Output past + * this is middle-elided (head and tail kept) because the interesting parts of + * a long build log are the command echo and the failure at the end. + */ +export const MAX_TOOL_OUTPUT_CHARS = 30_000 + +/** Scrollback the main process retains per terminal for reads and repaints. */ +export const MAX_SCROLLBACK_CHARS = 256_000 + +/** + * Ceiling on the raw bytes buffered while capturing one command's output. A + * full-screen program repaints continuously and can emit megabytes a second, + * so capture keeps a capped head plus a rolling tail rather than growing until + * the command ends. + */ +export const MAX_CAPTURE_CHARS = 512_000 + +/** + * How long `terminal_run` waits for a command before handing control back. + * + * Deliberately short. A long blocking call would leave the user watching + * nothing and the agent unable to react, so anything still running comes back + * as `running` with the output so far; the agent then polls it with `wait` and + * `terminal_read`. Successive reads are also how it tells progress from a + * stall — output that stops changing is a command waiting on input or wedged. + */ +export const DEFAULT_RUN_WAIT_MS = 30_000 + +export const MAX_RUN_WAIT_MS = 120_000 + +/** + * How long output must be silent, with the cursor left mid-line, before the + * command is treated as sitting on a prompt and handed straight back. + * + * Waiting out the full window for something as obvious as `[y/n]` reads as a + * hang. A command that stops mid-line has written a prompt and is waiting for + * an answer; one that is merely working either keeps printing or has ended its + * last line properly, so neither trips this. + */ +export const PROMPT_IDLE_MS = 2_500 + +/** + * Ceiling on one batch of keystrokes. Long enough to cross a menu, short + * enough that a mistaken batch cannot run away with the program — every key + * after the first is sent without seeing what the last one did. + */ +export const MAX_INPUT_KEYS = 20 + +/** Control keys the agent may send to a running foreground process. */ +export const TERMINAL_CONTROL_KEYS = [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', +] as const + +export type TerminalControlKey = (typeof TERMINAL_CONTROL_KEYS)[number] + +const TERMINAL_CONTROL_KEY_SET: ReadonlySet = new Set(TERMINAL_CONTROL_KEYS) + +export function isTerminalControlKey(value: unknown): value is TerminalControlKey { + return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) +} + +export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' + +/** + * Arguments for every operation, flattened into one object. + * + * A flat bag rather than a discriminated union because it has to survive a + * round trip through a JSON tool schema, where the model supplies whichever + * fields its chosen operation needs. Each operation validates the ones it + * requires and ignores the rest. + */ +export interface TerminalToolArgs { + /** `run`: the command line to execute. */ + command?: string + /** `input`: literal text to type. */ + text?: string + /** `input`: a key to press instead of text. */ + key?: TerminalControlKey + /** + * `input`: several keys pressed in order, for stepping through a menu + * ("down", "down", "enter") without a round trip per keystroke. Each is a + * real keypress with a pause between, so the program redraws as it would + * under a person's hands. Capped at {@link MAX_INPUT_KEYS}. + */ + keys?: TerminalControlKey[] + /** `read`: trailing lines to return. */ + lines?: number + /** `kill`: which signal. Defaults to SIGINT. */ + signal?: TerminalSignal + /** `new`: directory to open in. Defaults to the active terminal's cwd. */ + cwd?: string + /** `run`: how long to wait before handing back a still-running command. */ + waitSeconds?: number + /** + * Which terminal to act on. Omitting it targets the active one, which is + * what the user is looking at and what a single-terminal conversation + * always means. + */ + terminalId?: string + /** + * Which tmux pane to act on, as a tmux target (`session:window.pane`), for + * a terminal that has tmux attached. Omitting it uses that session's active + * pane. Ignored when the terminal is a plain shell. + */ + pane?: string + /** `handoff`: what the user needs to do, shown on the chip they click. */ + reason?: string +} + +export interface TerminalToolCall { + operation: TerminalOperation + args?: TerminalToolArgs +} + +/** + * How a `terminal_run` ended. Only `completed` means the command is finished + * and the terminal is free; in every other case it is still running and still + * holds the foreground. + */ +export type TerminalRunStatus = + /** Exited on its own. `exitCode` is set. */ + | 'completed' + /** + * Still going when the wait window elapsed. Not an error and not a stall — + * poll it rather than re-running or giving up. + */ + | 'running' + /** + * Took over the screen (an editor, pager, or interactive CLI). Its output is + * redraws rather than text and it will not exit unaided. + */ + | 'interactive' + +export interface TerminalRunResult { + command: string + output: string + status: TerminalRunStatus + /** Null unless `status` is `completed`. */ + exitCode: number | null + durationMs: number + cwd: string | null + terminalId: string + /** Set when the command ran in tmux: the target it ran under. */ + pane?: string + /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ + truncated: boolean + /** + * Set when the command looks like it is blocked on a prompt: it printed + * something, stopped mid-line, and went quiet. Answer it with terminal_input + * rather than waiting — it will not proceed on its own. + */ + awaitingInput?: boolean +} + +export interface TerminalReadResult { + /** + * The screen as text. When a row is highlighted the way a menu marks its + * selection, it is prefixed `[selected] ` — a TUI that indicates the current + * row with colour alone is otherwise invisible in plain text, leaving the + * agent unable to tell where it is before it starts pressing keys. + */ + output: string + cwd: string | null + terminalId: string + /** Set when the read came from tmux: the pane it captured. */ + pane?: string + truncated: boolean + /** + * The command still holding the terminal, or null when the shell is back at + * a prompt. This is the definitive "is it done" signal for a poll loop — + * seeing expected text in the output is not, because a command can print its + * last line well before it exits. + */ + running: string | null +} + +export interface TerminalCwdResult { + cwd: string | null + shellName: string | null + home: string | null + terminalId: string +} + +/** One open terminal, as shown in the tab strip. */ +export interface TerminalTabState { + terminalId: string + /** Short label for the tab: the running command, else the cwd's basename. */ + title: string + cwd: string | null + /** Command holding the foreground, when one is running. */ + running: string | null + /** + * True while a full-screen program owns the terminal. Distinct from merely + * `running`: a build is transient work, an editor or coding agent is an open + * application that will sit there until it is quit. + */ + interactive: boolean + active: boolean + /** + * The tmux session attached in this terminal, when one is. Its windows and + * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a + * count of the shells Sim opened. + */ + tmuxSession?: string | null +} + +/** One tmux pane, as reported by the `panes` operation. */ +export interface TerminalPaneState { + /** tmux target (`session:window.pane`), usable as the `pane` argument. */ + target: string + windowName: string + /** The process tmux reports in the pane; a bare shell means it is idle. */ + command: string + cwd: string | null + active: boolean +} + +/** + * The outcome of handing a terminal to the user. + * + * Resolves when the command that was blocking finishes, so the agent picks up + * where it left off rather than having to guess whether the user is done. A + * command still going after the user says they have finished comes back with + * `running` set, which is the same poll-it signal a long `run` returns. + */ +export interface TerminalHandoffResult { + terminalId: string + reason: string + /** True when the user pressed the hand-back button rather than the command just ending. */ + handedBack: boolean + /** The command still holding the terminal, or null when it is back at a prompt. */ + running: string | null + /** The screen as it stands now. */ + output: string + cwd: string | null +} + +export interface TerminalPanesResult { + terminalId: string + session: string + panes: TerminalPaneState[] +} + +export interface TerminalTabsState { + tabs: TerminalTabState[] + activeTerminalId: string | null +} + +/** The result of one terminal tool invocation, as returned over the bridge. */ +export interface TerminalToolResponse { + ok: boolean + result?: unknown + error?: string + code?: TerminalErrorCode +} + +export type TerminalErrorCode = + | 'SESSION_CLOSED' + /** Another command already holds the foreground in that terminal. */ + | 'BUSY' + | 'TIMEOUT' + /** + * The shell never emitted integration markers, so command boundaries and + * exit codes are unknowable and `terminal_run` must refuse rather than guess. + */ + | 'NO_SHELL_INTEGRATION' + | 'SPAWN_FAILED' + /** No terminal with that id — the ids come from terminal_list. */ + | 'NO_SUCH_TERMINAL' + /** Already at {@link MAX_TERMINALS}. */ + | 'TOO_MANY_TERMINALS' + /** The operation needs tmux, and this terminal has no tmux attached. */ + | 'NO_TMUX' + /** No pane with that target — the targets come from the `panes` operation. */ + | 'NO_SUCH_PANE' + | 'INVALID_REQUEST' + +export interface TerminalStartOptions { + cols: number + rows: number +} + +/** One batch of PTY bytes, tagged with the terminal that produced it. */ +export interface TerminalOutputEvent { + terminalId: string + data: string +} + +/** + * Command lifecycle, used by the panel to attribute rows to the agent and to + * show a running indicator. Emitted for user-typed commands too (no + * `toolCallId`), so the agent's `terminal_read` and the user's view agree. + */ +export interface TerminalCommandEvent { + terminalId: string + phase: 'start' | 'end' + command: string + /** Set when the agent initiated this command rather than the user. */ + toolCallId?: string + exitCode?: number + durationMs?: number +} diff --git a/packages/terminal-protocol/tsconfig.json b/packages/terminal-protocol/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/terminal-protocol/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 5d81d5a65f7..d8e3ecd699a 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -13,6 +13,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean isAuthDisabled: boolean @@ -56,6 +57,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, diff --git a/packages/testing/src/mocks/input-validation.mock.ts b/packages/testing/src/mocks/input-validation.mock.ts index bf8c38ac94d..06e8ca88e77 100644 --- a/packages/testing/src/mocks/input-validation.mock.ts +++ b/packages/testing/src/mocks/input-validation.mock.ts @@ -17,7 +17,6 @@ export const inputValidationMockFns = { mockValidateDatabaseHost: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), mockSecureFetchWithValidation: vi.fn(), - mockIsPrivateOrReservedIP: vi.fn().mockReturnValue(false), mockCreatePinnedLookup: vi.fn(), } @@ -35,7 +34,6 @@ export const inputValidationMock = { validateDatabaseHost: inputValidationMockFns.mockValidateDatabaseHost, secureFetchWithPinnedIP: inputValidationMockFns.mockSecureFetchWithPinnedIP, secureFetchWithValidation: inputValidationMockFns.mockSecureFetchWithValidation, - isPrivateOrReservedIP: inputValidationMockFns.mockIsPrivateOrReservedIP, createPinnedLookup: inputValidationMockFns.mockCreatePinnedLookup, SecureFetchHeaders: class { headers: Record = {} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 49ac0d3c6c5..de65ba7bbf1 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -752,7 +752,6 @@ export const schemaMock = { model: 'model', conversationId: 'conversationId', previewYaml: 'previewYaml', - planArtifact: 'planArtifact', config: 'config', resources: 'resources', lastSeenAt: 'lastSeenAt', diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 36cc743b146..de3e23b6cda 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -33,4 +33,9 @@ export { export type { BackoffOptions } from './retry.js' export { backoffWithJitter, parseRetryAfter } from './retry.js' export { normalizeSSODomain } from './sso-domain.js' -export { normalizeEmail, truncate } from './string.js' +export { + normalizeEmail, + sanitizeForJsonb, + sanitizeValueForJsonb, + truncate, +} from './string.js' diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index aca1811866f..af81c22d5aa 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -2,7 +2,14 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js' +import { + isVersionedType, + normalizeEmail, + sanitizeForJsonb, + sanitizeValueForJsonb, + stripVersionSuffix, + truncate, +} from './string.js' describe('truncate', () => { it('appends the suffix when the string exceeds the slice length', () => { @@ -56,6 +63,55 @@ describe('isVersionedType', () => { }) }) +describe('sanitizeForJsonb', () => { + it('replaces a lone high surrogate left by mid-character truncation', () => { + // '𝐀'.slice(0, 1) cuts the surrogate pair in half + const cut = '\uD835\uDC00'.slice(0, 1) + expect(sanitizeForJsonb(`FIFA WORLD CU${cut}`)).toBe('FIFA WORLD CU\uFFFD') + }) + + it('replaces a lone low surrogate', () => { + expect(sanitizeForJsonb('x\uDC00y')).toBe('x\uFFFDy') + }) + + it('replaces NUL characters', () => { + expect(sanitizeForJsonb('a\u0000b')).toBe('a\uFFFDb') + }) + + it('preserves well-formed surrogate pairs', () => { + expect(sanitizeForJsonb('𝐅𝐈𝐅𝐀 🏆')).toBe('𝐅𝐈𝐅𝐀 🏆') + }) + + it('handles a lone high surrogate followed by a valid pair', () => { + expect(sanitizeForJsonb('\uD835\uD835\uDC00')).toBe('\uFFFD\uD835\uDC00') + }) +}) + +describe('sanitizeValueForJsonb', () => { + it('sanitizes strings nested in objects and arrays', () => { + const input = { outline: ['ok', 'bad\uD835'], meta: { title: 'x\u0000' } } + expect(sanitizeValueForJsonb(input)).toEqual({ + outline: ['ok', 'bad\uFFFD'], + meta: { title: 'x\uFFFD' }, + }) + }) + + it('sanitizes object keys', () => { + expect(sanitizeValueForJsonb({ 'k\uDC00': 1 })).toEqual({ 'k\uFFFD': 1 }) + }) + + it('returns the same reference when nothing needs rewriting', () => { + const input = { a: ['clean', { b: 'also clean 🏆' }], n: 3 } + expect(sanitizeValueForJsonb(input)).toBe(input) + }) + + it('passes primitives through unchanged', () => { + expect(sanitizeValueForJsonb(42)).toBe(42) + expect(sanitizeValueForJsonb(null)).toBe(null) + expect(sanitizeValueForJsonb(undefined)).toBe(undefined) + }) +}) + describe('normalizeEmail', () => { it('trims surrounding whitespace and lowercases', () => { expect(normalizeEmail(' USER@Example.COM ')).toBe('user@example.com') diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 1cbea71cfbb..f087494ff77 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -45,3 +45,55 @@ export function isVersionedType(value: string): boolean { export function normalizeEmail(email: string): string { return email.trim().toLowerCase() } + +/** + * Matches UTF-16 code units that Postgres JSONB rejects: unpaired surrogate + * halves (e.g. produced by `slice()` cutting an astral character like 𝐀 in + * half) and the NUL character, which jsonb cannot store at all. + */ +const JSONB_UNSAFE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(value: T): T { + if (typeof value === 'string') { + const clean = sanitizeForJsonb(value) + return (clean === value ? value : clean) as T + } + if (Array.isArray(value)) { + let changed = false + const result = value.map((item) => { + const clean = sanitizeValueForJsonb(item) + if (clean !== item) changed = true + return clean + }) + return (changed ? result : value) as T + } + if (typeof value === 'object' && value !== null) { + let changed = false + const result: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + const cleanKey = sanitizeForJsonb(key) + const cleanItem = sanitizeValueForJsonb(item) + if (cleanKey !== key || cleanItem !== item) changed = true + result[cleanKey] = cleanItem + } + return (changed ? result : value) as T + } + return value +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7d364ffe8d8..db4fde94994 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 984, - zodRoutes: 984, + totalRoutes: 990, + zodRoutes: 990, nonZodRoutes: 0, } as const @@ -32,6 +32,12 @@ const BOUNDARY_POLICY_BASELINE = { const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/demo-requests/route.ts', + // Input-less session-bound GET: nothing to validate; response is + // contract-typed via `satisfies InvitationDetails` in the route. + // Public updater feed: input-less GET, session-less, returns YAML (not JSON), + // so it can't be JSON-contract-bound. Wrapped in withRouteHandler. + 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', + 'apps/sim/app/api/invitations/route.ts', 'apps/sim/app/api/logs/export/route.ts', 'apps/sim/app/api/tools/docusign/route.ts', // Better Auth handles its own validation for the catch-all route below. @@ -45,6 +51,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/auth/oauth/connections/route.ts', 'apps/sim/app/api/auth/providers/route.ts', 'apps/sim/app/api/auth/socket-token/route.ts', + 'apps/sim/app/api/desktop/auth/handoff/route.ts', 'apps/sim/app/api/workspaces/invitations/route.ts', // Internal cron entry point that authenticates via `Authorization: Bearer // CRON_SECRET` and ignores query/body. The boundary contract is "no diff --git a/scripts/check-desktop-bridge-contract.ts b/scripts/check-desktop-bridge-contract.ts new file mode 100644 index 00000000000..aa28cb658a4 --- /dev/null +++ b/scripts/check-desktop-bridge-contract.ts @@ -0,0 +1,258 @@ +/** + * Desktop bridge contract audit. + * + * The desktop shell is an installed binary users update on their own + * schedule, while the web app it loads is deployed continuously. The preload + * bridge contract (`@sim/desktop-bridge`, which embeds `@sim/browser-protocol` + * types) must therefore stay backward compatible: an already-installed shell + * has to satisfy whatever the newest web deployment expects. + * + * This script keeps a frozen snapshot of the full bridge type surface + * (`packages/desktop-bridge/contract-snapshot.ts`) and type-checks that a + * shell built from the snapshot is still assignable to the current + * `SimDesktopApi` — additive/optional changes pass, removals, renames, and + * new required members fail. + * + * Modes: + * - `--check` (default, CI): fails when the current types break + * compatibility with the snapshot, or when the snapshot's recorded floor + * drifts from `MIN_DESKTOP_VERSION`. + * - `--update`: regenerates the snapshot from the current sources. A + * breaking regeneration is refused unless `MIN_DESKTOP_VERSION` + * (`apps/sim/lib/desktop/min-version.ts`) was raised above the previous + * snapshot's floor — bumping the floor is the deliberate escape hatch that + * makes outdated shells show the "update to continue" takeover. + * + * Known limitation: TypeScript checks method parameters bivariantly, so + * widening a request union or callback payload is not flagged. Those changes + * are additive for the shell (unknown requests fail soft), but semantic + * changes to callback payloads still need review. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const BRIDGE_SOURCE_PATH = resolve(ROOT, 'packages/desktop-bridge/src/index.ts') +const PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/browser-protocol/src/index.ts') +const TERMINAL_PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/terminal-protocol/src/index.ts') +/** + * Protocol modules folded into the snapshot verbatim. Anything the bridge + * imports that carries wire shape belongs here — a package left out stays + * outside the freeze, and changes to it pass the audit unnoticed. + */ +const INLINED_PROTOCOL_PACKAGES = ['@sim/browser-protocol', '@sim/terminal-protocol'] as const +const SNAPSHOT_PATH = resolve(ROOT, 'packages/desktop-bridge/contract-snapshot.ts') +const MIN_VERSION_PATH = resolve(ROOT, 'apps/sim/lib/desktop/min-version.ts') + +const FLOOR_PATTERN = /^ \* min-desktop-version: (\S+)$/m + +async function readMinDesktopVersion(): Promise { + const source = await readFile(MIN_VERSION_PATH, 'utf8') + const match = /export const MIN_DESKTOP_VERSION = '([^']+)'/.exec(source) + if (!match) { + throw new Error(`Could not find MIN_DESKTOP_VERSION in ${MIN_VERSION_PATH}`) + } + return match[1] +} + +async function readSnapshot(): Promise<{ source: string; floor: string } | null> { + let source: string + try { + source = await readFile(SNAPSHOT_PATH, 'utf8') + } catch { + return null + } + const floor = FLOOR_PATTERN.exec(source)?.[1] + if (!floor) { + throw new Error(`${SNAPSHOT_PATH} is missing its min-desktop-version header`) + } + return { source, floor } +} + +/** Plain x.y.z ordering for floor versions; throws on anything else. */ +function isFloorRaised(next: string, previous: string): boolean { + const parse = (version: string): number[] => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) + if (!match) { + throw new Error(`MIN_DESKTOP_VERSION must be a plain x.y.z version, got '${version}'`) + } + return [Number(match[1]), Number(match[2]), Number(match[3])] + } + const [nextParts, previousParts] = [parse(next), parse(previous)] + for (let i = 0; i < 3; i++) { + if (nextParts[i] !== previousParts[i]) { + return nextParts[i] > previousParts[i] + } + } + return false +} + +async function buildSnapshot(floor: string): Promise { + const protocol = await readFile(PROTOCOL_SOURCE_PATH, 'utf8') + const terminalProtocol = await readFile(TERMINAL_PROTOCOL_SOURCE_PATH, 'utf8') + const bridgeRaw = await readFile(BRIDGE_SOURCE_PATH, 'utf8') + // The snapshot must be self-contained. A surviving import resolves to the + // CURRENT module on BOTH sides of the comparison, so every change to it is + // invisible to this audit — which is exactly what happened to + // @sim/terminal-protocol, leaving the whole terminal wire surface unfrozen. + const bridge = INLINED_PROTOCOL_PACKAGES.reduce( + (source, pkg) => source.replace(new RegExp(`import type \\{[^}]*\\} from '${pkg}'\\n`), ''), + bridgeRaw + ) + for (const pkg of INLINED_PROTOCOL_PACKAGES) { + if (bridge.includes(pkg)) { + throw new Error( + `packages/desktop-bridge/src/index.ts references ${pkg} in an unexpected shape — ` + + 'update scripts/check-desktop-bridge-contract.ts to inline it.' + ) + } + } + const header = [ + '/**', + ' * GENERATED FILE — DO NOT EDIT.', + ' *', + ' * Frozen snapshot of the desktop preload bridge type surface', + ' * (@sim/browser-protocol + @sim/terminal-protocol inlined into', + ' * @sim/desktop-bridge) as of the last accepted contract change.', + ' * CI type-checks that a shell built from this', + ' * snapshot still satisfies the current SimDesktopApi, so bridge changes', + ' * stay backward compatible with already-installed shells.', + ' *', + ' * Regenerate with: bun run desktop-bridge-contract:update', + ' * Full rules: scripts/check-desktop-bridge-contract.ts', + ' *', + ` * min-desktop-version: ${floor}`, + ' */', + '', + ].join('\n') + const source = `${header}${protocol}\n${terminalProtocol}\n${bridge}` + return formatGeneratedSource(source, SNAPSHOT_PATH, ROOT) +} + +/** + * Type-checks that an old shell (the committed snapshot) is assignable to + * the current SimDesktopApi — i.e. new web code can run against it. + */ +function checkCompatibility(): { compatible: boolean; output: string } { + const compatSource = [ + `import type { SimDesktopApi as CurrentApi } from '${BRIDGE_SOURCE_PATH}'`, + `import type { SimDesktopApi as OldShellApi } from '${SNAPSHOT_PATH}'`, + '', + 'declare const oldInstalledShell: OldShellApi', + '// If this assignment fails, the current web app expects something an', + '// already-installed shell cannot provide — a breaking bridge change.', + 'const currentWebAppExpectation: CurrentApi = oldInstalledShell', + 'void currentWebAppExpectation', + '', + ].join('\n') + const tsconfig = { + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + skipLibCheck: true, + types: [], + paths: { + '@sim/browser-protocol': [PROTOCOL_SOURCE_PATH], + '@sim/terminal-protocol': [TERMINAL_PROTOCOL_SOURCE_PATH], + }, + }, + files: ['./compat.ts'], + } + + const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-bridge-contract-')) + try { + writeFileSync(join(dir, 'compat.ts'), compatSource) + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)) + const result = spawnSync('bunx', ['tsc', '-p', dir, '--pretty', 'false'], { + cwd: ROOT, + encoding: 'utf8', + }) + return { + compatible: result.status === 0, + output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(), + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const BREAKING_GUIDANCE = ` +A shell built from the committed contract snapshot no longer satisfies the +current SimDesktopApi — installed desktop apps would break against this web +deployment. Either: + + 1. Make the change backward compatible: new fields, methods, and surfaces + on the bridge must be optional so older shells (which lack them) still + type-check. This is the default — prefer it. + + 2. If the change is genuinely breaking: bump MIN_DESKTOP_VERSION in + apps/sim/lib/desktop/min-version.ts to the desktop release your shell + change ships in, then run: + + bun run desktop-bridge-contract:update + + Shells older than that floor will show a blocking "Update Sim to + continue" screen until they update. +` + +async function runCheck(): Promise { + const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()]) + if (!snapshot) { + console.error( + `Missing ${SNAPSHOT_PATH}.\nRun: bun run desktop-bridge-contract:update and commit the result.` + ) + process.exit(1) + } + if (snapshot.floor !== minVersion) { + console.error( + `Contract snapshot floor (${snapshot.floor}) does not match MIN_DESKTOP_VERSION ` + + `(${minVersion}).\nRun: bun run desktop-bridge-contract:update and commit the result.` + ) + process.exit(1) + } + const { compatible, output } = checkCompatibility() + if (!compatible) { + console.error('Breaking desktop bridge change detected.\n') + console.error(output) + console.error(BREAKING_GUIDANCE) + process.exit(1) + } + console.log('Desktop bridge contract audit passed: bridge types are backward compatible.') +} + +async function runUpdate(): Promise { + const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()]) + if (snapshot) { + const { compatible, output } = checkCompatibility() + if (!compatible && !isFloorRaised(minVersion, snapshot.floor)) { + console.error('Refusing to accept a breaking bridge change without a floor bump.\n') + console.error(output) + console.error(BREAKING_GUIDANCE) + process.exit(1) + } + } + await writeFile(SNAPSHOT_PATH, await buildSnapshot(minVersion)) + console.log(`Regenerated ${SNAPSHOT_PATH} (min-desktop-version: ${minVersion}).`) +} + +const mode = process.argv.includes('--update') ? 'update' : 'check' +try { + if (mode === 'update') { + await runUpdate() + } else { + await runCheck() + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/scripts/check-desktop-ipc-contract.ts b/scripts/check-desktop-ipc-contract.ts new file mode 100644 index 00000000000..79ec7fda645 --- /dev/null +++ b/scripts/check-desktop-ipc-contract.ts @@ -0,0 +1,293 @@ +/** + * Audits the desktop IPC channel table against the preload bridge. + * + * This is the companion to `check-desktop-bridge-contract.ts` and exists + * because that one has a structural blind spot: it compares the bridge types + * against a snapshot the same PR is allowed to regenerate, so a change is only + * caught if the author forgets to run the updater. Nothing there derives truth + * from the running wire surface. + * + * This script has no snapshot. Every fact it checks is read from the source + * both sides actually execute: + * + * 1. Every channel the preload calls is declared in the main-process table, + * and every declared channel is reachable from the preload. A channel on + * one side only is either dead code or a call that silently no-ops. + * 2. Within a channel-name family (`terminal:`, `browser-agent:`, …) the + * `gate` and `requires` values agree, unless the outlier carries an + * explicit acknowledgment. A surface toggle that a new channel forgets is + * invisible in review — the channel simply works when it should not. + * + * Deviations are legitimate and common: a channel that READS or RESETS a + * surface's settings must keep working while the surface is off, or the user + * could never turn it back on. Each one says so in its own spec: + * + * 'browser-import:sites': { + * gate: 'app-origin', + * deviationReason: 'a read of already-imported data; settings lists these + * hosts to show what an import brought over', + * ... + * } + * + * A typed field rather than a `-- migration-safe:`-style comment because here + * the thing being annotated IS typed data. A comment is bound by position, so + * reordering the table would silently transfer an acknowledgment to whichever + * channel moved underneath it; a field moves with its channel. + * + * Run: `bun run check:desktop-ipc` + */ + +import { readdir, readFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const IPC_SOURCE_PATH = resolve(ROOT, 'apps/desktop/src/main/ipc.ts') +/** + * Every preload that can reach the handler table. The browser-page preload is + * a separate bundle with its own channels — omitting it made this audit report + * `browser-credentials:form-state` as dead when it is the one channel a real + * page depends on. + */ +const PRELOAD_SOURCE_PATHS = [ + resolve(ROOT, 'apps/desktop/src/preload/index.ts'), + resolve(ROOT, 'apps/desktop/src/preload/browser/index.ts'), +] + +const MAIN_SOURCE_DIR = resolve(ROOT, 'apps/desktop/src/main') + +/** + * Every channel declares a gate, so an empty parse is a parser failure rather + * than a real value — and a family that all parsed empty would agree with + * itself and assert nothing. Asserting per channel turns silent vacuity into + * a loud error, which is the failure mode this whole script exists to prevent + * in its sibling audit. + */ +const REQUIRED_FIELD = 'gate' + +/** + * How the main process pushes to the renderer. These channels never appear in + * the handler table — nothing is registered for them — so the reverse + * direction has to be verified against the senders themselves rather than + * skipped by prefix, which would have made the check vacuous for four of the + * six families. + */ +const PUSH_CALL_PATTERN = /(?:\.send|broadcast)\(\s*'([^']+)'/g + +interface ChannelDecl { + name: string + gate: string + requires: string + line: number + /** The channel's own `deviationReason`, or null when it declares none. */ + deviationReason: string | null +} + +/** The handler table is one `'channel': {` entry per line at a fixed depth. */ +function parseChannelTable(source: string): ChannelDecl[] { + const lines = source.split('\n') + const decls: ChannelDecl[] = [] + for (let i = 0; i < lines.length; i++) { + const match = /^ {4}'([^']+)':\s*\{$/.exec(lines[i]) + if (!match) continue + const closing = lines.indexOf(' },', i) + if (closing < 0) continue + const body = lines.slice(i, closing).join('\n') + // Read from the channel's OWN body, so the acknowledgment travels with it + // if the table is ever reordered. + const reason = /deviationReason:\s*\n?\s*(?:'([^']*)'|"([^"]*)")/.exec(body) + decls.push({ + name: match[1], + gate: /gate:\s*'([^']+)'/.exec(body)?.[1] ?? '', + requires: /requires:\s*'([^']+)'/.exec(body)?.[1] ?? '', + line: i + 1, + deviationReason: reason ? (reason[1] ?? reason[2] ?? '').trim() : null, + }) + } + return decls +} + +function familyOf(channel: string): string { + return channel.slice(0, channel.indexOf(':')) +} + +/** The value most channels in a family use; ties resolve to the first seen. */ +function dominant(values: string[]): string { + const counts = new Map() + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1) + let best = values[0] + let bestCount = 0 + for (const [value, count] of counts) { + if (count > bestCount) { + best = value + bestCount = count + } + } + return best +} + +/** + * Channel names bound to a module constant, so a call written as + * `ipcRenderer.send(FORM_STATE_CHANNEL, …)` resolves like an inline literal. + * Only channel-shaped values (`family:name`) are collected, which keeps every + * other string constant in the file out of the map. + */ +function channelConstants(source: string): Map { + const constants = new Map() + for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*'([^']*:[^']*)'/g)) { + constants.set(match[1], match[2]) + } + return constants +} + +/** Every channel any main-process module pushes to a renderer. */ +async function channelsPushedFromMain(): Promise> { + const pushed = new Set() + const entries = await readdir(MAIN_SOURCE_DIR, { recursive: true, withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) continue + const source = await readFile(resolve(entry.parentPath, entry.name), 'utf8') + for (const match of source.matchAll(PUSH_CALL_PATTERN)) pushed.add(match[1]) + } + return pushed +} + +function channelsFromPreload(source: string, methods: string): Set { + const constants = channelConstants(source) + const found = new Set() + const pattern = new RegExp( + `ipcRenderer\\.(?:${methods})\\(\\s*(?:'([^']+)'|([A-Za-z_$][\\w$]*))`, + 'g' + ) + for (const match of source.matchAll(pattern)) { + const name = match[1] ?? constants.get(match[2] ?? '') + if (name) found.add(name) + } + return found +} + +async function main(): Promise { + const [ipcSource, ...preloadSources] = await Promise.all([ + readFile(IPC_SOURCE_PATH, 'utf8'), + ...PRELOAD_SOURCE_PATHS.map((path) => readFile(path, 'utf8')), + ]) + + const declared = parseChannelTable(ipcSource) + // Completeness, not just non-emptiness. A total parse failure is obvious; the + // dangerous case is PARTIAL — 48 of 49 channels parsed, the audit prints + // "passed", and the one it skipped is the one the PR added. Counting the + // channel-shaped keys in the file independently of how the bodies parse is + // what makes a skipped entry loud. + // Deliberately looser than the parser's own pattern: it counts the KEY only, + // with no constraint on what follows. Deriving both counts from the same + // shape would make them drop together and agree, which is exactly the + // vacuous pass this guard exists to prevent. + const expectedChannels = (ipcSource.match(/^ {4}'[a-z-]+:[^']*':/gm) ?? []).length + if (declared.length !== expectedChannels) { + throw new Error( + `Parsed ${declared.length} channels from ${relative(ROOT, IPC_SOURCE_PATH)} but the file ` + + `declares ${expectedChannels} — the table's shape changed and this script can no longer ` + + 'read all of it. Update parseChannelTable rather than letting the audit pass vacuously.' + ) + } + + const unparsed = declared.filter((channel) => channel[REQUIRED_FIELD] === '') + if (unparsed.length > 0) { + throw new Error( + `Could not read \`${REQUIRED_FIELD}\` for ${unparsed.length} channel(s) ` + + `(${unparsed.map((channel) => channel.name).join(', ')}). Every channel declares one, so ` + + 'this is a parser failure — a family that all parsed empty would agree with itself and ' + + 'assert nothing. Update parseChannelTable rather than letting the audit pass vacuously.' + ) + } + + const called = new Set() + const subscribed = new Set() + for (const source of preloadSources) { + for (const name of channelsFromPreload(source, 'invoke|send')) called.add(name) + for (const name of channelsFromPreload(source, 'on|once')) subscribed.add(name) + } + const declaredNames = new Set(declared.map((channel) => channel.name)) + const preloadLabel = 'apps/desktop/src/preload' + const failures: string[] = [] + + for (const name of called) { + if (!declaredNames.has(name)) { + failures.push( + `${preloadLabel}: calls '${name}', which no main-process handler declares. The call ` + + 'resolves to nothing at runtime.' + ) + } + } + + for (const channel of declared) { + if (called.has(channel.name)) continue + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' is handled but never ` + + 'called from the preload — dead channel, or a bridge method that was dropped.' + ) + } + + const pushed = await channelsPushedFromMain() + for (const name of subscribed) { + if (declaredNames.has(name) || pushed.has(name)) continue + failures.push( + `${preloadLabel}: subscribes to '${name}', but no main-process module ever sends it — the ` + + 'listener can never fire.' + ) + } + + const families = new Map() + for (const channel of declared) { + const family = familyOf(channel.name) + const list = families.get(family) + if (list) list.push(channel) + else families.set(family, [channel]) + } + + for (const [family, channels] of families) { + if (channels.length < 2) continue + const expectedGate = dominant(channels.map((channel) => channel.gate)) + const expectedRequires = dominant(channels.map((channel) => channel.requires)) + for (const channel of channels) { + const deviations: string[] = [] + if (channel.gate !== expectedGate) { + deviations.push(`gate '${channel.gate}' (family uses '${expectedGate}')`) + } + if (channel.requires !== expectedRequires) { + const shown = channel.requires === '' ? 'no requires' : `requires '${channel.requires}'` + const expected = expectedRequires === '' ? 'no requires' : `'${expectedRequires}'` + deviations.push(`${shown} (family uses ${expected})`) + } + if (deviations.length === 0) continue + if (channel.deviationReason === null) { + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' departs from the ` + + `${family}: family — ${deviations.join(', ')}. If deliberate, give it a ` + + '`deviationReason` stating why.' + ) + } else if (channel.deviationReason === '') { + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' has an empty ` + + '`deviationReason` — state why the deviation is correct.' + ) + } + } + } + + if (failures.length > 0) { + console.error('Desktop IPC contract audit failed:\n') + for (const failure of failures) console.error(` ✗ ${failure}`) + console.error(`\n${failures.length} problem(s).`) + process.exit(1) + } + + const exempt = declared.filter((channel) => channel.deviationReason !== null).length + console.log( + `Desktop IPC contract audit passed: ${declared.length} channels across ${families.size} ` + + `families, ${exempt} acknowledged deviation(s).` + ) +} + +await main()