diff --git a/.changeset/account-scoped-tool-policies.md b/.changeset/account-scoped-tool-policies.md new file mode 100644 index 0000000000..a258479d0a --- /dev/null +++ b/.changeset/account-scoped-tool-policies.md @@ -0,0 +1,5 @@ +--- +"@executor-js/react": patch +--- + +Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason. diff --git a/.changeset/connection-oauth-default.md b/.changeset/connection-oauth-default.md new file mode 100644 index 0000000000..35cfce58c2 --- /dev/null +++ b/.changeset/connection-oauth-default.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md new file mode 100644 index 0000000000..a8e83972b8 --- /dev/null +++ b/.changeset/fair-admin-integrations.md @@ -0,0 +1,5 @@ +--- +"@executor-js/react": patch +--- + +Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. diff --git a/.changeset/oauth-discovered-scope-budget.md b/.changeset/oauth-discovered-scope-budget.md new file mode 100644 index 0000000000..c71d10c41e --- /dev/null +++ b/.changeset/oauth-discovered-scope-budget.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy". diff --git a/.changeset/quiet-connection-setup.md b/.changeset/quiet-connection-setup.md new file mode 100644 index 0000000000..9d75373928 --- /dev/null +++ b/.changeset/quiet-connection-setup.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. diff --git a/.changeset/toolkit-list-scope.md b/.changeset/toolkit-list-scope.md new file mode 100644 index 0000000000..2af6981f88 --- /dev/null +++ b/.changeset/toolkit-list-scope.md @@ -0,0 +1,6 @@ +--- +"@executor-js/sdk": patch +"@executor-js/plugin-toolkits": patch +--- + +Toolkit sessions no longer walk the whole workspace catalog on connect, search, or describe: the toolkit's access patterns narrow the tool rows core reads. Tools reads no longer wait on re-listing catalogs that are only older than the freshness TTL; those rebuild in the background while the read answers from the persisted rows. Stale-marked and config-revised catalogs still gate the read within the grace budget. diff --git a/.github/scripts/check-database-capacity.py b/.github/scripts/check-database-capacity.py new file mode 100644 index 0000000000..08ea5b6fef --- /dev/null +++ b/.github/scripts/check-database-capacity.py @@ -0,0 +1,59 @@ +"""Read aggregate connection capacity with libpq; never print credentials or SQL data.""" + +import json +import os +import subprocess +import sys +from urllib.parse import unquote, urlparse + + +def main() -> int: + try: + url = urlparse(os.environ["DATABASE_URL"]) + if url.scheme not in ("postgres", "postgresql") or not url.hostname: + raise ValueError("Invalid database URL") + if url.hostname.endswith(".psdb.cloud") and url.port not in (None, 5432): + raise ValueError("Capacity checks require the direct endpoint") + env = { + **os.environ, + "PGHOST": url.hostname, + "PGPORT": str(url.port or 5432), + "PGUSER": unquote(url.username or ""), + "PGPASSWORD": unquote(url.password or ""), + "PGDATABASE": unquote(url.path.removeprefix("/")), + "PGSSLMODE": "require", + "PGCONNECT_TIMEOUT": "10", + "PGAPPNAME": "database-capacity-check", + "PGOPTIONS": "-c default_transaction_read_only=on -c statement_timeout=10000", + } + result = subprocess.run( + ["psql", "-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-c", """ + SELECT json_build_object( + 'limit', current_setting('max_connections')::int, + 'reserved', current_setting('superuser_reserved_connections')::int + + current_setting('reserved_connections')::int, + 'used', count(*)::int + ) FROM pg_stat_activity WHERE backend_type = 'client backend' + """], + env=env, + capture_output=True, + text=True, + timeout=25, + check=True, + ) + capacity = json.loads(result.stdout) + if any(type(capacity[key]) is not int for key in ("limit", "reserved", "used")): + raise ValueError("Invalid capacity response") + free = capacity["limit"] - capacity["reserved"] - capacity["used"] + print(json.dumps({**capacity, "ordinary_free": free, "minimum_free": 10})) + if free < 10: + print("::error::Database connection headroom is below 10 slots. Inspect direct clients and the PgBouncer budget.") + return 1 + return 0 + except (KeyError, ValueError, OSError, subprocess.SubprocessError): + print("::error::Database capacity check failed. Check direct endpoint access and provider health.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/database-capacity.yml b/.github/workflows/database-capacity.yml new file mode 100644 index 0000000000..d0610eff43 --- /dev/null +++ b/.github/workflows/database-capacity.yml @@ -0,0 +1,27 @@ +name: Database capacity + +on: + schedule: + - cron: "2-57/5 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: database-capacity + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 2 + environment: production + steps: + - uses: actions/checkout@v4 + # psql is supplied by the Ubuntu runner image. A failed check uses the + # repository's Actions failure notifications; no customer data is logged. + - name: Check ordinary connection headroom + run: python3 .github/scripts/check-database-capacity.py + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index e42832181c..4c9a998383 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -206,6 +206,37 @@ jobs: run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts working-directory: apps/desktop + # electron-updater installs from the zip, not the DMG, and Squirrel.Mac + # rejects it unless the extracted app passes codesign. 1.6.9 shipped a + # zip whose framework symlinks (Versions/Current -> A) had been expanded + # into copies by a 7-Zip upgrade inside electron-builder; the DMG was + # fine, every auto-update silently failed. Extract the zip the way + # Squirrel does and verify it before anything is uploaded. + - name: Verify mac update zip + if: matrix.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + run: | + set -euo pipefail + zip="apps/desktop/dist/executor-desktop-mac-${{ matrix.arch }}.zip" + links=$(unzip -Z "$zip" | grep -c '^l' || true) + echo "symlink entries in $zip: $links" + if [ "$links" -eq 0 ]; then + echo "::error::$zip has no symlink entries; framework bundles were flattened and Squirrel.Mac will reject the update" + exit 1 + fi + # Unsigned builds (forks, no CSC_LINK) cannot pass codesign; the + # symlink check above still catches the flattening on its own. + if [ -z "${CSC_LINK:-}" ]; then + echo "no signing certificate configured; skipping codesign verification" + exit 0 + fi + tmp=$(mktemp -d) + ditto -x -k "$zip" "$tmp" + codesign --verify --deep --strict --verbose=1 "$tmp/Executor.app" + rm -rf "$tmp" + # The two mac legs each emit a latest-mac.yml listing only their own # arch. Rename per-arch here; the release job merges them back into the # single latest-mac.yml electron-updater clients fetch. Without this, diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 1d6806e6aa..7d64d39efb 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,17 @@ # executor +## 1.6.10 + +### Patch Changes + +- [#2044](https://github.com/UsefulSoftwareCo/executor/pull/2044) [`004024b`](https://github.com/UsefulSoftwareCo/executor/commit/004024b453e9ba07317d2893f050a0d6dae6a67b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` to the self-host. Better Auth 1.6.17 and later enforce sign-in rate limits strictly in production, and with no trusted proxy header every caller shares one bucket of three sign-ins per ten seconds. The Docker release gate signs in from many test files at once and tripped it. The flag is off by default; the e2e harness sets it for the image it tests. + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/local@1.6.10 + - @executor-js/api@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 8f96399565..f298ce7c4c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.6.9", + "version": "1.6.10", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 407785932d..691c2e786b 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/cloud +## 1.4.71 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/vite-plugin@0.0.70 + - @executor-js/cloudflare@0.0.52 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.73 + ## 1.4.70 ### Patch Changes diff --git a/apps/cloud/docs/database-connections.md b/apps/cloud/docs/database-connections.md new file mode 100644 index 0000000000..e0304c89e6 --- /dev/null +++ b/apps/cloud/docs/database-connections.md @@ -0,0 +1,46 @@ +# Production database connections + +Application traffic uses Hyperdrive, then PlanetScale's local transaction-mode +PgBouncer on port 6432. Deployment scripts use the direct endpoint on port 5432. +Code migrations hold session advisory locks, so they must bypass transaction pooling. + +The connection budget is: + +| Setting | Value | +| ----------------------------------------- | --------------- | +| PostgreSQL max_connections | 50 | +| PostgreSQL superuser_reserved_connections | 3 | +| Local PgBouncer processes | 1 | +| PgBouncer default_pool_size | 20 | +| PgBouncer max_db_connections | 20 | +| PgBouncer max_client_conn | 400 | +| PgBouncer max_prepared_statements | 200 | +| Hyperdrive origin connection limit | 20 (soft limit) | + +Hyperdrive's origin limit is advisory. PgBouncer's database limit enforces the +backend budget across users of one database. The cap is per PgBouncer process: +adding processes, databases, direct clients, or other poolers requires a new +aggregate budget. Keep capacity for provider sessions, deploys and administration. +The 20-connection application budget leaves 27 ordinary slots for those clients +after the three superuser-reserved slots. This is a concurrency ceiling, not a +target for active queries; check CPU, queue waits and latency before raising it. +Prepared statements require protocol-level support to remain enabled in PgBouncer. + +The migration and membership-readiness scripts retry only the initial `SELECT 1` +when PostgreSQL returns SQLSTATE `53300`. They make at most seven attempts, with +ten seconds between attempts and a ten-second connection timeout. They never +retry migration bodies or readiness mutations. Other errors fail immediately. + +The Database capacity workflow checks direct access and aggregate connection +headroom every five minutes. It fails when fewer than ten ordinary slots remain. +Counts include the monitor and conservatively count privileged client sessions +against ordinary capacity. GitHub schedule delays and notification preferences +apply; this is not a real-time paging service. Check PlanetScale CPU and PgBouncer +waiting clients alongside Cloudflare query errors and latency during load spikes. + +For a routing change, first account for overlapping old and new pools. Verify +the active PostgreSQL limit and applied pool settings before changing Hyperdrive. +Afterward, check an authenticated application page, direct database access, +backend counts, and provider errors. Roll back by restoring the prior Hyperdrive +origin port only while there is capacity for both pools. Do not kill idle sessions +as routine maintenance: clients can reconnect and consume the slots again. diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 3c1dde605f..6bf5a8c91a 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.70", + "version": "1.4.71", "private": true, "type": "module", "scripts": { diff --git a/apps/cloud/scripts/database-connection.ts b/apps/cloud/scripts/database-connection.ts new file mode 100644 index 0000000000..d3f725b842 --- /dev/null +++ b/apps/cloud/scripts/database-connection.ts @@ -0,0 +1,56 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: deployment CLI connection acquisition */ + +import { setTimeout } from "node:timers/promises"; + +const MAX_ATTEMPTS = 7; +const RETRY_DELAY_MS = 10_000; + +/** + * Validate the deploy transport without logging credentials. PlanetScale schema + * migrations use the direct endpoint because code migrations hold session locks. + */ +export const directDatabaseUrl = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("DATABASE_URL must be a valid PostgreSQL URL"); + } + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new Error("DATABASE_URL must use the postgres or postgresql protocol"); + } + if (url.hostname.endsWith(".psdb.cloud") && url.port !== "" && url.port !== "5432") { + throw new Error("PlanetScale deploy scripts require the direct endpoint on port 5432"); + } + return value; +}; + +/** + * Open the CLI's single connection before starting work. Retry only PostgreSQL + * admission failures (53300), at most six times with ten seconds between tries. + * The caller must set connect_timeout and close the client on every exit. + * Migration and readiness mutations remain outside this retry boundary. + */ +export const waitForDatabaseConnection = async ( + sql: { readonly unsafe: (query: string) => PromiseLike }, + options: { + readonly log: (message: string) => void; + readonly sleep?: (milliseconds: number) => Promise; + }, +): Promise => { + const sleep = options.sleep ?? ((milliseconds: number) => setTimeout(milliseconds)); + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + await sql.unsafe("SELECT 1"); + return; + } catch (cause) { + const isCapacityError = + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "53300"; + if (!isCapacityError || attempt === MAX_ATTEMPTS) throw cause; + options.log( + `Database connection capacity is full (53300). Retrying connection ${attempt}/${MAX_ATTEMPTS - 1} in 10s; no work has started.`, + ); + await sleep(RETRY_DELAY_MS); + } + } +}; diff --git a/apps/cloud/scripts/ensure-workos-mirror-ready.ts b/apps/cloud/scripts/ensure-workos-mirror-ready.ts index d10b826d3e..30794097e0 100644 --- a/apps/cloud/scripts/ensure-workos-mirror-ready.ts +++ b/apps/cloud/scripts/ensure-workos-mirror-ready.ts @@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { directDatabaseUrl, waitForDatabaseConnection } from "./database-connection"; import { MirrorReadinessState, @@ -56,9 +57,10 @@ if (!connectionString) { const usesLocalDatabase = connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); -const sql = postgres(connectionString, { +const sql = postgres(directDatabaseUrl(connectionString), { max: 1, prepare: false, + connect_timeout: 10, ...(usesLocalDatabase ? {} : { ssl: "require" as const }), }); const db = drizzle(sql); @@ -84,6 +86,7 @@ const runScript = (what: string, script: string) => { }; try { + await waitForDatabaseConnection(sql, { log }); let state = await readiness(); log(describeMirrorReadiness(state)); diff --git a/apps/cloud/scripts/migrate.ts b/apps/cloud/scripts/migrate.ts index 9466a049b5..f610f3df9c 100644 --- a/apps/cloud/scripts/migrate.ts +++ b/apps/cloud/scripts/migrate.ts @@ -8,6 +8,7 @@ import { migrate as migrateDrizzle } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { cloudCodeMigrations, runCodeMigrations } from "./code-migrations/index"; +import { directDatabaseUrl, waitForDatabaseConnection } from "./database-connection"; const __dirname = dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); @@ -41,13 +42,15 @@ if (!connectionString) { const usesLocalDatabase = connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); -const sql = postgres(connectionString, { +const sql = postgres(directDatabaseUrl(connectionString), { max: 1, prepare: false, + connect_timeout: 10, ...(usesLocalDatabase ? {} : { ssl: "require" as const }), }); try { + await waitForDatabaseConnection(sql, { log: console.log }); if (!codeOnly) { if (dryRun) { console.log("[schema-migrate] dry run: Drizzle SQL migrations are not applied"); diff --git a/apps/cloud/src/db/deployment-connection.test.ts b/apps/cloud/src/db/deployment-connection.test.ts new file mode 100644 index 0000000000..015b88db81 --- /dev/null +++ b/apps/cloud/src/db/deployment-connection.test.ts @@ -0,0 +1,93 @@ +/* oxlint-disable executor/no-promise-reject -- boundary: simulate the Postgres.js driver's rejected promises */ + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { directDatabaseUrl, waitForDatabaseConnection } from "../../scripts/database-connection"; + +describe("deployment database connection", () => { + it.effect("waits for admission before allowing deployment work", () => + Effect.promise(async () => { + let remainingFailures = 2; + const waits: number[] = []; + const logs: string[] = []; + const queries: string[] = []; + await waitForDatabaseConnection( + { + unsafe: (query) => { + queries.push(query); + return remainingFailures-- > 0 + ? Promise.reject({ code: "53300", detail: "private connection data" }) + : Promise.resolve([]); + }, + }, + { + log: (line) => logs.push(line), + sleep: async (ms) => { + waits.push(ms); + }, + }, + ); + expect(queries).toEqual(["SELECT 1", "SELECT 1", "SELECT 1"]); + expect(waits).toEqual([10_000, 10_000]); + expect(logs).toHaveLength(2); + expect(logs.join()).not.toContain("private connection data"); + }), + ); + + it.effect("fails after the bounded admission budget", () => + Effect.promise(async () => { + const failure = { code: "53300" }; + let attempts = 0; + const waits: number[] = []; + await expect( + waitForDatabaseConnection( + { + unsafe: () => { + attempts += 1; + return Promise.reject(failure); + }, + }, + { + log: () => {}, + sleep: async (ms) => { + waits.push(ms); + }, + }, + ), + ).rejects.toBe(failure); + expect(attempts).toBe(7); + expect(waits).toEqual(Array(6).fill(10_000)); + }), + ); + + it.effect("fails immediately for authentication, transport and SQL errors", () => + Effect.promise(async () => { + for (const code of ["28P01", "CONNECT_TIMEOUT", "CONNECTION_CLOSED", "42601", "40001"]) { + const failure = { code }; + const waits: number[] = []; + await expect( + waitForDatabaseConnection( + { unsafe: () => Promise.reject(failure) }, + { + log: () => {}, + sleep: async (ms) => { + waits.push(ms); + }, + }, + ), + ).rejects.toBe(failure); + expect(waits).toEqual([]); + } + }), + ); + + it("keeps PlanetScale deployment traffic on the direct endpoint", () => { + const direct = "postgres://example:secret@region.pg.psdb.cloud:5432/database"; + expect(directDatabaseUrl(direct)).toBe(direct); + expect(directDatabaseUrl("postgres://localhost:25432/postgres")).toContain(":25432"); + expect(() => directDatabaseUrl(direct.replace(":5432", ":6432"))).toThrow("direct endpoint"); + expect(() => directDatabaseUrl("invalid-secret")).toThrow("valid PostgreSQL URL"); + expect(() => directDatabaseUrl("https://localhost/database")).toThrow("protocol"); + }); +}); diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index 01d54c3530..28c1dd5bcf 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/desktop +## 1.6.10 + +### Patch Changes + +- [#2049](https://github.com/UsefulSoftwareCo/executor/pull/2049) [`dc0808d`](https://github.com/UsefulSoftwareCo/executor/commit/dc0808d5ca9716d73e4def9365f49d4c1afd4af9) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix macOS auto-update. The 1.6.9 update zip was built with a 7-Zip that + expanded the framework symlinks into copies, so the extracted app failed code + signing and Squirrel.Mac silently refused to install it; "Restart to update" + appeared to do nothing. electron-builder is bumped to a release that preserves + symlinks, the publish job now verifies the zip's signature before uploading, + and a rejected install surfaces as "Update failed" instead of leaving the card + untouched. + ## 1.6.9 ## 1.6.8 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 47faabf240..0c17148dd4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.6.9", + "version": "1.6.10", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor", "license": "MIT", @@ -48,7 +48,7 @@ "@zip.js/zip.js": "^2.8.26", "bun-types": "catalog:", "electron": "41.10.3", - "electron-builder": "^26", + "electron-builder": "26.16.1", "electron-vite": "^5", "quickjs-emscripten": "catalog:", "typescript": "catalog:", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 98f9ec7b88..0168684676 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { app, + autoUpdater as nativeAutoUpdater, BrowserWindow, dialog, ipcMain, @@ -806,17 +807,11 @@ const registerIpcHandlers = () => { // Outside a packaged build there is no real bundle to swap, and quitting // would tear down the e2e harness — reflect "installing" so the renderer // can prove the wiring instead. - if (!app.isPackaged) { - setUpdateStatus({ state: "installing", version }); - return; - } - // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching - // the native dialog's restart path. - stopSupervisedMonitor(); - if (connection) { - await stopConnection(connection); - connection = null; - } + setUpdateStatus({ state: "installing", version }); + if (!app.isPackaged) return; + // Squirrel.Mac only validates the staged bundle now; the sidecar is torn + // down in 'before-quit-for-update' once it has accepted the update, so a + // rejected zip leaves the app usable and surfaces via the 'error' handler. autoUpdater.quitAndInstall(false, true); }); // Crash-screen last resort for damaged state: confirm, move the data dir @@ -937,13 +932,7 @@ const promptInstallUpdate = async (version: string) => { cancelId: 1, }); if (response.response === 0) { - // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A - // supervised daemon is left running — it's independent of this bundle. - stopSupervisedMonitor(); - if (connection) { - await stopConnection(connection); - connection = null; - } + setUpdateStatus({ state: "installing", version }); autoUpdater.quitAndInstall(false, true); return; } @@ -963,6 +952,18 @@ const setupAutoUpdater = () => { autoUpdater.logger = log; autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = false; + // Fired by Electron's native updater once Squirrel.Mac has downloaded and + // validated the bundle and is about to quit for the swap. Stop a spawned + // sidecar here rather than before quitAndInstall: if Squirrel rejects the + // update (bad signature, corrupt zip) nothing has been torn down. A + // supervised daemon is left running — it's independent of this bundle. + nativeAutoUpdater.on("before-quit-for-update", () => { + stopSupervisedMonitor(); + if (connection) { + void stopConnection(connection); + connection = null; + } + }); autoUpdater.on("update-available", (info: UpdateInfo) => { pendingUpdateVersion = info.version; diff --git a/apps/desktop/src/main/updater-state.test.ts b/apps/desktop/src/main/updater-state.test.ts index 9809f759cb..388413966d 100644 --- a/apps/desktop/src/main/updater-state.test.ts +++ b/apps/desktop/src/main/updater-state.test.ts @@ -79,6 +79,15 @@ describe("updater state decisions", () => { }); }); + it("moves a staged or installing update to error when Squirrel rejects it", () => { + expect( + statusAfterUpdateError({ state: "downloaded", version: "1.6.9" }, "Update failed"), + ).toEqual({ state: "error", version: "1.6.9", message: "Update failed" }); + expect( + statusAfterUpdateError({ state: "installing", version: "1.6.9" }, "Update failed"), + ).toEqual({ state: "error", version: "1.6.9", message: "Update failed" }); + }); + it("restores autoInstallOnAppQuit only when the fatal path recovers", () => { expect( planFatalAutoInstallOnQuit({ diff --git a/apps/desktop/src/main/updater-state.ts b/apps/desktop/src/main/updater-state.ts index 1739337006..9f3c209a0c 100644 --- a/apps/desktop/src/main/updater-state.ts +++ b/apps/desktop/src/main/updater-state.ts @@ -89,14 +89,17 @@ export const planDownloadedUpdate = (input: DownloadedUpdateInput): DownloadedUp }; }; +// Any state that names a version is an update in flight, including a staged +// ("downloaded") or installing one: Squirrel.Mac validates the bundle only when +// the install starts, so a rejected zip surfaces as an error *after* the card +// already offered "Restart to update". Dropping that error left the card +// unchanged and the click looked like a no-op. export const statusAfterUpdateError = ( status: DesktopUpdateStatus, message: string, ): DesktopUpdateStatus => { - if (status.state === "available" || status.state === "downloading" || status.state === "error") { - return { state: "error", version: status.version, message }; - } - return status; + if (status.state === "idle") return status; + return { state: "error", version: status.version, message }; }; export const planFatalAutoInstallOnQuit = (input: { diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 8fc30f0e85..807951b07f 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -69,6 +69,7 @@ the container defaults. | `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | | `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 1eb13376a6..3e3296c884 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -36,6 +36,12 @@ # default — adversarial generated code should not reach your internal network. # EXECUTOR_ALLOW_LOCAL_NETWORK=false +# --- Auth rate limiting ------------------------------------------------------- +# Sign-in attempts are rate-limited per client IP. Without a trusted proxy +# header every caller shares one bucket. Set the exact string "true" only when +# something in front of Executor rate-limits instead. +# EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false + # --- Local stdio MCP (trusted deployments only) ------------------------------- # Stdio MCP is disabled unless this is explicitly set to the exact string # "true". Enabling it lets users configure MCP servers whose commands execute diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index 222a2029f8..e4d5e8b9ba 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,5 +1,28 @@ # @executor-js/host-selfhost +## 0.0.52 + +### Patch Changes + +- [#2044](https://github.com/UsefulSoftwareCo/executor/pull/2044) [`004024b`](https://github.com/UsefulSoftwareCo/executor/commit/004024b453e9ba07317d2893f050a0d6dae6a67b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` to the self-host. Better Auth 1.6.17 and later enforce sign-in rate limits strictly in production, and with no trusted proxy header every caller shares one bucket of three sign-ins per ten seconds. The Docker release gate signs in from many test files at once and tripped it. The flag is off by default; the e2e harness sets it for the image it tests. + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.17 + - @executor-js/api@1.4.73 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/plugin-encrypted-secrets@0.0.52 + - @executor-js/plugin-provider-service-split@0.0.24 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/react@1.4.73 + ## 0.0.51 ### Patch Changes diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index d91eaae8bf..0f9c804248 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 1d714312fd..4031fdf273 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -130,6 +130,10 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: baseURL: config.webBaseUrl, trustedOrigins: [...config.trustedOrigins], advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, + // Better Auth's own limiter is on in production and off in development. + // Only an explicit opt-out is passed through, so that environment default + // stays in charge everywhere else. + ...(config.authRateLimit ? {} : { rateLimit: { enabled: false } }), emailAndPassword: { enabled: true }, // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index ab443f3bf3..07dcc56d14 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -54,6 +54,15 @@ export interface SelfHostConfig { * internal network unless an operator opts in. */ readonly allowLocalNetwork: boolean; + /** + * Whether Better Auth rate-limits its own endpoints (sign-in and friends). + * Better Auth turns this on in production and keys the limit on the client + * IP it reads from a trusted proxy header. With no such header every caller + * shares one bucket, so an operator who rate-limits upstream, or an + * automated suite that signs in far faster than a person, turns it off with + * `EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true`. + */ + readonly authRateLimit: boolean; // Better Auth session secret. Always resolved (env, else generated + persisted // under the data dir) so a single-container deploy boots with no env; the auth // layer still validates an explicitly-set env secret is long enough. @@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { webBaseUrl, trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 313d097b85..576b93820c 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -9,6 +9,8 @@ const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; const originalValue = process.env[ENV_NAME]; const originalSecret = process.env[SECRET_ENV_NAME]; const originalTtl = process.env[TTL_ENV_NAME]; +const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT"; +const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -30,6 +32,11 @@ afterEach(() => { } else { process.env[TTL_ENV_NAME] = originalTtl; } + if (originalRateLimit === undefined) { + delete process.env[RATE_LIMIT_ENV_NAME]; + } else { + process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit; + } }); const allowStdio = (): boolean => { @@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { process.env[TTL_ENV_NAME] = "-1"; expect(() => loadConfig()).toThrow(/must not be negative/); }); + +test("auth rate limiting stays on unless the opt-out is exactly true", () => { + delete process.env[RATE_LIMIT_ENV_NAME]; + expect(loadConfig().authRateLimit).toBe(true); + process.env[RATE_LIMIT_ENV_NAME] = "TRUE"; + expect(loadConfig().authRateLimit).toBe(true); +}); + +test("auth rate limiting is off when the opt-out is exactly true", () => { + process.env[RATE_LIMIT_ENV_NAME] = "true"; + expect(loadConfig().authRateLimit).toBe(false); +}); diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md index a13966fc3a..54955cdca3 100644 --- a/apps/local/CHANGELOG.md +++ b/apps/local/CHANGELOG.md @@ -1,5 +1,32 @@ # @executor-js/local +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/plugin-file-secrets@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-keychain@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-onepassword@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/plugin-example@1.6.10 + - @executor-js/plugin-desktop-settings@1.6.10 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.17 + - @executor-js/api@1.4.73 + - @executor-js/vite-plugin@0.0.70 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/plugin-provider-service-split@0.0.24 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/apps/local/package.json b/apps/local/package.json index c1df3307be..138f4cc37f 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/local", - "version": "1.6.9", + "version": "1.6.10", "private": true, "type": "module", "exports": { diff --git a/bun.lock b/bun.lock index 02fa988dae..87589fabdd 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.6.9", + "version": "1.6.10", "bin": { "executor": "./bin/executor.ts", }, @@ -60,7 +60,7 @@ }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.70", + "version": "1.4.71", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -133,7 +133,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@sentry/bun": "^10.57.0", "@sentry/electron": "7.19.0", @@ -161,7 +161,7 @@ "@zip.js/zip.js": "^2.8.26", "bun-types": "catalog:", "electron": "41.10.3", - "electron-builder": "^26", + "electron-builder": "26.16.1", "electron-vite": "^5", "quickjs-emscripten": "catalog:", "typescript": "catalog:", @@ -220,7 +220,7 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@better-auth/api-key": "^1.6.11", "@cloudflare/worker-bundler": "0.2.1", @@ -273,7 +273,7 @@ }, "apps/local": { "name": "@executor-js/local", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", @@ -353,7 +353,7 @@ }, "e2e": { "name": "@executor-js/e2e", - "version": "0.0.49", + "version": "0.0.50", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/emulate": "^0.14.2", @@ -388,7 +388,7 @@ }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.70", + "version": "0.0.71", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -407,7 +407,7 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.55", + "version": "0.0.56", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -464,7 +464,7 @@ }, "packages/core/analytics": { "name": "@executor-js/analytics", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/execution": "workspace:*", @@ -480,7 +480,7 @@ }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.72", + "version": "1.4.73", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -497,7 +497,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.59", + "version": "0.2.60", "bin": { "executor-sdk": "./dist/index.js", }, @@ -518,7 +518,7 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -539,7 +539,7 @@ }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -605,7 +605,7 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/fumadb": "workspace:*", "@standard-schema/spec": "^1.1.0", @@ -658,7 +658,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.69", + "version": "0.0.70", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -678,7 +678,7 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", @@ -719,7 +719,7 @@ }, "packages/hosts/mcp-apps-shell": { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.20", + "version": "1.4.21", "dependencies": { "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -757,7 +757,7 @@ }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", @@ -830,7 +830,7 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/codemode-core": "workspace:*", "quickjs-emscripten": "catalog:", @@ -850,7 +850,7 @@ }, "packages/kernel/runtime-workerd-subprocess": { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.24", + "version": "0.0.25", "dependencies": { "@executor-js/codemode-core": "workspace:*", "effect": "catalog:", @@ -865,7 +865,7 @@ }, "packages/onboarding-demo": { "name": "@executor-js/onboarding-demo", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -889,7 +889,7 @@ }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -902,7 +902,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -917,7 +917,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -940,7 +940,7 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -957,7 +957,7 @@ }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -996,7 +996,7 @@ }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", @@ -1015,7 +1015,7 @@ }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", @@ -1059,7 +1059,7 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", @@ -1092,7 +1092,7 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -1133,7 +1133,7 @@ }, "packages/plugins/provider-service-split": { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -1150,7 +1150,7 @@ }, "packages/plugins/toolkits": { "name": "@executor-js/plugin-toolkits", - "version": "1.5.44", + "version": "1.5.45", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -1219,7 +1219,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.72", + "version": "1.4.73", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", @@ -1687,7 +1687,7 @@ "@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.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="], + "@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "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-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], "@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=="], @@ -2229,10 +2229,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], - - "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], - "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], @@ -3309,7 +3305,7 @@ "@zip.js/zip.js": ["@zip.js/zip.js@2.8.26", "", {}, "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA=="], - "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + "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=="], @@ -3357,7 +3353,7 @@ "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - "app-builder-lib": ["app-builder-lib@26.15.0", "", { "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.3", "@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.0", "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.0", "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.0", "electron-builder-squirrel-windows": "26.15.0" } }, "sha512-j2+P6Lh+l/VuWfXZWSs7u+OAPqYJQGnZZO30M833XQQaRuyohm4RZk7Gw4nQXfeyQH9GqXaTwR16Y0LaVTlS+g=="], + "app-builder-lib": ["app-builder-lib@26.16.1", "", { "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": "^1.8.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.16.0", "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.16.0", "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.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="], "app-root-path": ["app-root-path@3.1.0", "", {}, "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA=="], @@ -3485,7 +3481,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "builder-util": ["builder-util@26.15.0", "", { "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-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="], + "builder-util": ["builder-util@26.16.0", "", { "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-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], @@ -3513,8 +3509,6 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], - "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=="], @@ -3787,8 +3781,6 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -3831,7 +3823,7 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "dmg-builder": ["dmg-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-oS8MWttbpIUF/2v8LOEY+f4ayL84ipMOarZvdRMl/pxlhLxAYjYMklTXHEXIl37Ig+qJv/bVF7HgyIoOoZyMWA=="], + "dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="], "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], @@ -3875,13 +3867,13 @@ "electron": ["electron@41.10.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js" } }, "sha512-MJuSODPw8siv/I8JjhctW/cS/XNldwI4gLRyyWZx6QkoZJUDgbEvitp7IVOnGrHENTQb6Udo+zMpKhFnhlIhdg=="], - "electron-builder": ["electron-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.0", "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-zd4cfvjHmtyGqMaDudg5rAjNUkwIJDz8ICaCsz77hFKcjMQHcZNNNCs/C4phwN9+gEVwmhvpKMzNFum6fs/n6A=="], + "electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "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-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="], "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], "electron-log": ["electron-log@5.4.3", "", {}, "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ=="], - "electron-publish": ["electron-publish@26.15.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "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-pt6K3ol/a+o3HbqmYkL2NYlVH5pd34tL4FPRcgX8E88xQAqQyIsseXe4vWy7Pq2BaYy+iFGJrtInZe11FFAQwQ=="], + "electron-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "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-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="], "electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="], @@ -4113,8 +4105,6 @@ "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=="], - "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4285,8 +4275,6 @@ "import-in-the-middle": ["import-in-the-middle@3.0.2", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -4593,8 +4581,6 @@ "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], - "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], - "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -4757,16 +4743,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], - - "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], - - "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], - - "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], - - "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], @@ -4831,7 +4807,7 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + "node-gyp": ["node-gyp@12.3.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-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], @@ -4841,7 +4817,7 @@ "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], - "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + "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=="], @@ -5061,7 +5037,7 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "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=="], @@ -5417,10 +5393,6 @@ "smol-toml": ["smol-toml@1.7.1", "", {}, "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ=="], - "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], - - "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], "solid-transition-group": ["solid-transition-group@0.2.3", "", { "dependencies": { "@solid-primitives/refs": "^1.0.5", "@solid-primitives/transition-group": "^1.0.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg=="], @@ -5453,8 +5425,6 @@ "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -5675,10 +5645,6 @@ "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], - "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], - - "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], - "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], @@ -5765,8 +5731,6 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -5967,11 +5931,7 @@ "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], - "@electron/rebuild/node-abi": ["node-abi@4.35.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q=="], - - "@electron/rebuild/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - - "@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@electron/rebuild/node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -6119,12 +6079,6 @@ "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@opentelemetry/configuration/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], @@ -6483,6 +6437,8 @@ "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/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -6541,8 +6497,6 @@ "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - "cacache/p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], - "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "cheerio/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -6579,8 +6533,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -6601,6 +6553,8 @@ "electron-builder/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=="], + "electron-builder/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + "electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@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.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "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.8.1", "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.0.3", "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", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], "electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "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-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], @@ -6737,12 +6691,6 @@ "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6755,7 +6703,9 @@ "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "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=="], @@ -6843,10 +6793,6 @@ "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "socks/ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], - - "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "solid-js/seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -7105,15 +7051,7 @@ "@electron/osx-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "@electron/rebuild/ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "@electron/rebuild/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "@electron/rebuild/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - - "@electron/rebuild/ora/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "@electron/rebuild/ora/log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "@electron/rebuild/node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -7619,8 +7557,6 @@ "electron-builder-squirrel-windows/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=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "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-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], - "electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], "electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -7655,6 +7591,10 @@ "electron-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "electron-builder/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=="], + + "electron-builder/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -7809,15 +7749,9 @@ "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -7967,12 +7901,6 @@ "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@electron/rebuild/ora/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@electron/rebuild/ora/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@electron/rebuild/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@executor-js/motel/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], @@ -8031,10 +7959,6 @@ "electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp": ["node-gyp@12.3.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-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], - "electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -8057,6 +7981,8 @@ "electron-builder-squirrel-windows/builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "electron-builder/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -8085,8 +8011,6 @@ "typeorm/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@electron/rebuild/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], @@ -8103,16 +8027,6 @@ "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -8123,10 +8037,6 @@ "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "temp/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md index 625d796924..aeb68f0a64 100644 --- a/e2e/CHANGELOG.md +++ b/e2e/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/e2e +## 0.0.50 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/plugin-toolkits@1.5.45 + ## 0.0.49 ### Patch Changes diff --git a/e2e/cloud/integration-creation-permissions.test.ts b/e2e/cloud/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..44f7b91bb5 --- /dev/null +++ b/e2e/cloud/integration-creation-permissions.test.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { forBrowser, joinOrg } from "./support/session"; + +scenario( + "Integration creation · cloud members see admin guidance and admins can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member)); + }), +); diff --git a/e2e/package.json b/e2e/package.json index 2671745d08..10f9a8e40f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/e2e", - "version": "0.0.49", + "version": "0.0.50", "private": true, "type": "module", "scripts": { diff --git a/e2e/scenarios/connection-setup-ux.test.ts b/e2e/scenarios/connection-setup-ux.test.ts new file mode 100644 index 0000000000..fa87ea93d6 --- /dev/null +++ b/e2e/scenarios/connection-setup-ux.test.ts @@ -0,0 +1,290 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { hydrated, visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +// Each journey has its own real provider state, OAuth app, user and integration. +const connectionFixture = (registerClient: boolean) => + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`setup-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + const baseUrl = yield* createEmulatorInstance("slack", "connection-setup"); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + const credential = yield* Effect.promise(() => + emulator.credentials.mint({ + type: "oauth-authorization-code", + redirect_uris: [new URL("/api/oauth/callback", target.baseUrl).toString()], + }), + ); + const { + client_id: clientId, + client_secret: clientSecret, + authorization_url: authorizationUrl, + token_url: tokenUrl, + } = credential; + if (!clientId || !clientSecret || !authorizationUrl || !tokenUrl) { + return yield* Effect.die("Slack emulator did not mint an OAuth app"); + } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const connections = yield* client.connections.list({ query: { integration: slug } }); + for (const connection of connections) { + yield* client.connections + .remove({ + params: { + owner: connection.owner, + integration: slug, + name: connection.name, + }, + }) + .pipe(Effect.ignore); + } + yield* client.oauth + .removeClient({ params: { slug: app }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }).pipe(Effect.ignore), + ); + yield* client.openapi.addSpec({ + payload: { + slug, + name: "Team chat", + baseUrl: "https://slack.com", + displayDomain: "slack.com", + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Team chat", version: "1" }, + // No API operations: only the isolated emulator receives OAuth traffic. + servers: [{ url: "https://slack.com" }], + paths: {}, + }), + }, + authenticationTemplate: [ + { + slug: "token", + type: "apiKey", + headers: { Authorization: ["Bearer ", variable("token")] }, + }, + { + slug: "oauth", + kind: "oauth2", + // The unconfigured case tests metadata only, without contacting a + // provider. A unique reserved host cannot match another test's app. + authorizationUrl: registerClient + ? authorizationUrl + : `https://${slug}.invalid/authorize`, + tokenUrl: registerClient ? tokenUrl : `https://${slug}.invalid/token`, + scopes: ["users:read"], + }, + ], + }, + }); + if (registerClient) + yield* client.oauth.createClient({ + payload: { + slug: app, + owner: "org", + grant: "authorization_code", + clientId, + clientSecret, + authorizationUrl, + tokenUrl, + originIntegration: slug, + }, + }); + return { target, browser, identity, client, slug, emulator }; + }); +const fixture = connectionFixture(true); + +scenario( + "Slack OAuth · provider consent saves a connection", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug, client, emulator } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + await step("Sign in with a provider account without naming the connection", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + await page.getByRole("tab", { name: "OAuth2", exact: true }).click(); + const opened = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click(); + const popup = await opened; + await popup.waitForURL(/oauth\/v2\/authorize/); + // The hosted emulator renders a root-relative form action. Rebase only + // that provider transport onto this run's isolated instance. + await popup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) => + route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }), + ); + await popup.getByRole("button", { name: /admin/ }).click(); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 30_000 }); + }); + }); + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect(connections, "the completed callback persists the new account").toHaveLength(1); + expect(connections[0]?.name).toBeTruthy(); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + expect( + ledger.some((entry) => entry.method === "POST" && entry.path.includes("oauth.v2.access")), + "the real provider exchanged an authorization code", + ).toBe(true); + }), + ), +); + +scenario( + "Connection setup · without a matching client the API key stays the default", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* connectionFixture(false); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open an integration whose OAuth app has not been configured", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + expect( + await page + .getByRole("tab", { name: "API key (Authorization)", exact: true }) + .getAttribute("aria-selected"), + "declaring OAuth without a usable client must not replace the key form", + ).toBe("true"); + await page.getByRole("tab", { name: "OAuth2", exact: true }).click(); + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); + }); + }); + }), + ), +); + +for (const { interaction, expectedOAuth, expectedKeys } of [ + { interaction: "untouched", expectedOAuth: "true", expectedKeys: [] }, + { interaction: "select API key", expectedOAuth: "false", expectedKeys: [""] }, + { + interaction: "enter API key", + expectedOAuth: "false", + expectedKeys: ["synthetic-key-in-progress"], + }, +] as const) { + scenario( + `Connection setup · client list arrives with the form ${interaction}`, + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + const started = Promise.withResolvers(); + const released = Promise.withResolvers(); + const completed = Promise.withResolvers(); + await page.route(/\/api\/oauth\/clients(?:\?|$)/, async (route) => { + const response = await route.fetch(); + started.resolve(); + await released.promise; + await route.fulfill({ response }); + completed.resolve(); + }); + try { + await step("Open the dialog while the real OAuth client list is held", async () => { + await page.goto(`/integrations/${slug}?addAccount=1`, { + waitUntil: "domcontentloaded", + }); + await hydrated(page); + await started.promise; + const keyTab = page.getByRole("tab", { + name: "API key (Authorization)", + exact: true, + }); + await keyTab.waitFor(); + expect(await keyTab.getAttribute("aria-selected")).toBe("true"); + }); + await step( + "Resolve client availability without replacing a user's choice", + async () => { + const keyTab = page.getByRole("tab", { + name: "API key (Authorization)", + exact: true, + }); + const keyInput = page.getByRole("textbox", { name: "Authorization", exact: true }); + if (interaction === "select API key") await keyTab.click(); + if (interaction === "enter API key") + await keyInput.fill("synthetic-key-in-progress"); + released.resolve(); + await completed.promise; + await page.waitForLoadState("networkidle"); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + "only an untouched form should adopt the available OAuth client", + ).toBe(expectedOAuth); + expect( + await keyTab.getAttribute("aria-selected"), + "late data must preserve the chosen key form", + ).toBe(String(expectedOAuth === "false")); + expect( + await Promise.all((await keyInput.all()).map((input) => input.inputValue())), + "any key already entered must remain unchanged", + ).toEqual(expectedKeys); + }, + ); + } finally { + released.resolve(); + await page.unrouteAll({ behavior: "wait" }); + } + }); + }), + ), + ); +} + +scenario( + "Connection setup · a ready OAuth app is the default", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + await step("Add a connection with both a token and a registered sign-in app", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + await page.getByRole("tab", { name: "OAuth2", exact: true }).waitFor(); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + "sign-in is selected without first switching away from API token", + ).toBe("true"); + }); + await step("Choose an API key instead of browser sign-in", async () => { + const tokenTab = page.getByRole("tab", { name: "API key (Authorization)", exact: true }); + await tokenTab.click(); + expect(await tokenTab.getAttribute("aria-selected")).toBe("true"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("heading", { name: /Add connection/ }).waitFor({ state: "hidden" }); + }); + await step("Open a new connection on browser sign-in again", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + ).toBe("true"); + }); + }); + }), + ), +); diff --git a/e2e/scenarios/policies-ui.test.ts b/e2e/scenarios/policies-ui.test.ts index ad45864429..00509252f5 100644 --- a/e2e/scenarios/policies-ui.test.ts +++ b/e2e/scenarios/policies-ui.test.ts @@ -4,18 +4,23 @@ // the category (group) row menu writes a subtree rule. The product promises // under test: // -// 1. Both menus surface the REAL stored pattern (connection-wildcarded -// `integration.*.*.tool`) before anything is written. +// 1. Both menus surface the REAL stored pattern (pinned to the account the +// row sits under, `integration...tool`) before +// anything is written. // 2. A leaf rule and a category rule coexist: the more specific leaf rule // keeps precedence over the later category rule, which covers the rest // of its group. -// 3. Rules are connection-agnostic: set from one account's section, they -// govern the other account's rows too, and the menu there shows the -// active rule with a Clear option. -// 4. The tool detail header's policy badge is the same authoring surface: +// 3. Rules are account-scoped: set from one account's section, they leave +// the other account's rows untouched. Two connections of one +// integration are different credentials (a bot token and a user token), +// so blocking a tool on one must not block it on the other. +// 4. The account header has its own menu that rules the whole connection +// (`integration...*`), recognizes its rule, and +// clears it. +// 5. The tool detail header's policy badge is the same authoring surface: // it writes the same stored pattern, recognizes its own rule afterward // (the Clear affordance), and Clear really removes the rule. -// 5. The rules materialize as manageable rows on /policies and persist +// 6. The rules materialize as manageable rows on /policies and persist // server-side with exactly the owner/pattern/action the UI promised. import { randomBytes } from "node:crypto"; @@ -83,11 +88,12 @@ scenario( const beta = ConnectionName.make(`beta${suffix}`); const accounts = [alpha, beta] as const; - // The UI hides owner/connection segments; a rule authored on a node is - // stored connection-wildcarded so it spans every account. - const leafPattern = `${integration}.*.*.records.create`; - const categoryPattern = `${integration}.*.*.records.*`; - const listLeafPattern = `${integration}.*.*.records.list`; + // The UI hides owner/connection segments in the row labels, but a rule + // authored under an account section is stored pinned to that account. + const leafPattern = `${integration}.org.${alpha}.records.create`; + const categoryPattern = `${integration}.org.${alpha}.records.*`; + const listLeafPattern = `${integration}.org.${alpha}.records.list`; + const betaAccountPattern = `${integration}.org.${beta}.*`; // Selfhost scenarios share one workspace — remove everything this one // made (policies, connections, the integration) even on failure. @@ -159,6 +165,14 @@ scenario( .getByRole("button") .filter({ hasText: leaf }) .getByLabel(label, { exact: true }); + // Wait until a leaf's indicator with this label is gone (after a clear). + const expectNoIndicator = async (connection: string, leaf: string, label: string) => { + await expect + .poll(() => leafIndicator(connection, leaf, label).count(), { + message: `${connection} ${leaf} still shows "${label}"`, + }) + .toBe(0); + }; const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); await step("Open the integration's Tools tab", async () => { @@ -254,25 +268,58 @@ scenario( }, ); - await step("The same rules govern the second account's rows", async () => { + await step("The second account's rows are untouched", async () => { await closedGroup(beta, integration).click(); await closedGroup(beta, "records").click(); - await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor(); - await leafIndicator( - beta, - "list", - `Require approval (matched ${categoryPattern})`, - ).waitFor(); + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); + expect( + await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).count(), + "a rule set under one account does not block the same tool on another", + ).toBe(0); + expect( + await leafIndicator( + beta, + "list", + `Require approval (matched ${categoryPattern})`, + ).count(), + "a category rule set under one account does not reach another account", + ).toBe(0); }); await step("Reopening the menu offers to clear the active rule", async () => { - await policyMenuFor(beta, `${integration}.records.create`).click(); + await policyMenuFor(alpha, `${integration}.records.create`).click(); await page.getByRole("menuitem", { name: "Clear" }).waitFor(); await page.keyboard.press("Escape"); }); + await step("The account header blocks the whole second connection", async () => { + const headerMenu = sectionFor(beta).getByRole("button", { + name: `Set policy for ${integration} / ${beta}`, + exact: true, + }); + await headerMenu.click(); + // The header menu is headed by the whole-account pattern it will store. + await page.getByText(betaAccountPattern, { exact: true }).waitFor(); + await page.getByRole("menuitem", { name: "Block" }).click(); + await leafIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`).waitFor(); + await leafIndicator(beta, "list", `Blocked (matched ${betaAccountPattern})`).waitFor(); + // The first account is not affected by the second account's rule. + await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor(); + }); + + await step("The account header recognizes its rule and Clear removes it", async () => { + const headerMenu = sectionFor(beta).getByRole("button", { + name: `Set policy for ${integration} / ${beta}`, + exact: true, + }); + await headerMenu.click(); + await page.getByRole("menuitem", { name: "Clear" }).click(); + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); + await expectNoIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`); + }); + await step("Open the tool detail for records.list", async () => { - await sectionFor(beta).getByRole("button").filter({ hasText: "list" }).click(); + await sectionFor(alpha).getByRole("button").filter({ hasText: "list" }).click(); // The header badge reflects the inherited category rule. await page.getByRole("button", { name: `Matched policy: ${categoryPattern}` }).waitFor(); }); diff --git a/e2e/selfhost/integration-creation-permissions.test.ts b/e2e/selfhost/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..0ddd87ebe8 --- /dev/null +++ b/e2e/selfhost/integration-creation-permissions.test.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { createInvitedIdentity } from "../targets/selfhost"; + +scenario( + "Integration creation · self-host members see admin guidance and owners can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, admin, { + role: "member", + emailPrefix: "integration-permissions", + }), + ); + yield* integrationCreationPermissions(admin, member); + }), +); diff --git a/e2e/setup/selfhost-docker.boot.ts b/e2e/setup/selfhost-docker.boot.ts index 67f49d0fbf..d8bcc27f92 100644 --- a/e2e/setup/selfhost-docker.boot.ts +++ b/e2e/setup/selfhost-docker.boot.ts @@ -108,6 +108,11 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis // test servers and points the instance at them. "-e", "EXECUTOR_ALLOW_LOCAL_NETWORK=true", + // The production image runs Better Auth's rate limiter. It sees no proxy + // header here, so it pools every caller into one bucket of three sign-ins + // per ten seconds, and this suite signs in from 100+ files at once. + "-e", + "EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true", options.image, ]; log(options.logFile, `docker ${args.join(" ")}`); diff --git a/e2e/src/integration-creation-permissions.ts b/e2e/src/integration-creation-permissions.ts new file mode 100644 index 0000000000..cce298ac9f --- /dev/null +++ b/e2e/src/integration-creation-permissions.ts @@ -0,0 +1,176 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { Api, Browser } from "./services"; +import type { Identity } from "./target"; +import { visit } from "./surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Exercise integration creation and member restrictions through the shared console. */ +export const integrationCreationPermissions = (admin: Identity, member: Identity) => + Effect.gen(function* () { + const browser = yield* Browser; + const { client } = yield* Api; + const adminClient = yield* client(api, admin); + const title = `Permissions API ${randomBytes(4).toString("hex")}`; + const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_")); + const spec = JSON.stringify({ + openapi: "3.0.3", + info: { title, version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: {}, + components: { + securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } }, + }, + security: [{ apiKey: [] }], + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* browser.session(admin, async ({ page, step }) => { + await step("Admin opens the integration catalog", async () => { + await visit(page, "/"); + await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("link", { name: "Add integration", exact: true }).click(); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByRole("textbox", { name: "Search integrations, or paste a URL" }) + .waitFor(); + }); + await step("Admin creates an integration from the setup form", async () => { + await visit(page, "/integrations/add/openapi"); + await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); + await page.getByRole("button", { name: "Add integration", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), { + timeout: 30_000, + }); + await page.getByRole("button", { name: "Edit", exact: true }).waitFor(); + await page.getByRole("button", { name: "Delete", exact: true }).waitFor(); + }); + }); + expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({ + name: title, + }); + + yield* browser.session(member, async ({ page, step }) => { + await step( + "Member sees disabled creation controls with an admin explanation", + async () => { + await visit(page, "/"); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + await page.getByTestId(`integration-entry-${slug}`).waitFor(); + const add = page.getByRole("button", { name: "Add integration", exact: true }); + await add.waitFor(); + expect(await add.isDisabled()).toBe(true); + expect( + await page + .getByRole("button", { name: "Browse integrations", exact: true }) + .isDisabled(), + ).toBe(true); + const hint = page + .getByRole("group", { name: "Requires a workspace admin" }) + .filter({ has: add }); + await hint.hover(); + await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor(); + await hint.focus(); + const before = page.url(); + await page.keyboard.press("Enter"); + expect(page.url()).toBe(before); + }, + ); + await step( + "Member sees disabled add commands and can still find existing integrations", + async () => { + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByRole("dialog"); + await palette.getByRole("option", { name: new RegExp(title) }).waitFor(); + const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ }); + await addCommand.waitFor(); + expect(await addCommand.getAttribute("aria-disabled")).toBe("true"); + expect(await addCommand.textContent()).toContain("Admin only"); + await page.keyboard.press("Escape"); + }, + ); + await step("Member sees disabled Edit and Delete actions", async () => { + await page.getByTestId(`integration-entry-${slug}`).click(); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + for (const name of ["Edit", "Delete"]) { + const action = page.getByRole("button", { name, exact: true }); + await action.waitFor(); + expect(await action.isDisabled()).toBe(true); + } + }); + await step("Member sees tool policies without a way to change them", async () => { + // Policies on the Tools page are workspace rules the server refuses + // for members, so the row menus and the detail badge menu stay off. + await visit(page, "/tools"); + await page.getByRole("button").filter({ hasText: "executor" }).first().waitFor(); + expect( + await page.getByRole("button", { name: /^Set policy/ }).count(), + "members get no policy menus on tool rows", + ).toBe(0); + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + }); + await step("Member can still add a personal connection", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0); + }); + await step("Member browses the catalog with disabled Add buttons", async () => { + await visit(page, "/integrations/browse"); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByText("Requires a workspace admin to add integrations.", { exact: true }) + .waitFor(); + const addButtons = page.getByRole("button", { name: /^Add / }); + await addButtons.first().waitFor(); + for (const button of await addButtons.all()) + expect(await button.isDisabled()).toBe(true); + const scratch = page.getByRole("button", { + name: "New OpenAPI integration from scratch", + exact: true, + }); + expect(await scratch.isDisabled()).toBe(true); + const view = page.getByRole("link", { name: `View ${title}`, exact: true }); + await view.waitFor(); + expect(await view.isEnabled()).toBe(true); + }); + await step("Member cannot add a URL with the button or Enter key", async () => { + const input = page.getByRole("textbox", { + name: "Search integrations, or paste a URL", + }); + await input.fill("https://api.example.com/openapi.json"); + expect( + await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(), + ).toBe(true); + const before = page.url(); + await input.press("Enter"); + expect(page.url()).toBe(before); + }); + for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) { + await step(`Member follows ${path} and sees the admin explanation`, async () => { + await visit(page, path); + await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor(); + expect(await page.getByRole("textbox").count()).toBe(0); + expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0); + }); + } + await step("Member returns to their existing integrations", async () => { + await page.getByRole("link", { name: "Back to integrations" }).click(); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + }); + }); + }), + adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }); diff --git a/examples/all-plugins/CHANGELOG.md b/examples/all-plugins/CHANGELOG.md index e980998560..cf669b4ade 100644 --- a/examples/all-plugins/CHANGELOG.md +++ b/examples/all-plugins/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/example-all-plugins +## 0.0.71 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-file-secrets@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-keychain@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-onepassword@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/plugin-workos-vault@0.0.2 + ## 0.0.70 ### Patch Changes diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 4022ceddd2..6adceba1ff 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-all-plugins", - "version": "0.0.70", + "version": "0.0.71", "private": true, "type": "module", "scripts": { diff --git a/examples/docs-sdk-quickstart/CHANGELOG.md b/examples/docs-sdk-quickstart/CHANGELOG.md index 058f39683f..a7d9111d44 100644 --- a/examples/docs-sdk-quickstart/CHANGELOG.md +++ b/examples/docs-sdk-quickstart/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/example-docs-sdk-quickstart +## 0.0.56 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + ## 0.0.55 ### Patch Changes diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index da8f8f480d..5828cfdb82 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.55", + "version": "0.0.56", "private": true, "type": "module", "scripts": { diff --git a/packages/core/analytics/CHANGELOG.md b/packages/core/analytics/CHANGELOG.md index 4827f67b1f..ee97c48fab 100644 --- a/packages/core/analytics/CHANGELOG.md +++ b/packages/core/analytics/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/analytics +## 0.1.17 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/execution@1.6.10 + ## 0.1.16 ### Patch Changes diff --git a/packages/core/analytics/package.json b/packages/core/analytics/package.json index 9b8baf18eb..1862e0851d 100644 --- a/packages/core/analytics/package.json +++ b/packages/core/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/analytics", - "version": "0.1.16", + "version": "0.1.17", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", "bugs": { diff --git a/packages/core/api/CHANGELOG.md b/packages/core/api/CHANGELOG.md index 7910d9a3cf..ea1036bfd3 100644 --- a/packages/core/api/CHANGELOG.md +++ b/packages/core/api/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/api +## 1.4.73 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/host-mcp@1.4.4 + ## 1.4.72 ### Patch Changes diff --git a/packages/core/api/package.json b/packages/core/api/package.json index 8205bb886e..31c1beca31 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/api", - "version": "1.4.72", + "version": "1.4.73", "private": true, "type": "module", "exports": { diff --git a/packages/core/cli/CHANGELOG.md b/packages/core/cli/CHANGELOG.md index adb1299ba0..e1812914a5 100644 --- a/packages/core/cli/CHANGELOG.md +++ b/packages/core/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/cli +## 0.2.60 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.2.59 ### Patch Changes diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index f5477f210b..ab83e21e04 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cli", - "version": "0.2.59", + "version": "0.2.60", "description": "CLI for the executor SDK — schema generation, migrations", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", "bugs": { diff --git a/packages/core/config/CHANGELOG.md b/packages/core/config/CHANGELOG.md index 20701aaeb9..46b8e59a35 100644 --- a/packages/core/config/CHANGELOG.md +++ b/packages/core/config/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/config +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/config/package.json b/packages/core/config/package.json index dfd10bd38a..b128eebddf 100644 --- a/packages/core/config/package.json +++ b/packages/core/config/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/config", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/execution/CHANGELOG.md b/packages/core/execution/CHANGELOG.md index cbfa89cdb7..cd3eee3c46 100644 --- a/packages/core/execution/CHANGELOG.md +++ b/packages/core/execution/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/execution +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/codemode-core@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index 0891004b15..edefd98597 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/execution", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/CHANGELOG.md b/packages/core/sdk/CHANGELOG.md index 4a1f1d2334..6e05d81e28 100644 --- a/packages/core/sdk/CHANGELOG.md +++ b/packages/core/sdk/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/sdk +## 1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 9412d9d4da..bf4374d6e7 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/sdk", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 51c7f8d110..1fd653c093 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -10,6 +10,7 @@ import { Option, Predicate, Result, + Schedule, Schema, Tracer, } from "effect"; @@ -1912,6 +1913,135 @@ describe("tool catalog sync safety", () => { ), ); + // Live clock: the poll below waits on a detached rebuild fiber, not on the + // test clock. + it.live("a time-expired catalog answers from persisted rows and rebuilds in the background", () => + Effect.scoped( + Effect.gen(function* () { + const listingStarted = yield* Deferred.make(); + const releaseListing = yield* Deferred.make(); + let resolutions = 0; + const remotePlugin = definePlugin(() => ({ + id: "remote" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + remoteToolCatalog: true, + resolveTools: () => + Effect.gen(function* () { + resolutions += 1; + if (resolutions === 1) { + return { tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }; + } + yield* Deferred.succeed(listingStarted, undefined); + yield* Deferred.await(releaseListing); + return { + tools: [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("list"), description: "list" }, + ], + }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + // TTL 0: every catalog is time-expired on every read. + const config = { + ...makeTestConfig({ plugins: [remotePlugin] as const }), + toolsSyncTtlMs: 0, + }; + const executor = yield* createExecutor(config); + yield* executor.remote.seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + + // Let the clock move past the stamp `create` wrote, so the catalog is + // older than the zero TTL on the read below. + yield* Effect.sleep("5 millis"); + + // The upstream listing is held open. A read that waited on it would + // pay the full grace budget; this one must answer at once from the + // persisted catalog. + const startedAt = Date.now(); + const stale = yield* executor.tools.list({ integration: INTEG }); + expect(Date.now() - startedAt).toBeLessThan(1000); + expect(stale.map((tool) => String(tool.name))).toEqual(["deploy"]); + yield* Deferred.await(listingStarted); + + // Once the background rebuild lands, a later read observes it. + yield* Deferred.succeed(releaseListing, undefined); + const converged = yield* executor.tools.list({ integration: INTEG }).pipe( + Effect.map((tools) => tools.map((tool) => String(tool.name)).sort()), + Effect.repeat({ + until: (names) => names.length === 2, + schedule: Schedule.spaced("10 millis"), + }), + Effect.timeout("5 seconds"), + ); + expect(converged).toEqual(["deploy", "list"]); + }), + ), + ); + + it.effect("a stale-marked catalog still gates the read within the grace budget", () => + Effect.scoped( + Effect.gen(function* () { + let resolutions = 0; + const remotePlugin = definePlugin(() => ({ + id: "remote" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + remoteToolCatalog: true, + resolveTools: () => + Effect.sync(() => { + resolutions += 1; + return { + tools: + resolutions === 1 + ? [{ name: ToolName.make("deploy"), description: "deploy" }] + : [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("list"), description: "list" }, + ], + }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + const config = makeTestConfig({ plugins: [remotePlugin] as const }); + const executor = yield* createExecutor(config); + yield* executor.remote.seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + // Stale-marked (an upstream said the catalog changed): the very next + // read reflects the rebuild. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")), + set: { tools_synced_at: null }, + }), + ); + const tools = yield* executor.tools.list({ integration: INTEG }); + expect(tools.map((tool) => String(tool.name)).sort()).toEqual(["deploy", "list"]); + }), + ), + ); + it.effect( "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty", () => diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cd89ab9075..fdbc9b7671 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -154,6 +154,7 @@ import { import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, + isUnboundedDynamicToolScope, isValidPattern, matchPattern, positionForNewPattern, @@ -180,6 +181,7 @@ import type { StaticIntegrationDecl, StaticToolDecl, StorageDeps, + PreparedToolPolicy, ToolPolicyProvider, ToolPolicyProviderRule, ToolInvocationCredential, @@ -3222,7 +3224,10 @@ export const createExecutor = !tool.static).map((tool) => String(tool.integration)), ); @@ -3605,7 +3610,15 @@ export const createExecutor = !tool.static) @@ -5138,7 +5154,11 @@ export const createExecutor = => findConnectionRow(ref).pipe( Effect.flatMap((fresh) => - fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null + fresh === null || + oauthReauthRequiredFromProviderState(fresh.provider_state) !== null || + // A credential verdict cannot refute a failed tool sync; only a + // successful sync clears that record (see `isToolSyncHealth`). + isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health))) ? Effect.void : persistHealthResult(ref, fresh, result), ), @@ -5241,6 +5261,17 @@ export const createExecutor = + result.status === "healthy" && previous !== null && isToolSyncHealth(previous) + ? previous + : result, + ), Effect.tap((result) => persistProbeHealthResult(ref, result)), Effect.map((result) => ({ source: "credential_only" as const, @@ -5399,10 +5430,8 @@ export const createExecutor = EffectivePolicy; + readonly resolve: PreparedToolPolicy["resolve"]; + readonly dynamicScope: PreparedToolPolicy["dynamicScope"]; }; const compareProviderPolicyRule = ( @@ -5443,9 +5472,10 @@ export const createExecutor = ({ + Effect.map((prepared) => ({ kind: "prepared" as const, - resolve, + resolve: prepared.resolve, + dynamicScope: prepared.dynamicScope, })), ) : activeToolPolicyProvider.resolve @@ -5521,116 +5551,137 @@ export const createExecutor = [row.slug, row] as const)); - // The TTL only matters when a loaded plugin actually lists a live remote - // catalog; otherwise skip it so age alone never widens the stale query. - const anyRemoteCatalog = Array.from(runtimes.values()).some( - (runtime) => runtime.plugin.remoteToolCatalog === true, - ); - const cutoff = - toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs; - - // Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or - // synced before the latest instant any trigger could fire at (the TTL - // cutoff / the newest config revision). Per-row trigger checks below - // re-verify against each row's own integration; in steady state this - // query returns nothing and the read pays one indexed lookup. - const latestRevision = integrations.reduce( - (max, row) => - row.config_revised_at == null - ? max - : Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)), - null, - ); - const staleBefore = - cutoff === null && latestRevision === null - ? null - : Math.max(cutoff ?? Number.MIN_SAFE_INTEGER, latestRevision ?? Number.MIN_SAFE_INTEGER); - - const connections = yield* core.findMany("connection", { - where: (b: AnyCb) => - staleBefore === null - ? b.isNull("tools_synced_at") - : b.or(b.isNull("tools_synced_at"), b("tools_synced_at", "<", staleBefore)), - }); - // Each rebuild is an independent upstream listing, so they run together - // rather than one after another: a host with many stale remote-catalog - // connections otherwise pays the sum of every server's latency on the - // read that trips the TTL. Only the listings overlap — `persistCatalog` - // keeps the catalog writes in a single-file queue, so this fan-out never - // opens two transactions on a one-connection database. - const rebuilds: Effect.Effect[] = []; - for (const connection of connections) { - const integrationRow = integrationBySlug.get(connection.integration); - if (!integrationRow) continue; - const runtime = runtimes.get(integrationRow.plugin_id); - // Only re-produce catalogs this executor can actually re-list — - // rebuilding under an unloaded plugin would clear a working catalog. - // (A loaded plugin without `resolveTools` still flows through: - // `produceConnectionTools` runs its clear-and-stamp cleanup path.) - if (!runtime) continue; - - const syncedAt = - connection.tools_synced_at == null ? null : Number(connection.tools_synced_at); - const revisedTime = - integrationRow.config_revised_at == null + const syncStaleConnectionTools = (mode: "converge" | "bounded") => + Effect.gen(function* () { + // The platform view can never persist a rebuilt catalog (writes are + // denied at the storage boundary), so attempting the sync would only + // fire upstream `resolveTools` calls whose results are thrown away — + // network side effects on a read-only credential. Skip it entirely: + // read-only-ness of the platform read path is a stated invariant here, + // not an accident of the best-effort catch below. + if (config.platformView === true) return; + const integrations = yield* core.findMany("integration", {}); + if (integrations.length === 0) return; + const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); + // The TTL only matters when a loaded plugin actually lists a live remote + // catalog; otherwise skip it so age alone never widens the stale query. + const anyRemoteCatalog = Array.from(runtimes.values()).some( + (runtime) => runtime.plugin.remoteToolCatalog === true, + ); + const cutoff = + toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs; + + // Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or + // synced before the latest instant any trigger could fire at (the TTL + // cutoff / the newest config revision). Per-row trigger checks below + // re-verify against each row's own integration; in steady state this + // query returns nothing and the read pays one indexed lookup. + const latestRevision = integrations.reduce( + (max, row) => + row.config_revised_at == null + ? max + : Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)), + null, + ); + const staleBefore = + cutoff === null && latestRevision === null ? null - : Number(integrationRow.config_revised_at); - - const staleMarked = syncedAt === null; - const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime; - const expired = - cutoff !== null && - runtime.plugin.remoteToolCatalog === true && - syncedAt !== null && - syncedAt < cutoff; - if (!staleMarked && !configRevised && !expired) continue; + : Math.max( + cutoff ?? Number.MIN_SAFE_INTEGER, + latestRevision ?? Number.MIN_SAFE_INTEGER, + ); - rebuilds.push( - produceConnectionTools( - integrationRow, - { - owner: connection.owner as Owner, - integration: IntegrationSlug.make(connection.integration), - name: ConnectionName.make(connection.name), - }, - "background", - ).pipe( - // Best-effort, but never silent: the read still succeeds on the - // stale-but-working catalog and the peer rebuilds still finish, - // while the operator gets the connection that failed and why. - // Without this a connection whose upstream is permanently broken - // re-fails on every read and leaves no trace anywhere. - Effect.catch((error) => - Effect.logWarning("executor stale tool sync failed", { - integration: connection.integration, - connection: connection.name, - error: describeSyncFailure(error), - }).pipe(Effect.as([] as readonly Tool[])), - ), - Effect.withSpan("executor.tools.sync_stale", { - attributes: { - "executor.integration": connection.integration, - "executor.connection": connection.name, + const connections = yield* core.findMany("connection", { + where: (b: AnyCb) => + staleBefore === null + ? b.isNull("tools_synced_at") + : b.or(b.isNull("tools_synced_at"), b("tools_synced_at", "<", staleBefore)), + }); + // Each rebuild is an independent upstream listing, so they run together + // rather than one after another: a host with many stale remote-catalog + // connections otherwise pays the sum of every server's latency on the + // read that trips the TTL. Only the listings overlap — `persistCatalog` + // keeps the catalog writes in a single-file queue, so this fan-out never + // opens two transactions on a one-connection database. + // + // Two urgency classes. A stale-MARKED or config-revised catalog is known + // wrong (the upstream said so, or the integration's config changed), so + // the read waits for it within the grace budget. A catalog that is only + // older than the TTL is stale-but-working: in bounded mode its rebuild + // runs entirely in the background and the read answers from the + // persisted rows at once. Without that split every read after the TTL + // paid the grace budget for MCP listings it had no reason to wait on. + const urgent: Effect.Effect[] = []; + const deferred: Effect.Effect[] = []; + for (const connection of connections) { + const integrationRow = integrationBySlug.get(connection.integration); + if (!integrationRow) continue; + const runtime = runtimes.get(integrationRow.plugin_id); + // Only re-produce catalogs this executor can actually re-list — + // rebuilding under an unloaded plugin would clear a working catalog. + // (A loaded plugin without `resolveTools` still flows through: + // `produceConnectionTools` runs its clear-and-stamp cleanup path.) + if (!runtime) continue; + + const syncedAt = + connection.tools_synced_at == null ? null : Number(connection.tools_synced_at); + const revisedTime = + integrationRow.config_revised_at == null + ? null + : Number(integrationRow.config_revised_at); + + const staleMarked = syncedAt === null; + const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime; + const expired = + cutoff !== null && + runtime.plugin.remoteToolCatalog === true && + syncedAt !== null && + syncedAt < cutoff; + if (!staleMarked && !configRevised && !expired) continue; + + (staleMarked || configRevised || mode === "converge" ? urgent : deferred).push( + produceConnectionTools( + integrationRow, + { + owner: connection.owner as Owner, + integration: IntegrationSlug.make(connection.integration), + name: ConnectionName.make(connection.name), }, - }), - ), - ); - } - yield* Effect.all(rebuilds, { - concurrency: STALE_TOOLS_SYNC_CONCURRENCY, + "background", + ).pipe( + // Best-effort, but never silent: the read still succeeds on the + // stale-but-working catalog and the peer rebuilds still finish, + // while the operator gets the connection that failed and why. + // Without this a connection whose upstream is permanently broken + // re-fails on every read and leaves no trace anywhere. + Effect.catch((error) => + Effect.logWarning("executor stale tool sync failed", { + integration: connection.integration, + connection: connection.name, + error: describeSyncFailure(error), + }).pipe(Effect.as([] as readonly Tool[])), + ), + Effect.withSpan("executor.tools.sync_stale", { + attributes: { + "executor.integration": connection.integration, + "executor.connection": connection.name, + }, + }), + ), + ); + } + if (deferred.length > 0) { + const background = yield* Effect.forkDetach( + Effect.all(deferred, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY }), + ); + config.waitUntil?.( + new Promise((resolve) => background.addObserver(() => resolve(undefined))), + ); + } + yield* Effect.all(urgent, { + concurrency: STALE_TOOLS_SYNC_CONCURRENCY, + }); }); - }); // How long a tools read waits for the stale sync before answering from // the persisted rows (`ExecutorConfig.toolsSyncGraceMs`; `null` blocks @@ -5648,51 +5699,97 @@ export const createExecutor = - Effect.gen(function* () { - const fiber = yield* Effect.forkDetach( - syncStaleConnectionTools.pipe( - Effect.catch((error) => - Effect.logWarning("executor stale tool sync scan failed", { - error: describeSyncFailure(error), - }), + const startStaleSync = Effect.gen(function* () { + const fiber = yield* Effect.forkDetach( + syncStaleConnectionTools("bounded").pipe( + Effect.catch((error) => + Effect.logWarning("executor stale tool sync scan failed", { + error: describeSyncFailure(error), + }), + ), + ), + ); + // On hosts that cancel request-scoped I/O once the response settles + // (Cloudflare Workers), hand the host the rebuilds' completion so the + // catalog still converges after the read stops waiting. + config.waitUntil?.( + new Promise((resolve) => fiber.addObserver(() => resolve(undefined))), + ); + return fiber; + }); + + // Restrict a tool-row read to the prefixes an allowlist policy source can + // reach. `null` = no restriction; `false` = nothing reachable, skip the + // read. Any unbounded prefix (a bare `*`, or wildcards in every position) + // makes the whole scope unrestricted. + const dynamicScopeCondition = ( + ruleSet: ActivePolicyRuleSet, + ): ((b: AnyCb) => Condition | boolean) | null | false => { + if (ruleSet.kind !== "prepared" || ruleSet.dynamicScope === undefined) return null; + const scopes = ruleSet.dynamicScope; + if (scopes.length === 0) return false; + if (scopes.some(isUnboundedDynamicToolScope)) return null; + return (b: AnyCb) => + b.or( + ...scopes.map((scope) => + b.and( + scope.integration === null ? true : b("integration", "=", scope.integration), + scope.owner === null ? true : b("owner", "=", scope.owner), + scope.connection === null ? true : b("connection", "=", scope.connection), ), ), ); - // On hosts that cancel request-scoped I/O once the response settles - // (Cloudflare Workers), hand the host the rebuilds' completion so the - // catalog still converges after the read stops waiting. - config.waitUntil?.( - new Promise((resolve) => fiber.addObserver(() => resolve(undefined))), - ); - yield* Fiber.await(fiber).pipe(Effect.timeoutOption(graceMs), Effect.asVoid); - }); + }; - const toolsList = (filter?: ToolListFilter): Effect.Effect => + // `awaitStaleSync: false` still starts the bounded background sync (so + // catalogs converge for sessions that only ever list connections) but + // answers without waiting on it. Visibility-only readers (which + // connections and integrations exist under the active policy) use it: a + // stale catalog does not change which connection a tool belongs to, so + // gating those reads on upstream MCP listings only added latency to every + // session start. In strict (`null` grace) mode the wait is unconditional. + const readTools = ( + filter: ToolListFilter | undefined, + options: { readonly awaitStaleSync: boolean }, + ): Effect.Effect => Effect.gen(function* () { + let syncFiber: Fiber.Fiber | null = null; if (toolsSyncGraceMs === null) { - yield* syncStaleConnectionTools; + yield* syncStaleConnectionTools("converge"); } else { - yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs); + syncFiber = yield* startStaleSync; } + // Fetch the policy snapshot while the sync runs: the scope it carries + // decides which rows to read at all. + const policyRules = yield* listActivePolicyRuleSet(); + if (syncFiber && options.awaitStaleSync) { + yield* Fiber.await(syncFiber).pipe( + Effect.timeoutOption(toolsSyncGraceMs ?? 0), + Effect.asVoid, + ); + } + const scopeCondition = dynamicScopeCondition(policyRules); // Projected: the list surface is metadata (address, description, // annotations) — loading every tool's input/output schema JSON made // an unbounded list scale with schema bytes, not tool count. - const rows = yield* core.findMany("tool", { - where: (b: AnyCb) => - b.and( - filter?.integration === undefined - ? true - : b("integration", "=", String(filter.integration)), - filter?.owner === undefined ? true : b("owner", "=", filter.owner), - filter?.connection === undefined - ? true - : b("connection", "=", String(filter.connection)), - ), - select: TOOL_INVOCATION_COLUMNS, - }); + const rows = + scopeCondition === false + ? [] + : yield* core.findMany("tool", { + where: (b: AnyCb) => + b.and( + filter?.integration === undefined + ? true + : b("integration", "=", String(filter.integration)), + filter?.owner === undefined ? true : b("owner", "=", filter.owner), + filter?.connection === undefined + ? true + : b("connection", "=", String(filter.connection)), + scopeCondition === null ? true : scopeCondition(b), + ), + select: TOOL_INVOCATION_COLUMNS, + }); const includeBlocked = filter?.includeBlocked ?? false; - const policyRules = yield* listActivePolicyRuleSet(); // Only tools whose integration is still in the catalog. A tool row // whose integration was removed is an orphan (a removal that could // not reach this subject's rows): listing it invites an invoke that @@ -5729,6 +5826,9 @@ export const createExecutor = => + readTools(filter, { awaitStaleSync: true }); + const toolSchema = ( address: ToolAddress, ): Effect.Effect => diff --git a/packages/core/sdk/src/health-check.ts b/packages/core/sdk/src/health-check.ts index a4d6f1cb20..9e39047d55 100644 --- a/packages/core/sdk/src/health-check.ts +++ b/packages/core/sdk/src/health-check.ts @@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; export const toolSyncHealthDetailPrefix = "Tool sync failing"; export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean => + result?.reason === "tool_sync_failed" || result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true; // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 56d7d5b777..627d3de1de 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -192,7 +192,10 @@ export { export { matchPattern, isValidPattern, + dynamicToolScopeForPattern, + isUnboundedDynamicToolScope, effectivePolicyFromSorted, + type DynamicToolScope, ToolPolicyActionSchema, type ToolPolicy, type CreateToolPolicyInput, @@ -388,6 +391,7 @@ export { type AnyPlugin, type StorageDeps, type OwnerBinding, + type PreparedToolPolicy, type ToolPolicyProvider, type ToolPolicyProviderRule, type IntegrationRecord, diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 7496ce1efd..b98d500716 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -2033,6 +2033,73 @@ describe("oauth token refresh in resolveConnectionValue", () => { ), ); + it.effect( + "checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + // Tool sync found the upstream rejecting the freshly minted token + // (e.g. an MCP discovery handshake answering 401) and stamped it. + // The token itself still resolves, so a credential-only check would + // otherwise report healthy and hide a connection that has no tools. + const stamped = { + status: "expired", + checkedAt: Date.now(), + detail: "MCP OAuth reauthorization required", + reason: "tool_sync_failed", + }; + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { last_health: stamped }, + }), + ); + + const result = yield* executor.connections.checkHealth({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }); + expect(result).toMatchObject(stamped); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(row?.last_health).toMatchObject(stamped); + }), + ), + ); + it.effect("records missing authorization-code scopes without blocking the connection", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index d94e8c34ad..92bc623d06 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -621,6 +621,7 @@ describe("exchangeAuthorizationCode", () => { it.effect("uses nested granted scopes for Slack-style user token responses", () => withTokenEndpoint( tokenResponse({ + ok: true, access_token: "xoxp-user-token", token_type: "Bearer", scope: "", @@ -1838,3 +1839,155 @@ describe("OAuth2Error tagging", () => { }); }); }); + +// Slack labels bearer credentials by actor type. The same envelope is used +// during authorization-code exchange and refresh-token rotation. +describe("Provider token envelopes", () => { + const grants = [ + { + label: "standard bearer response with ok metadata", + body: { + ok: true, + access_token: "provider-token", + token_type: "Bearer", + scope: "scope,with-comma other.scope", + }, + expected: { + access_token: "provider-token", + token_type: "bearer", + scope: "scope,with-comma other.scope", + }, + }, + { + // https://docs.slack.dev/reference/methods/oauth.v2.access/ + // A single response can contain two distinct accounts and refresh tokens. + label: "Slack bot and user response", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "commands,incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + authed_user: { + access_token: "user-token", + token_type: "user", + scope: "chat:write", + expires_in: 43200, + refresh_token: "user-refresh", + }, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "commands incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + }, + }, + { + label: "bot", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "channels:read,chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "channels:read chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + }, + { + label: "user", + body: { + ok: true, + access_token: "user-token", + token_type: "user", + scope: "users:read,users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + expected: { + access_token: "user-token", + token_type: "bearer", + scope: "users:read users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + }, + { + label: "nested user", + body: { + ok: true, + authed_user: { + access_token: "nested-user-token", + token_type: "user", + scope: "users:read,chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + expected: { + access_token: "nested-user-token", + token_type: "bearer", + scope: "users:read chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + ]; + for (const grant of grants) { + it.effect(`exchanges a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + it.effect(`refreshes a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + refreshToken: "old-refresh", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + } + it.effect("still rejects unsupported token types in an otherwise successful envelope", () => + withTokenEndpoint( + tokenResponse({ ok: true, access_token: "token", token_type: "mac" }), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index c6e3209fe7..b8b11714c2 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -1117,16 +1117,74 @@ const stripIdToken = async (response: Response): Promise }; }; +const SlackGrant = Schema.Struct({ + access_token: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.Literals(["bot", "user", "Bearer", "bearer"])), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), +}); +const decodeSlackEnvelope = Schema.decodeUnknownOption( + Schema.Struct({ + ...SlackGrant.fields, + ok: Schema.Literal(true), + authed_user: Schema.optional(SlackGrant), + }), +); + +/** Slack's `bot` and `user` values identify the account, not an HTTP auth + * scheme. Project its successful envelope to an RFC 6749 bearer grant before + * oauth4webapi validates it. User-only grants may live entirely in authed_user; + * never replace a populated top-level grant with another account's grant. */ +const normalizeSlackTokenEnvelope = async (response: Response): Promise => { + const decoded = decodeSlackEnvelope(await safeJsonFromResponse(response)); + if (Option.isNone(decoded)) return response; + const envelope = decoded.value; + const user = envelope.authed_user; + const grant = + user?.access_token !== undefined && + (envelope.access_token === undefined || !envelope.scope?.trim()) + ? user + : envelope; + // Standard bearer responses may also contain `ok: true`. Preserve their + // scopes and provider metadata; only Slack's actor token types need adapting. + if ( + grant.access_token === undefined || + (grant.token_type !== "bot" && grant.token_type !== "user") + ) { + return response; + } + const scope = grant.scope + ?.split(/[\s,]+/) + .filter(Boolean) + .join(" "); + return new Response( + JSON.stringify({ + access_token: grant.access_token, + token_type: "Bearer", + refresh_token: grant.refresh_token, + expires_in: grant.expires_in, + ...(scope ? { scope } : {}), + }), + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }, + ); +}; + const processTokenEndpointResponse = async ( as: oauth.AuthorizationServer, client: oauth.Client, response: Response, ): Promise => { const stripped = await stripIdToken(response); - const providerUserGrant = await nestedAuthedUserGrant(stripped.response); + const normalizedResponse = await normalizeSlackTokenEnvelope(stripped.response); + const providerUserGrant = await nestedAuthedUserGrant(normalizedResponse); const parsed = tokenResponseFrom( as, - await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), + await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse), ); const token = parsed.scope === undefined && providerUserGrant !== undefined @@ -1442,7 +1500,7 @@ export const refreshAccessToken = ( const result = await oauth.processRefreshTokenResponse( as, client, - (await stripIdToken(response)).response, + await normalizeSlackTokenEnvelope((await stripIdToken(response)).response), ); return tokenResponseFrom(as, result); }, diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 47b92d16e0..97dbd3dce5 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -713,13 +713,42 @@ describe("oauth.start integration-driven scopes", () => { ), ); - it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () => + it.effect("(j) requests every advertised scope of a large but realistic resource list", () => Effect.scoped( Effect.gen(function* () { - // A hostile/buggy server advertises far more scopes than any real - // template. Discovery caps the request at 100 so the authorize URL - // cannot be blown up. - const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`); + // A fine-grained resource can legitimately advertise well over a + // hundred scopes (PostHog lists 150). Dropping any of them mints a + // token the resource rejects, so the whole list must be requested. + const manyScopes = Array.from( + { length: 150 }, + (_, i) => `resource_${i}:${i % 2 === 0 ? "read" : "write"}`, + ); + const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } }); + const executor = yield* setupMcpScopeClient(server); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(manyScopes); + }), + ), + ); + + it.effect("(j2) caps server-advertised resource scopes so the authorize URL stays bounded", () => + Effect.scoped( + Effect.gen(function* () { + // A hostile/buggy server advertises an absurd list. Discovery keeps + // the longest leading prefix whose joined `scope` value fits the + // 8 KiB budget so the authorize URL cannot be blown up. + const manyScopes = Array.from({ length: 2000 }, (_, i) => `scope:${i}`); const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } }); const executor = yield* setupMcpScopeClient(server); @@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => { if (started.status !== "redirect") return; const requested = scopesFromAuthorizeUrl(started.authorizationUrl); - expect(requested.length).toBe(100); - expect(requested).toEqual(manyScopes.slice(0, 100)); + expect(requested.length).toBeLessThan(manyScopes.length); + expect(requested).toEqual(manyScopes.slice(0, requested.length)); + expect(requested.join(" ").length).toBeLessThanOrEqual(8192); + expect([...requested, manyScopes[requested.length]].join(" ").length).toBeGreaterThan(8192); }), ), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index a98eeda039..7d8d7d46ea 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -782,9 +782,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // Caps on server-controlled discovery input — a hostile or buggy server must // not be able to hang `oauth.start` or overflow the authorize URL. const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice - const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template - const capScopes = (scopes: readonly string[]): readonly string[] => - dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); + // The cap is on the encoded `scope` parameter's length, not the scope + // count: the URL is what overflows, and a real resource can legitimately + // advertise well over a hundred fine-grained scopes (PostHog lists 150). + // Dropping any advertised scope silently mints a token the resource then + // rejects, so the budget is generous — 8 KiB leaves room for the rest of the + // authorize URL under the common 8-16 KiB request-line limits — and only an + // absurd list is truncated. + const MAX_DISCOVERED_SCOPE_CHARS = 8192; + const capScopes = (scopes: readonly string[]): readonly string[] => { + const unique = dedupeScopes(scopes); + let length = 0; + let count = 0; + for (const scope of unique) { + const next = length + scope.length + (count > 0 ? 1 : 0); + if (next > MAX_DISCOVERED_SCOPE_CHARS) break; + length = next; + count += 1; + } + return unique.slice(0, count); + }; // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS // fetches, each with its own request timeout). 30s is larger than a single diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 2ace32f891..e079f5ab8a 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -55,6 +55,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; import type { CreateToolPolicyInput, + DynamicToolScope, EffectivePolicy, RemoveToolPolicyInput, ToolPolicy, @@ -131,13 +132,25 @@ export interface ToolPolicyProvider { * requests), so caching on it would serve stale policy state. Each operation * gets a fresh snapshot. */ - readonly prepare?: () => Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - >; + readonly prepare?: () => Effect.Effect; +} + +/** What `ToolPolicyProvider.prepare` hands core for one operation. */ +export interface PreparedToolPolicy { + /** Pure resolver over the snapshot `prepare` fetched. */ + readonly resolve: (input: { + readonly toolId: string; + readonly defaultRequiresApproval?: boolean; + }) => EffectivePolicy; + /** + * The dynamic-tool prefixes this policy source can ever approve. When set, + * core restricts the tool rows it loads on a list to these prefixes instead + * of reading the whole catalog and blocking most of it in memory — the read + * then scales with the allowlist, not the workspace. An empty array means no + * dynamic tool is reachable. Omit when the source is not an allowlist (any + * row may be approved) so core keeps the unrestricted read. + */ + readonly dynamicScope?: readonly DynamicToolScope[]; } // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index 18c5d29a72..98af2a65e7 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -16,6 +16,7 @@ import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { createExecutor } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { + dynamicToolScopeForPattern, effectivePolicyFromSorted, isValidPattern, matchPattern, @@ -108,6 +109,56 @@ describe("isValidPattern", () => { }); }); +describe("dynamicToolScopeForPattern", () => { + it("reads the connection prefix out of subtree patterns", () => { + expect(dynamicToolScopeForPattern("github.org.main.*")).toEqual({ + integration: "github", + owner: "org", + connection: "main", + }); + expect(dynamicToolScopeForPattern("github.org.*")).toEqual({ + integration: "github", + owner: "org", + connection: null, + }); + expect(dynamicToolScopeForPattern("github.*")).toEqual({ + integration: "github", + owner: null, + connection: null, + }); + }); + + it("treats a mid-segment wildcard as any value for that position", () => { + expect(dynamicToolScopeForPattern("github.*.*.repos.list")).toEqual({ + integration: "github", + owner: null, + connection: null, + }); + expect(dynamicToolScopeForPattern("github.user.*.repos.*")).toEqual({ + integration: "github", + owner: "user", + connection: null, + }); + }); + + it("is unbounded for the universal pattern", () => { + expect(dynamicToolScopeForPattern("*")).toEqual({ + integration: null, + owner: null, + connection: null, + }); + }); + + it("yields no scope for patterns that can only reach static tools", () => { + // Exact ids shorter than a dynamic address. + expect(dynamicToolScopeForPattern("github")).toBeNull(); + expect(dynamicToolScopeForPattern("github.org.main")).toBeNull(); + // A literal owner that is neither org nor user is a static namespace. + expect(dynamicToolScopeForPattern("executor.coreTools.*")).toBeNull(); + expect(dynamicToolScopeForPattern("executor.coreTools.connections.list")).toBeNull(); + }); +}); + describe("resolveToolPolicy", () => { // v2: policy rows carry `owner` (org|user) instead of a scope id. const ROW = ( @@ -714,6 +765,100 @@ describe("active tool-policy provider", () => { ); }); +describe("prepared tool policy provider with a dynamic scope", () => { + const scopedProviderPlugin = ( + dynamicScope: readonly { + integration: string | null; + owner: string | null; + connection: string | null; + }[], + ) => + definePlugin(() => ({ + id: "scoped-policy-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => Effect.succeed([]), + // Approves everything it is asked about: only the scope decides what + // core reads, so anything missing from the list was never loaded. + prepare: () => + Effect.succeed({ + resolve: () => ({ action: "approve" as const, source: "user" as const, pattern: "*" }), + dynamicScope, + }), + }), + }))(); + + const setupScoped = ( + dynamicScope: readonly { + integration: string | null; + owner: string | null; + connection: string | null; + }[], + ) => + makeTestExecutor({ + plugins: [policyTestPlugin(), scopedProviderPlugin(dynamicScope)] as const, + }).pipe( + Effect.tap((executor) => + Effect.gen(function* () { + yield* executor.ptest.seed(); + for (const integration of [VERCEL, GITHUB]) { + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration, + template: TEMPLATE, + value: "v", + }); + } + }), + ), + ); + + const dynamicAddresses = (tools: readonly { address: unknown; static?: boolean }[]) => + tools + .filter((tool) => !tool.static) + .map((tool) => String(tool.address)) + .sort(); + + it.effect("restricts the list to the scoped connection", () => + Effect.gen(function* () { + const executor = yield* setupScoped([ + { integration: String(VERCEL), owner: "org", connection: String(CONN) }, + ]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([ + String(addr(VERCEL, "delete")), + String(addr(VERCEL, "deploy")), + ]); + const connections = yield* executor.connections.list(); + expect(connections.map((connection) => String(connection.integration))).toEqual([ + String(VERCEL), + ]); + }), + ); + + it.effect("a wildcard position widens the scope to every value", () => + Effect.gen(function* () { + const executor = yield* setupScoped([{ integration: null, owner: "org", connection: null }]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([ + String(addr(GITHUB, "list")), + String(addr(VERCEL, "delete")), + String(addr(VERCEL, "deploy")), + ]); + }), + ); + + it.effect("an empty scope reads no dynamic rows", () => + Effect.gen(function* () { + const executor = yield* setupScoped([]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([]); + expect(yield* executor.connections.list()).toEqual([]); + }), + ); +}); + describe("approve / require_approval interaction with annotations", () => { it.effect("approve skips the elicitation prompt even when plugin requires approval", () => Effect.gen(function* () { diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d3..b9e1f1ecad 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -120,6 +120,49 @@ export const isValidPattern = (pattern: string): boolean => { return true; }; +// --------------------------------------------------------------------------- +// Dynamic-tool scope — the (integration, owner, connection) prefix a pattern +// can reach. Lets a policy source that is an allowlist (a toolkit) narrow the +// tool rows core loads to the connections the allowlist names, instead of +// walking the whole catalog and blocking almost all of it in memory. +// --------------------------------------------------------------------------- + +/** One reachable prefix of a dynamic tool id `integration.owner.connection.tool`. + * `null` in a position means any value. All three `null` = unbounded. */ +export interface DynamicToolScope { + readonly integration: string | null; + readonly owner: string | null; + readonly connection: string | null; +} + +export const isUnboundedDynamicToolScope = (scope: DynamicToolScope): boolean => + scope.integration === null && scope.owner === null && scope.connection === null; + +/** + * The prefix of dynamic tool ids a pattern can match, or `null` when it can + * match none. A dynamic tool id has at least four segments, so an exact + * pattern shorter than that reaches only static tools. A trailing `*` covers + * every deeper segment; a mid-pattern `*` covers exactly that segment. + */ +export const dynamicToolScopeForPattern = (pattern: string): DynamicToolScope | null => { + if (pattern === "*") return { integration: null, owner: null, connection: null }; + const segments = pattern.split("."); + const subtree = segments.at(-1) === "*"; + if (!subtree && segments.length < 4) return null; + const at = (index: number): string | null => { + const segment = segments[index]; + if (segment === undefined) return null; + // Only the trailing `*` reaches past its own position; a mid `*` is one + // segment, which is also "any value" for that position. + return segment === "*" ? null : segment; + }; + const owner = at(1); + // A dynamic tool id's owner segment is always `org` or `user`; any other + // literal there names a static namespace (`executor.coreTools.*`). + if (owner !== null && owner !== "org" && owner !== "user") return null; + return { integration: at(0), owner, connection: at(2) }; +}; + // --------------------------------------------------------------------------- // Resolution — each owner contributes its first matching rule by local // position; the most restrictive matched action across owners wins. Caller diff --git a/packages/core/vite-plugin/CHANGELOG.md b/packages/core/vite-plugin/CHANGELOG.md index ab73ab229f..dafa62eb8b 100644 --- a/packages/core/vite-plugin/CHANGELOG.md +++ b/packages/core/vite-plugin/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/vite-plugin +## 0.0.70 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.0.69 ### Patch Changes diff --git a/packages/core/vite-plugin/package.json b/packages/core/vite-plugin/package.json index 8d013f6dd6..4c9203e8d6 100644 --- a/packages/core/vite-plugin/package.json +++ b/packages/core/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/vite-plugin", - "version": "0.0.69", + "version": "0.0.70", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/hosts/cloudflare/CHANGELOG.md b/packages/hosts/cloudflare/CHANGELOG.md index 8d548affc0..5e54606529 100644 --- a/packages/hosts/cloudflare/CHANGELOG.md +++ b/packages/hosts/cloudflare/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/cloudflare +## 0.0.52 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/host-mcp@1.4.4 + ## 0.0.51 ### Patch Changes diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index d18022c193..a04546dbd4 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloudflare", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md index 4c7426080c..dbeb773c76 100644 --- a/packages/hosts/mcp-apps-shell/CHANGELOG.md +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/mcp-apps-shell +## 1.4.21 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/react@1.4.73 + ## 1.4.20 ### Patch Changes diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index abd6616b7f..e1615bdcc2 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.20", + "version": "1.4.21", "private": true, "type": "module", "exports": { diff --git a/packages/kernel/core/CHANGELOG.md b/packages/kernel/core/CHANGELOG.md index ff5850ac6a..eed11fadab 100644 --- a/packages/kernel/core/CHANGELOG.md +++ b/packages/kernel/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/codemode-core +## 1.6.10 + ## 1.6.9 ## 1.6.8 diff --git a/packages/kernel/core/package.json b/packages/kernel/core/package.json index d2952db08a..a1d24ebbe7 100644 --- a/packages/kernel/core/package.json +++ b/packages/kernel/core/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/codemode-core", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-quickjs/CHANGELOG.md b/packages/kernel/runtime-quickjs/CHANGELOG.md index 19a9cde4ea..5425da04d1 100644 --- a/packages/kernel/runtime-quickjs/CHANGELOG.md +++ b/packages/kernel/runtime-quickjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-quickjs +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/kernel/runtime-quickjs/package.json b/packages/kernel/runtime-quickjs/package.json index 082895092f..029a937e2e 100644 --- a/packages/kernel/runtime-quickjs/package.json +++ b/packages/kernel/runtime-quickjs/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-quickjs", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md index 0c0aa1bcab..bc94e1566c 100644 --- a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +++ b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-workerd-subprocess +## 0.0.25 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.6.10 + ## 0.0.24 ### Patch Changes diff --git a/packages/kernel/runtime-workerd-subprocess/package.json b/packages/kernel/runtime-workerd-subprocess/package.json index cb98709333..be832a7e18 100644 --- a/packages/kernel/runtime-workerd-subprocess/package.json +++ b/packages/kernel/runtime-workerd-subprocess/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/onboarding-demo/CHANGELOG.md b/packages/onboarding-demo/CHANGELOG.md index 886ee25d63..5af39e4d7a 100644 --- a/packages/onboarding-demo/CHANGELOG.md +++ b/packages/onboarding-demo/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/onboarding-demo +## 0.0.5 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/react@1.4.73 + ## 0.0.4 ### Patch Changes diff --git a/packages/onboarding-demo/package.json b/packages/onboarding-demo/package.json index 5e6fbf4b00..0ec0fc60da 100644 --- a/packages/onboarding-demo/package.json +++ b/packages/onboarding-demo/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/onboarding-demo", - "version": "0.0.4", + "version": "0.0.5", "private": true, "type": "module", "scripts": { diff --git a/packages/plugins/desktop-settings/CHANGELOG.md b/packages/plugins/desktop-settings/CHANGELOG.md index 38975d5802..95d45049af 100644 --- a/packages/plugins/desktop-settings/CHANGELOG.md +++ b/packages/plugins/desktop-settings/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-desktop-settings +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index aba4e2a3f5..3f927dc2c0 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-desktop-settings", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/encrypted-secrets/CHANGELOG.md b/packages/plugins/encrypted-secrets/CHANGELOG.md index 4fc7c36721..db01af7f82 100644 --- a/packages/plugins/encrypted-secrets/CHANGELOG.md +++ b/packages/plugins/encrypted-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-encrypted-secrets +## 0.0.52 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.0.51 ### Patch Changes diff --git a/packages/plugins/encrypted-secrets/package.json b/packages/plugins/encrypted-secrets/package.json index d47049b036..0499dd9136 100644 --- a/packages/plugins/encrypted-secrets/package.json +++ b/packages/plugins/encrypted-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/example/CHANGELOG.md b/packages/plugins/example/CHANGELOG.md index 84188a7a3e..ebef693e0e 100644 --- a/packages/plugins/example/CHANGELOG.md +++ b/packages/plugins/example/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-example +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/example/package.json b/packages/plugins/example/package.json index 61e2cc5f18..87b1e2a62a 100644 --- a/packages/plugins/example/package.json +++ b/packages/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-example", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/file-secrets/CHANGELOG.md b/packages/plugins/file-secrets/CHANGELOG.md index 4fb028a45f..5aa71cc0af 100644 --- a/packages/plugins/file-secrets/CHANGELOG.md +++ b/packages/plugins/file-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-file-secrets +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index dc783a98bd..2575cad448 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-file-secrets", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/graphql/CHANGELOG.md b/packages/plugins/graphql/CHANGELOG.md index b063c836e2..22f48273fb 100644 --- a/packages/plugins/graphql/CHANGELOG.md +++ b/packages/plugins/graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-graphql +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index c1de04eaa7..3aa148b426 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-graphql", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/keychain/CHANGELOG.md b/packages/plugins/keychain/CHANGELOG.md index 68c8f47d50..e0c3dbd480 100644 --- a/packages/plugins/keychain/CHANGELOG.md +++ b/packages/plugins/keychain/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-keychain +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/keychain/package.json b/packages/plugins/keychain/package.json index d28235f142..c679b44b8d 100644 --- a/packages/plugins/keychain/package.json +++ b/packages/plugins/keychain/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-keychain", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/CHANGELOG.md b/packages/plugins/mcp/CHANGELOG.md index 54cc8691f5..956176cf99 100644 --- a/packages/plugins/mcp/CHANGELOG.md +++ b/packages/plugins/mcp/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-mcp +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index b10e9909d2..b070d53b60 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-mcp", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts index f3328876b3..34ec9f24f8 100644 --- a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts +++ b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts @@ -14,7 +14,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref, Schema } from "effect"; +import { Deferred, Effect, Fiber, Option, Ref, Schedule, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { @@ -133,11 +133,13 @@ describe("MCP tool-catalog sync (end-to-end)", () => { }), ); - it.effect("expired catalogs re-list on read once older than the freshness TTL", () => + // Live clock: the rebuild is real I/O against the test server, so the poll + // must advance on wall time rather than the test clock. + it.live("expired catalogs re-list in the background once older than the freshness TTL", () => Effect.gen(function* () { const mutable = makeMutableCatalogMcpServer(); const server = yield* serveMcpServer(mutable.factory); - // Everything is instantly stale — every tools read re-lists. + // Everything is instantly stale — every tools read starts a re-list. const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: 0 }); expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName); @@ -145,7 +147,17 @@ describe("MCP tool-catalog sync (end-to-end)", () => { // Server-side change with no notification and no executor signal at all. mutable.renameTool(); - const refreshed = toolNames(yield* executor.tools.list()); + // A time-expired catalog is stale-but-working: the read answers from the + // persisted rows without waiting on the upstream listing, and a later + // read observes the rebuilt catalog. + const refreshed = yield* executor.tools.list().pipe( + Effect.map(toolNames), + Effect.repeat({ + until: (names) => names.includes(mutable.renamedToolName), + schedule: Schedule.spaced("20 millis"), + }), + Effect.timeout("5 seconds"), + ); expect(refreshed).toContain(mutable.renamedToolName); expect(refreshed).not.toContain(mutable.initialToolName); }), diff --git a/packages/plugins/onepassword/CHANGELOG.md b/packages/plugins/onepassword/CHANGELOG.md index 5af3bed5d1..ddbeefa517 100644 --- a/packages/plugins/onepassword/CHANGELOG.md +++ b/packages/plugins/onepassword/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-onepassword +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index a52acd00df..7fcecff5e9 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-onepassword", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/openapi/CHANGELOG.md b/packages/plugins/openapi/CHANGELOG.md index 29dcdb2f4f..e16ed52c78 100644 --- a/packages/plugins/openapi/CHANGELOG.md +++ b/packages/plugins/openapi/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-openapi +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 57e8d4320b..f9f527f0c9 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-openapi", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/provider-service-split/CHANGELOG.md b/packages/plugins/provider-service-split/CHANGELOG.md index 0cefad1526..346f7495a7 100644 --- a/packages/plugins/provider-service-split/CHANGELOG.md +++ b/packages/plugins/provider-service-split/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/plugin-provider-service-split +## 0.0.24 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + ## 0.0.23 ### Patch Changes diff --git a/packages/plugins/provider-service-split/package.json b/packages/plugins/provider-service-split/package.json index a790fdea11..385f3cf685 100644 --- a/packages/plugins/provider-service-split/package.json +++ b/packages/plugins/provider-service-split/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.23", + "version": "0.0.24", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/toolkits/CHANGELOG.md b/packages/plugins/toolkits/CHANGELOG.md index a4c8172a01..2df0df288c 100644 --- a/packages/plugins/toolkits/CHANGELOG.md +++ b/packages/plugins/toolkits/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-toolkits +## 1.5.45 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.5.44 ### Patch Changes diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json index 275f8f3fcd..9de56ae469 100644 --- a/packages/plugins/toolkits/package.json +++ b/packages/plugins/toolkits/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-toolkits", - "version": "1.5.44", + "version": "1.5.45", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..3db165f6e6 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -132,6 +132,50 @@ describe("toolkitsPlugin", () => { }), ); + it.effect("prepares a dynamic scope from the toolkit's access patterns", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const orgKit = yield* executor.toolkits.create({ owner: "org", name: "Org Kit" }); + for (const pattern of [ + "github.org.main.*", + "slack.*", + "linear.*.*.issues.list", + "github.user.alice.*", + "executor.coreTools.*", + ]) { + yield* executor.toolkits.createConnection(orgKit.id, { pattern }); + } + const prepared = yield* executor.toolkits.preparePolicyResolverForSlug(orgKit.slug); + // An org toolkit never reaches personal rows: unowned prefixes pin to + // org, user-only prefixes drop, and static-only patterns contribute none. + const byIntegration = ( + a: { integration: string | null }, + b: { integration: string | null }, + ) => String(a.integration).localeCompare(String(b.integration)); + expect([...(prepared.dynamicScope ?? [])].sort(byIntegration)).toEqual([ + { integration: "github", owner: "org", connection: "main" }, + { integration: "linear", owner: "org", connection: null }, + { integration: "slack", owner: "org", connection: null }, + ]); + expect(prepared.resolve({ toolId: "github.org.main.repos.list" }).action).toBe("approve"); + expect(prepared.resolve({ toolId: "github.user.alice.repos.list" }).action).toBe("block"); + + const personalKit = yield* executor.toolkits.create({ owner: "user", name: "Me Kit" }); + yield* executor.toolkits.createConnection(personalKit.id, { pattern: "github.user.alice.*" }); + const personal = yield* executor.toolkits.preparePolicyResolverForSlug(personalKit.slug); + expect(personal.dynamicScope).toEqual([ + { integration: "github", owner: "user", connection: "alice" }, + ]); + + const missing = yield* executor.toolkits.preparePolicyResolverForSlug("no-such-kit"); + expect(missing.dynamicScope).toEqual([]); + expect(missing.resolve({ toolId: "github.org.main.repos.list" }).action).toBe("block"); + }), + ); + it.effect("treats a persisted connection-root approve as an access policy", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index 7dacc3e456..eef15de797 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -2,16 +2,19 @@ import { Context, definePlugin, definePluginStorageCollection, + dynamicToolScopeForPattern, Effect, HttpApiBuilder, isValidPattern, matchPattern, Schema, + type DynamicToolScope, type EffectivePolicy, type Owner, type PluginCtx, type PluginStorageFacade, type PluginStorageCollectionFacade, + type PreparedToolPolicy, type StorageFailure, type ToolPolicyAction, type ToolPolicyProvider, @@ -150,22 +153,42 @@ const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); }; -const resolveToolkitPolicy = ( - toolId: string, +// The toolkit's rules, digested once so resolving a tool is a scan over +// already-sorted patterns. A tools list resolves every candidate row against +// the same snapshot, so the legacy split and the sort must not be redone per +// tool. +interface ToolkitRuleSnapshot { + /** Patterns granting access: connection records plus legacy approve rows. */ + readonly accessPatterns: readonly string[]; + /** Non-legacy policies in precedence order. */ + readonly orderedPolicies: readonly ToolkitPolicyRecord[]; +} + +const digestToolkitRules = ( connections: readonly ToolkitConnectionRecord[], policies: readonly ToolkitPolicyRecord[], +): ToolkitRuleSnapshot => { + const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); + return { + accessPatterns: [ + ...connections.map((connection) => connection.pattern), + ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((p) => p.pattern), + ], + orderedPolicies: policies + .filter((policy) => !legacyPolicyIds.has(policy.id)) + .sort(comparePositioned), + }; +}; + +const resolveToolkitPolicy = ( + toolId: string, + rules: ToolkitRuleSnapshot, defaultRequiresApproval?: boolean, ): EffectivePolicy => { - const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); - const connected = - connections.some((connection) => matchPattern(connection.pattern, toolId)) || - policies.some( - (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), - ); + const connected = rules.accessPatterns.some((pattern) => matchPattern(pattern, toolId)); if (!connected) return blockedPolicy(); - for (const policy of [...policies].sort(comparePositioned)) { - if (legacyPolicyIds.has(policy.id)) continue; + for (const policy of rules.orderedPolicies) { if (!matchPattern(policy.pattern, toolId)) continue; return { action: policy.action, @@ -177,6 +200,28 @@ const resolveToolkitPolicy = ( return pluginDefaultPolicy(defaultRequiresApproval); }; +// The dynamic-tool prefixes the access patterns can reach. An org toolkit +// never grants personal tools, so its prefixes are pinned to org rows and +// user-only prefixes drop out; the per-tool check still enforces the same +// rule for anything the prefix cannot express. +const toolkitDynamicScope = ( + rules: ToolkitRuleSnapshot, + isOrg: boolean, +): readonly DynamicToolScope[] => { + const scopes: DynamicToolScope[] = []; + for (const pattern of rules.accessPatterns) { + const scope = dynamicToolScopeForPattern(pattern); + if (!scope) continue; + if (!isOrg) { + scopes.push(scope); + continue; + } + if (scope.owner === "user") continue; + scopes.push(scope.owner === null ? { ...scope, owner: "org" } : scope); + } + return scopes; +}; + const legacyConnectionPolicyIds = ( policies: readonly ToolkitPolicyRecord[], connections: readonly ToolkitConnectionRecord[], @@ -507,38 +552,36 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); const policies = yield* listPoliciesForRecord(toolkit.data.id); const connections = yield* listConnectionsForRecord(toolkit.data.id); - return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); + return resolveToolkitPolicy( + toolId, + digestToolkitRules(connections, policies), + defaultRequiresApproval, + ); }); // Batched form of `resolvePolicyForSlug`: fetch the toolkit, its policies, and // its connections ONCE, then hand back a pure resolver core can run for every - // tool in a single tools/list or tools/call. `resolvePolicyForSlug` re-fetches - // policies + connections on every tool, which is the per-tool N+1 that scales - // with the whole catalog on the list surface. This is byte-for-byte the same - // resolution, just hoisted out of the loop. + // tool in a single tools/list or tools/call, plus the prefixes those rules + // can reach so core reads only the toolkit's rows instead of the whole + // catalog. `resolvePolicyForSlug` re-fetches policies + connections on every + // tool, which is the per-tool N+1 that scales with the whole catalog on the + // list surface. This is the same resolution, hoisted out of the loop. const preparePolicyResolverForSlug = ( slug: string, - ): Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - > => + ): Effect.Effect => Effect.gen(function* () { const toolkit = yield* getBySlugEntry(slug); - if (!toolkit) return () => blockedPolicy(); + if (!toolkit) return { resolve: () => blockedPolicy(), dynamicScope: [] }; const isOrg = toolkit.owner === "org"; const policies = yield* listPoliciesForRecord(toolkit.data.id); const connections = yield* listConnectionsForRecord(toolkit.data.id); - return (input: { readonly toolId: string; readonly defaultRequiresApproval?: boolean }) => { - if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy(); - return resolveToolkitPolicy( - input.toolId, - connections, - policies, - input.defaultRequiresApproval, - ); + const rules = digestToolkitRules(connections, policies); + return { + resolve: (input) => { + if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy(); + return resolveToolkitPolicy(input.toolId, rules, input.defaultRequiresApproval); + }, + dynamicScope: toolkitDynamicScope(rules, isOrg), }; }); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 6239d80c2c..4c3a7df603 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/react +## 1.4.73 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + ## 1.4.72 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index bc7e72a21a..62e41b5c4b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/react", - "version": "1.4.72", + "version": "1.4.73", "private": true, "type": "module", "exports": { diff --git a/packages/react/src/api/error-reporting.test.ts b/packages/react/src/api/error-reporting.test.ts index 655f792ba7..c1c1d09e97 100644 --- a/packages/react/src/api/error-reporting.test.ts +++ b/packages/react/src/api/error-reporting.test.ts @@ -53,6 +53,20 @@ describe("frontend error reporting", () => { expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); }); + it("reads a message the error exposes as a prototype getter", () => { + // Schema-tagged API errors (e.g. OrgWriteDeniedError) declare no `message` + // field; the sentence lives on a class getter, which a struct decode misses. + class GetterError extends Data.TaggedError("GetterError")<{}> { + override get message(): string { + return "Requires a workspace admin."; + } + } + const exit = Exit.fail(new GetterError()); + + expect(messageFromExit(exit, "Fallback")).toBe("Requires a workspace admin."); + expect(messageFromUnknown(new GetterError(), "Fallback")).toBe("Requires a workspace admin."); + }); + it("reports failed exits with the provided context", () => { const exit = Exit.fail({ message: "Could not update integration" }); const { calls, report } = captureReports(); diff --git a/packages/react/src/api/error-reporting.tsx b/packages/react/src/api/error-reporting.tsx index 8a9eab0d03..2c19b8ce39 100644 --- a/packages/react/src/api/error-reporting.tsx +++ b/packages/react/src/api/error-reporting.tsx @@ -21,8 +21,17 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ readonly context: FrontendErrorContext; }> {} -const ErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); +// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no +// `message` field and expose it as a prototype getter instead, so a struct +// decode — which only sees own properties — would miss the very sentence the +// server wrote for the user. Read the property directly. +const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => { + if (typeof value !== "object" || value === null) return Option.none(); + const message: unknown = Reflect.get(value, "message"); + return typeof message === "string" && message.length > 0 + ? Option.some({ message }) + : Option.none(); +}; const TaggedValue = Schema.Struct({ _tag: Schema.String }); const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index 0fdedf4983..90558590e6 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -11,6 +11,7 @@ import { import type { AuthMethod } from "../lib/auth-placements"; import type { OAuthPopupReservation } from "../plugins/oauth-sign-in"; +import type { OAuthClientOption } from "../plugins/use-effective-oauth-client"; import { connectionNameFrom, connectionLabel, @@ -19,6 +20,7 @@ import { DEFAULT_CONNECTION_OWNER, hasDcr, mergeCustomMethods, + preferredMethodId, oauthIdentityLabelFromHealth, runAutomaticOAuthConnect, runCimdConnect, @@ -1438,3 +1440,68 @@ describe("runDcrConnect", () => { expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com"); }); }); + +describe("preferredMethodId", () => { + const integration = IntegrationSlug.make("team-chat"); + const token = apiKeyMethod("token", "spec"); + const oauth: AuthMethod = { + id: "oauth", + label: "OAuth", + kind: "oauth", + source: "spec", + template: AuthTemplateSlug.make("oauth"), + placements: [], + oauth: { tokenUrl: "https://auth.example.com/token", scopes: ["read"] }, + }; + const client: OAuthClientOption = { + owner: "org", + slug: OAuthClientSlug.make("team-chat-app"), + grant: "authorization_code", + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + clientId: "synthetic-client", + origin: { kind: "manual", integration: null }, + }; + + it("prefers OAuth only with a client matched to that method", () => { + expect(preferredMethodId([token, oauth], [client], integration)).toBe("oauth"); + expect(preferredMethodId([oauth, token], [], integration)).toBe("token"); + expect( + preferredMethodId( + [token, oauth], + [{ ...client, tokenUrl: "https://other.example.com/token" }], + integration, + ), + ).toBe("token"); + }); + + it("selects the OAuth method with a client rather than the first OAuth method", () => { + const unconfigured = { + ...oauth, + id: "other-oauth", + oauth: { tokenUrl: "https://unregistered.example.net/token" }, + }; + expect(preferredMethodId([token, unconfigured, oauth], [client], integration)).toBe("oauth"); + }); + + it("uses matching built-in clients only when they allow the requested scopes", () => { + const builtIn: OAuthClientOption = { + ...client, + origin: { kind: "first_party", allowedScopes: ["read"] }, + }; + expect(preferredMethodId([token, oauth], [builtIn], integration)).toBe("oauth"); + expect( + preferredMethodId( + [token, { ...oauth, oauth: { ...oauth.oauth, scopes: ["write"] } }], + [builtIn], + integration, + ), + ).toBe("token"); + }); + + it("keeps single-method and empty integrations usable", () => { + expect(preferredMethodId([token], [], integration)).toBe("token"); + expect(preferredMethodId([oauth], [], integration)).toBe("oauth"); + expect(preferredMethodId([], [], integration)).toBe(""); + }); +}); diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 5e10fd509f..222dd0f682 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -73,6 +73,7 @@ import { clientDisplayName, clientHost, optimisticDcrClientSlug, + selectClientsForEndpoints, selectDcrClientsForIntegration, uniqueClientSlug, useOAuthClientsForIntegration, @@ -628,13 +629,29 @@ export const connectionExistsMessage = (label: string): string => * explicit choice. Personal: a connection is most often a personal credential. */ export const DEFAULT_CONNECTION_OWNER: Owner = "user"; -/** The method the modal opens on. OAuth needs a registered app (or a DCR - * round-trip) before "Connect" does anything; a key is one paste. When an - * integration declares both, starting on OAuth greets most users with - * "Register app" — a dead end — while the working method sits one tab over. - * Prefer the first non-OAuth method; OAuth stays one click away. */ -export const preferredMethodId = (methods: readonly AuthMethod[]): string => - (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? ""; +/** Prefer OAuth only when its picker has a matching, usable client. Otherwise + * prefer a credential method; OAuth-only integrations still expose setup. */ +export const preferredMethodId = ( + methods: readonly AuthMethod[], + clients: readonly OAuthClientOption[], + integration: IntegrationSlug, +): string => + ( + methods.find( + (method) => + method.kind === "oauth" && + selectClientsForEndpoints(clients, { + integration, + tokenUrl: method.oauth?.tokenUrl, + authorizationUrl: method.oauth?.authorizationUrl, + scopes: method.oauth?.scopes, + discoversScopes: hasDcr(method), + requireEndpointMatch: true, + }).matched.length > 0, + ) ?? + methods.find((method) => method.kind !== "oauth") ?? + methods[0] + )?.id ?? ""; const authMethodKey = (method: AuthMethod): string => method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; @@ -1443,7 +1460,9 @@ function AddAccountModalView(props: AddAccountModalProps) { ); const [addingMethod, setAddingMethod] = useState(false); - const [methodId, setMethodId] = useState(preferredMethodId(methods)); + // An untouched form follows client availability. User interaction or a + // handoff pins a method so a late clients response cannot replace their form. + const [selectedMethodId, setMethodId] = useState(null); // One value per distinct credential input (`variable → pasted value`). A // single-secret method has just `{ token }`; a method with two distinct inputs // (e.g. Datadog's two keys) collects one value per variable. @@ -1548,6 +1567,23 @@ function AddAccountModalView(props: AddAccountModalProps) { () => (AsyncResult.isSuccess(allClientsResult) ? allClientsResult.value : []), [allClientsResult], ); + const defaultMethodId = useMemo( + () => + preferredMethodId( + allMethods, + clientSummaries.flatMap((client) => + client.grant === "authorization_code" || client.grant === "client_credentials" + ? [{ ...client, grant: client.grant }] + : [], + ), + integration, + ), + [allMethods, clientSummaries, integration], + ); + const methodId = + selectedMethodId !== null && allMethods.some((method) => method.id === selectedMethodId) + ? selectedMethodId + : defaultMethodId; const usage = useMemo( () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []), [connectionsResult], @@ -1582,15 +1618,6 @@ function AddAccountModalView(props: AddAccountModalProps) { [allMethods, methodId], ); - useEffect(() => { - if (allMethods.length === 0) { - if (methodId !== "") setMethodId(""); - return; - } - if (allMethods.some((m: AuthMethod) => m.id === methodId)) return; - setMethodId(allMethods[0]!.id); - }, [allMethods, methodId]); - // Apply the handoff prefill ONCE per handoff key (tracked by ref). The // effect's deps include `allMethods`, which gets a new identity whenever the // integration refetches — and the wizard itself triggers a refetch mid-flow @@ -1622,18 +1649,6 @@ function AddAccountModalView(props: AddAccountModalProps) { setDcrFallbackMessage(null); }, [initialState, allMethods, defaultOwner, ownerOptions]); - useEffect(() => { - if (allMethods.length === 0) return; - if (allMethods.some((m: AuthMethod) => m.id === methodId)) return; - const initialMethod = initialState?.template - ? allMethods.find( - (m: AuthMethod) => - m.id === initialState.template || String(m.template) === initialState.template, - ) - : undefined; - setMethodId(initialMethod?.id ?? preferredMethodId(allMethods)); - }, [allMethods, initialState?.template, methodId]); - // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. // The agent fills in the endpoints/grant/client id it discovered; the client // secret is deliberately absent and is typed by the human in the form below. @@ -2695,6 +2710,9 @@ function AddAccountModalView(props: AddAccountModalProps) { setMethodId(methodId)} + onKeyDownCapture={() => setMethodId(methodId)} + onInput={() => setMethodId(methodId)} className={cn( "max-h-[85vh] overflow-x-hidden overflow-y-auto", (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 585ceed1f2..ecd148dcb2 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi import { PresetIcon } from "./preset-icon"; import { integrationsOptimisticAtom } from "../api/atoms"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { CommandDialog, CommandEmpty, @@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool const integrationPlugins = useIntegrationPlugins(); const navigate = useNavigate(); const integrationsResult = useAtomValue(integrationsOptimisticAtom); + const canCreateIntegration = useCanCreateWorkspaceConnections(); // Toggle with ⌘K / Ctrl+K useEffect(() => { @@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool {integrationPlugins.map((plugin) => ( goToAdd(plugin.key)} > Add {plugin.label} + {!canCreateIntegration && Admin only} ))} @@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool {presetEntries.map((e) => ( goToPreset(e.pluginKey, e.presetId, e.presetUrl)} > @@ -208,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool } /> {e.presetName} - {e.pluginLabel} + + {canCreateIntegration ? e.pluginLabel : "Admin only"} + ))} diff --git a/packages/react/src/components/integration-creation-gate.tsx b/packages/react/src/components/integration-creation-gate.tsx new file mode 100644 index 0000000000..afcdee7025 --- /dev/null +++ b/packages/react/src/components/integration-creation-gate.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; + +import { orgMembersAtom } from "../api/account-atoms"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; +import { Button } from "./button"; +import { PageContainer, PageHeader } from "./page"; +import { Skeleton } from "./skeleton"; + +/** Keep integration creation flows behind the same role gate as edit and delete. */ +export function IntegrationCreationGate({ children }: { readonly children: ReactNode }) { + const canCreate = useCanCreateWorkspaceConnections(); + const members = useAtomValue(orgMembersAtom); + if (canCreate) return children; + if (isAsyncResultLoading(members)) { + return ( + + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index f0fa20e6f2..d3b8c94908 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; -import { toPolicyPattern } from "../lib/policy-pattern"; +import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern"; import { Badge } from "./badge"; import { Button } from "./button"; import { Input } from "./input"; @@ -297,7 +297,9 @@ export function ToolTree(props: { * emit the tool's full dotted id; group rows emit `prefix.*`. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; - /** Maps the displayed row path into the persisted policy pattern. */ + /** Maps the displayed row path into the persisted policy pattern for the + * flat tree. Ignored in the account-grouped view, where every row writes a + * pattern pinned to its own account (`integration...`). */ patternForDisplay?: (displayPattern: string) => string; /** Sorted user-authored policies (most-precedent first). Used to * decide whether a node has its own exact-pattern user rule today @@ -429,26 +431,55 @@ export function ToolTree(props: { : (props.emptyLabel ?? "No tools available")} ) : groupByConnection ? ( - accountGroups.map((group) => ( -
-
- {ownerDisplay.showOwnerLabels ? ( - - {ownerLabel(group.owner)} - - ) : null} - - {group.integration && group.connection - ? `${group.integration} / ${group.connection}` - : group.connection || ownerDisplay.label(group.owner)} - - - {group.tools.length} - -
- -
- )) + accountGroups.map((group) => { + // Rows inside an account section author rules for THAT account + // only: two connections of one integration are different + // credentials, so a rule set under one must not govern the other. + const accountPattern = accountPolicyPattern(group.owner, group.connection); + const wholeAccountPattern = accountPattern(`${group.integration}.*`); + const wholeAccountRule = exactPatterns.get(wholeAccountPattern); + const accountLabel = + group.integration && group.connection + ? `${group.integration} / ${group.connection}` + : group.connection || ownerDisplay.label(group.owner); + return ( +
+
+ {ownerDisplay.showOwnerLabels ? ( + + {ownerLabel(group.owner)} + + ) : null} + + {accountLabel} + + + {group.tools.length} + + {onSetPolicy && group.integration && group.connection ? ( + + ) : null} +
+ +
+ ); + }) ) : ( )} diff --git a/packages/react/src/components/workspace-admin-hint.tsx b/packages/react/src/components/workspace-admin-hint.tsx new file mode 100644 index 0000000000..ea497615f0 --- /dev/null +++ b/packages/react/src/components/workspace-admin-hint.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; + +/** Explain a disabled workspace action on hover or keyboard focus. */ +export function WorkspaceAdminHint(props: { + readonly allowed: boolean; + readonly children: ReactNode; +}) { + if (props.allowed) return props.children; + return ( + + + + + {props.children} + + + + Requires a workspace admin + + + + ); +} diff --git a/packages/react/src/lib/integration-add.tsx b/packages/react/src/lib/integration-add.tsx index 1b1ddbac96..968bc0f3f9 100644 --- a/packages/react/src/lib/integration-add.tsx +++ b/packages/react/src/lib/integration-add.tsx @@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; -import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { integrationsOptimisticAtom } from "../api/atoms"; - -const ErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); +import { messageFromExit } from "../api/error-reporting"; /** The failed Exit's `message`, or `fallback` when the error carries none. */ -export const errorMessageFromExit = (exit: Exit.Exit, fallback: string): string => - Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { - onNone: () => fallback, - onSome: ({ message }) => message, - }); +export const errorMessageFromExit: (exit: Exit.Exit, fallback: string) => string = + messageFromExit; export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit): boolean => Option.match(Exit.findErrorOption(exit), { diff --git a/packages/react/src/lib/policy-pattern.test.ts b/packages/react/src/lib/policy-pattern.test.ts new file mode 100644 index 0000000000..0c3604eb0a --- /dev/null +++ b/packages/react/src/lib/policy-pattern.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { accountPolicyPattern, toPolicyPattern } from "./policy-pattern"; + +describe("policy pattern bridges", () => { + it("wildcards owner and connection for the connection-agnostic tree", () => { + expect(toPolicyPattern("slack.conversations.history")).toBe("slack.*.*.conversations.history"); + expect(toPolicyPattern("slack.conversations.*")).toBe("slack.*.*.conversations.*"); + expect(toPolicyPattern("slack.*")).toBe("slack.*"); + expect(toPolicyPattern("*")).toBe("*"); + }); + + it("pins owner and connection for a row inside an account section", () => { + const forBot = accountPolicyPattern("org", "bot"); + expect(forBot("slack.conversations.history")).toBe("slack.org.bot.conversations.history"); + expect(forBot("slack.conversations.*")).toBe("slack.org.bot.conversations.*"); + expect(forBot("slack.*")).toBe("slack.org.bot.*"); + expect(forBot("slack")).toBe("slack.org.bot.*"); + expect(forBot("*")).toBe("*"); + }); +}); diff --git a/packages/react/src/lib/policy-pattern.ts b/packages/react/src/lib/policy-pattern.ts index 7a274f4296..c2727f38b4 100644 --- a/packages/react/src/lib/policy-pattern.ts +++ b/packages/react/src/lib/policy-pattern.ts @@ -1,4 +1,4 @@ -import { matchPattern } from "@executor-js/sdk/shared"; +import { matchPattern, type Owner } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Policy pattern bridge. @@ -29,3 +29,29 @@ export const toPolicyPattern = (displayPattern: string): string => { }; export { matchPattern }; + +// --------------------------------------------------------------------------- +// Account-scoped bridge. +// +// The account-grouped Tools tab shows the same tool once per connection. A +// rule authored from a row inside one account's section must govern THAT +// account only — a Slack bot connection and a Slack user connection are +// different credentials with different capabilities, and blocking a tool on +// one must not silently block it on the other. So instead of wildcarding the +// owner + connection segments, fill them in: `integration...`. +// `integration.*` becomes the whole-account subtree +// `integration...*`. Apply the returned mapper at both the site +// that BUILDS a pattern and the site that LOOKS UP the exact rule, exactly as +// with `toPolicyPattern`. +// --------------------------------------------------------------------------- + +export const accountPolicyPattern = + (owner: Owner, connection: string) => + (displayPattern: string): string => { + if (displayPattern === "*") return "*"; + const firstDot = displayPattern.indexOf("."); + if (firstDot === -1) return `${displayPattern}.${owner}.${connection}.*`; + const integration = displayPattern.slice(0, firstDot); + const rest = displayPattern.slice(firstDot + 1); + return `${integration}.${owner}.${connection}.${rest}`; + }; diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 1bd109ece1..2ee203b25e 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; import type { Integration } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; import { SidebarUpdateCard } from "../components/update-card"; @@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; import { Wordmark } from "../components/wordmark"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { useAuth } from "./auth-context"; +import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; // --------------------------------------------------------------------------- // Shared multiplayer shell (cloud + self-host). @@ -351,6 +353,7 @@ function SidebarContent( }, ) { const plugins = useClientPlugins(); + const canCreateIntegration = useCanCreateWorkspaceConnections(); const pluginNavItems = plugins.flatMap((plugin) => (plugin.pages ?? []).flatMap((page) => page.nav @@ -385,17 +388,20 @@ function SidebarContent(
Integrations - + + +
diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index 9691ca4cd2..f6b1f32a72 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -1,16 +1,27 @@ -import { Suspense } from "react"; +import { Suspense, type ComponentProps } from "react"; import { useAtomRefresh } from "@effect/atom-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { IntegrationCreationGate } from "../components/integration-creation-gate"; // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- -export function AddIntegrationPage(props: { +/** Render an integration setup flow only when the workspace role permits creation. */ +export function AddIntegrationPage(props: ComponentProps) { + useExecutorDocumentTitle("Add integration"); + return ( + + + + ); +} + +function AddIntegrationContent(props: { pluginKey: string; url?: string; preset?: string; @@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { authKind?: string; specOverrides?: string; }) { - useExecutorDocumentTitle("Add integration"); const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = props; const navigate = useNavigate(); diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx index 83b133e28f..c63789d3ef 100644 --- a/packages/react/src/pages/integration-browse.tsx +++ b/packages/react/src/pages/integration-browse.tsx @@ -27,6 +27,7 @@ import { } from "../components/integration-favicon"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { availableCatalogKinds, catalogLogoUrl, @@ -244,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { ); } -function ResultCard(props: { readonly row: Row }) { +function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) { const { row } = props; return (
+ {!canCreate && ( +

+ Requires a workspace admin to add integrations. +

+ )}
{ - if (event.key === "Enter" && isUrl) void handleDetect(); + if (event.key === "Enter" && isUrl && canCreate) void handleDetect(); }} placeholder="Search integrations, or paste a URL…" aria-label="Search integrations, or paste a URL" @@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { + + + )} {canRefresh && ( @@ -530,20 +551,23 @@ export function IntegrationDetailPage(props: { variant="destructive" size="sm" onClick={() => void handleDelete()} - disabled={deleting} + disabled={deleting || !canMutateIntegration} > {deleting ? "Deleting..." : "Confirm Delete"}
) : ( - + + + ))}
@@ -609,8 +633,8 @@ export function IntegrationDetailPage(props: { tools={integrationTools} selectedToolId={selectedToolId} onSelect={setSelectedToolId} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern) => void policyActions.clear(pattern)} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} policies={sortedPolicies} groupByConnection={!isBuiltInIntegration} emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} @@ -625,9 +649,15 @@ export function IntegrationDetailPage(props: { toolName={selectedTool.name} staticTool={selection?.static} policy={selectedTool.policy} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern, policyId) => - void policyActions.clear(pattern, policyId) + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + // The header badge must write and look up the SAME + // account-pinned pattern the tree row under this + // account uses, or it cannot recognize its own rule. + patternForDisplay={ + selection && !selection.static + ? accountPolicyPattern(selection.owner, selection.connection) + : undefined } {...(!selection?.static && selectedBareName ? { diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index a08faeaa67..b12c8fc558 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; import { @@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; const KIND_TO_PLUGIN_KEY: Record = { openapi: "openapi", @@ -48,6 +50,7 @@ export function IntegrationsPage() { useExecutorDocumentTitle("Integrations"); const integrations = useAtomValue(integrationsOptimisticAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); + const canCreate = useCanCreateWorkspaceConnections(); return ( @@ -55,15 +58,24 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - + canCreate ? ( + + ) : ( + + + + ) } /> @@ -83,7 +95,7 @@ export function IntegrationsPage() { ), onSuccess: ({ value }) => { if (value.length === 0) { - return ; + return ; } return ( @@ -102,7 +114,7 @@ export function IntegrationsPage() { // Empty state // --------------------------------------------------------------------------- -function EmptyIntegrations() { +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { return (
@@ -110,17 +122,28 @@ function EmptyIntegrations() {

No integrations yet

- Connect an integration to start curating tools. + {canCreate + ? "Connect an integration to start curating tools." + : "Ask a workspace admin to add an integration."}

- + {canCreate ? ( + + ) : ( + + + + )}
); } diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 60e6e0fa9a..cdb05d3e38 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; import { Link } from "@tanstack/react-router"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; +import { + ToolAddress, + effectivePolicyFromSorted, + type ToolPolicyAction, +} from "@executor-js/sdk/shared"; import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; import { usePolicyActions } from "../hooks/use-policy-actions"; @@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; // Dynamic tool policy patterns are derived from the connection-aware address. // Static tools (for example Executor's own tools) use their address directly. @@ -38,6 +43,15 @@ export function ToolsPage() { const refreshTools = useAtomRefresh(toolsAllAtom); const policies = useAtomValue(policiesOptimisticAtom); const policyActions = usePolicyActions("org"); + // Policies here are workspace rules, which the server refuses for non-admin + // members. Offer the menus only to those who can actually write them. + const canSetPolicy = useCanCreateWorkspaceConnections(); + const onSetPolicy = canSetPolicy + ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action) + : undefined; + const onClearPolicy = canSetPolicy + ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId) + : undefined; const [selectedToolId, setSelectedToolId] = useState(null); @@ -144,8 +158,8 @@ export function ToolsPage() { tools={summaries} selectedToolId={selectedToolId} onSelect={setSelectedToolId} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern) => void policyActions.clear(pattern)} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} policies={sortedPolicies} /> @@ -156,10 +170,8 @@ export function ToolsPage() { toolName={selectedTool.name} staticTool={selection?.static} policy={selectedTool.policy} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern, policyId) => - void policyActions.clear(pattern, policyId) - } + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} /> ) : ( 0} />