From 4848fcee864cd42716f34a550db0cdf7b8571060 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Thu, 4 Jun 2026 08:43:34 +0400 Subject: [PATCH] Replace Windsurf provider with Devin --- README.md | 2 +- docs/providers/antigravity.md | 2 +- docs/providers/devin.md | 123 +++ docs/providers/windsurf.md | 208 ----- plugins/devin/icon.svg | 5 + plugins/devin/plugin.js | 309 +++++++ plugins/{windsurf => devin}/plugin.json | 10 +- plugins/devin/plugin.test.js | 395 +++++++++ plugins/windsurf/icon.svg | 3 - plugins/windsurf/plugin.js | 193 ----- plugins/windsurf/plugin.test.js | 816 ------------------- src-tauri/src/plugin_engine/host_api.rs | 91 +++ src/hooks/app/use-settings-bootstrap.test.ts | 40 + src/hooks/app/use-settings-bootstrap.ts | 5 +- src/lib/settings.test.ts | 55 +- src/lib/settings.ts | 24 + 16 files changed, 1050 insertions(+), 1231 deletions(-) create mode 100644 docs/providers/devin.md delete mode 100644 docs/providers/windsurf.md create mode 100644 plugins/devin/icon.svg create mode 100644 plugins/devin/plugin.js rename plugins/{windsurf => devin}/plugin.json (59%) create mode 100644 plugins/devin/plugin.test.js delete mode 100644 plugins/windsurf/icon.svg delete mode 100644 plugins/windsurf/plugin.js delete mode 100644 plugins/windsurf/plugin.test.js diff --git a/README.md b/README.md index f30294c0..f720bb8a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits -- [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits +- [**Devin**](docs/providers/devin.md) / weekly quota usage, extra usage - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches Community contributions welcome. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 6fa18acc..185283f1 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -2,7 +2,7 @@ > Reverse-engineered from app bundle and language server binary. May change without notice. -Antigravity is essentially a Google-branded fork of [Windsurf](windsurf.md) — both use the same Codeium language server binary and Connect-RPC protocol. The discovery, port probing, and RPC endpoints are virtually identical. The key differences: Antigravity uses fraction-based per-model quota (not credits), and doesn't require an API key in the request metadata. +Antigravity is built on Codeium/Windsurf-derived infrastructure and uses the same Codeium language server binary and Connect-RPC protocol. The discovery, port probing, and RPC endpoints are virtually identical to that stack. The key differences: Antigravity uses fraction-based per-model quota (not credits), and doesn't require an API key in the request metadata. ## Overview diff --git a/docs/providers/devin.md b/docs/providers/devin.md new file mode 100644 index 00000000..a9b644f0 --- /dev/null +++ b/docs/providers/devin.md @@ -0,0 +1,123 @@ +# Devin + +> Reverse-engineered from Devin CLI credentials and Devin app state. May change without notice. + +## Overview + +- **Vendor:** Cognition / Devin +- **Protocol:** Connect RPC v1, JSON over HTTPS +- **Service:** `exa.seat_management_pb.SeatManagementService` +- **Auth:** local Devin session token (`devin-session-token$...`) +- **Quota:** weekly quota usage/remaining percentage +- **Extra:** overage balance in micros +- **Requires:** `devin auth login` or a signed-in Devin app + +OpenUsage does not use `api.devin.ai` for this provider. Devin's public API usage and consumption endpoints are enterprise/admin APIs and do not expose the same local account quota shown in the app. + +## Auth Sources + +The plugin checks auth in this order: + +| Order | Source | Path / key | +|---|---|---| +| 1 | Devin CLI | `~/.local/share/devin/credentials.toml` | +| 2 | Devin app | `~/Library/Application Support/Devin/User/globalStorage/state.vscdb` / `windsurfAuthStatus` | + +CLI credentials: + +```toml +windsurf_api_key = "devin-session-token$..." +api_server_url = "https://server.codeium.com" +devin_api_url = "https://api.devin.ai" +``` + +Only `windsurf_api_key` and `api_server_url` are used. If `api_server_url` is missing or invalid, the plugin uses `https://server.codeium.com`. + +App SQLite: + +```bash +sqlite3 ~/Library/Application\ Support/Devin/User/globalStorage/state.vscdb \ + "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'" +``` + +The value is JSON: + +```json +{ "apiKey": "devin-session-token$..." } +``` + +## GetUserStatus + +```http +POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus +Content-Type: application/json +Connect-Protocol-Version: 1 +``` + +Request: + +```json +{ + "metadata": { + "apiKey": "devin-session-token$...", + "ideName": "devin", + "ideVersion": "1.108.2", + "extensionName": "devin", + "extensionVersion": "1.108.2", + "locale": "en" + } +} +``` + +Response fields used: + +Observed response shape, sanitized: + +```json +{ + "userStatus": { + "teamsTier": "TEAMS_TIER_DEVIN_MAX", + "planStatus": { + "planInfo": { + "planName": "Max", + "teamsTier": "TEAMS_TIER_DEVIN_MAX", + "hideDailyQuota": true, + "isDevin": true, + "billingStrategy": "BILLING_STRATEGY_QUOTA" + }, + "availablePromptCredits": -1, + "dailyQuotaRemainingPercent": 100, + "overageBalanceMicros": "1829587876", + "dailyQuotaResetAtUnix": "1780560000", + "weeklyQuotaResetAtUnix": "1780819200" + } + }, + "planInfo": { + "planName": "Max", + "hideDailyQuota": true, + "isDevin": true + } +} +``` + +Response fields used: + +| Response field | Display | +|---|---| +| `userStatus.planStatus.planInfo.planName` | Plan label | +| `userStatus.planStatus.dailyQuotaRemainingPercent` | Weekly quota usage percent when `hideDailyQuota` is `true` and no weekly percent is present | +| `userStatus.planStatus.weeklyQuotaRemainingPercent` | Weekly quota percent if Devin starts returning it | +| `userStatus.planStatus.dailyQuotaResetAtUnix` | Daily reset time when daily quota is visible | +| `userStatus.planStatus.weeklyQuotaResetAtUnix` | Weekly reset time | +| `userStatus.planStatus.overageBalanceMicros` | Extra usage balance | +| `userStatus.planStatus.planInfo.hideDailyQuota` | Hide daily quota line when `true` | + +Devin currently returns no `weeklyQuotaRemainingPercent` field in the observed payload. When `hideDailyQuota` is `true`, OpenUsage maps `dailyQuotaRemainingPercent` as a used percentage on the visible weekly line and uses `weeklyQuotaResetAtUnix` for the reset timer. Despite the field name, `100` matches Devin's `Weekly quota usage: 100%` UI, not `100% left`. + +## Plugin Strategy + +1. Read CLI credentials. +2. Read Devin app SQLite auth if CLI credentials are missing or different. +3. POST `GetUserStatus` with `ideName: "devin"`. +4. Build weekly quota usage and extra balance lines. Show daily quota only if Devin does not mark it hidden. +5. If auth fails, show: `Run devin auth login or sign in to Devin and try again.` diff --git a/docs/providers/windsurf.md b/docs/providers/windsurf.md deleted file mode 100644 index c302cf4f..00000000 --- a/docs/providers/windsurf.md +++ /dev/null @@ -1,208 +0,0 @@ -# Windsurf - -> Reverse-engineered from app bundle, extension.js, and language server binary. May change without notice. - -Windsurf and [Antigravity](antigravity.md) share the same Codeium language server binary and Connect-RPC protocol. The discovery, port probing, and RPC endpoints are virtually identical — the key differences are authentication (Windsurf requires an API key) and the usage model (credits vs fractions). - -## Variants - -The plugin supports two Windsurf variants, probed in this order: - -| Variant | App | Bundle ID | `--ide_name` | App Support dir | -|---|---|---|---|---| -| **Windsurf** | `Windsurf.app` | `com.exafunction.windsurf` | `windsurf` | `~/Library/Application Support/Windsurf/` | -| **Windsurf Next** | `Windsurf - Next.app` | `com.exafunction.windsurfNext` | `windsurf-next` | `~/Library/Application Support/Windsurf - Next/` | - -Both use the same Codeium language server binary (`language_server_macos_arm`), same Connect-RPC service, same CSRF auth, and same `GetUserStatus` RPC. They differ only in: - -- **Process marker**: `windsurf` vs `windsurf-next` (matched via `--ide_name` exact value) -- **SQLite path**: `Windsurf/User/globalStorage/state.vscdb` vs `Windsurf - Next/User/globalStorage/state.vscdb` -- **ideName in metadata**: `windsurf` vs `windsurf-next` - -## Overview - -- **Vendor:** Codeium (Windsurf) -- **Protocol:** Connect RPC v1 (JSON over HTTP) on local language server -- **Service:** `exa.language_server_pb.LanguageServerService` -- **Auth:** API key (`sk-ws-01-...`) from SQLite + CSRF token from process args -- **Usage model:** credit-based (prompt + flex credits, stored in hundredths) -- **Billing cycle:** monthly (`planStart` / `planEnd`) -- **Timestamps:** ISO 8601 -- **Requires:** Windsurf IDE running (language server is a child process), or signed-in credentials in SQLite (cloud fallback) - -## Discovery - -Same process as [Antigravity](antigravity.md) — same binary, same flags. The distinguishing marker is the `--ide_name` flag value in the process args. - -```bash -# 1. Find process and extract CSRF token + version -ps -ax -o pid=,command= | grep 'language_server_macos' -# Windsurf: --ide_name windsurf -# Windsurf Next: --ide_name windsurf-next -# Extract: --csrf_token -# Extract: --windsurf_version -# Extract: --extension_server_port (HTTP fallback) - -# 2. Find listening ports -lsof -nP -iTCP -sTCP:LISTEN -a -p - -# 3. Probe each port to find the Connect-RPC endpoint -POST https://127.0.0.1:/.../GetUnleashData → any response wins - -# 4. Get API key from SQLite (path depends on variant) -# Windsurf: -sqlite3 ~/Library/Application\ Support/Windsurf/User/globalStorage/state.vscdb \ - "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'" -# Windsurf Next: -sqlite3 ~/Library/Application\ Support/Windsurf\ -\ Next/User/globalStorage/state.vscdb \ - "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'" -# → JSON: { apiKey: "sk-ws-01-...", ... } -``` - -Port and CSRF token change on every IDE restart. The LS may use HTTPS with a self-signed cert. - -## Headers (all local requests) - -| Header | Required | Value | -|---|---|---| -| Content-Type | yes | `application/json` | -| Connect-Protocol-Version | yes | `1` | -| x-codeium-csrf-token | yes | `` (from process args) | - -## Endpoints - -### GetUserStatus (primary) - -Returns plan info with credit-based usage for the current billing cycle. Same RPC as [Antigravity](antigravity.md), but requires `metadata.apiKey`. - -``` -POST http://127.0.0.1:{port}/exa.language_server_pb.LanguageServerService/GetUserStatus -``` - -#### Request - -```json -{ - "metadata": { - "apiKey": "sk-ws-01-...", - "ideName": "windsurf", - "ideVersion": "", - "extensionName": "windsurf", - "extensionVersion": "", - "locale": "en" - } -} -``` - -For Windsurf Next, use `"ideName": "windsurf-next"` and `"extensionName": "windsurf-next"`. - -Unlike [Antigravity](antigravity.md), Windsurf **requires** `metadata.apiKey`. The LS uses it to authenticate with the cloud backend internally. Antigravity authenticates via the Google OAuth session instead. - -#### Response (~167KB, mostly model configs) - -```jsonc -{ - "userStatus": { - "planStatus": { - "planInfo": { - "planName": "Teams", // "Free" | "Pro" | "Teams" | "Free Trial" - "monthlyPromptCredits": 50000, - "monthlyFlexCreditPurchaseAmount": 100000 - }, - "planStart": "2026-01-18T09:07:17Z", - "planEnd": "2026-02-18T09:07:17Z", - "availablePromptCredits": 50000, // total pool (in hundredths) - "usedPromptCredits": 4700, // consumed (omitted when 0) - "availableFlexCredits": 2679300, - "usedFlexCredits": 175550 - } - } -} -``` - -#### Field mapping - -| Response field | Display | Notes | -|---|---|---| -| `availablePromptCredits` | Total credit pool | Divide by 100 for display (50000 → 500) | -| `usedPromptCredits` | Credits consumed | Divide by 100 | -| `planStart` / `planEnd` | Billing cycle | ISO 8601, used for pacing | -| negative `available*` | Unlimited | Skip that credit line | - -## Differences from Antigravity - -| | Windsurf | Antigravity | -|---|---|---| -| **Auth** | API key (`sk-ws-01-`) required in metadata | No API key needed (CSRF only) | -| **Usage model** | Credit-based (prompt + flex) | Fraction-based per model (0.0–1.0) | -| **Credit units** | Stored in hundredths (÷100 for display) | N/A | -| **Billing cycle** | Monthly (`planStart`/`planEnd`) | 5-hour rolling windows per model | -| **Version flag** | `--windsurf_version` | N/A | -| **Token location** | SQLite `windsurfAuthStatus` → `apiKey` | Not needed | -| **Models shown** | Not used (credits are the metric) | Per-model quota bars | - -Both use the same Codeium language server binary, same Connect-RPC service, same CSRF auth, same discovery process (ps + lsof + port probe), and same `GetUserStatus` RPC. - -## Token Location - -SQLite database — path depends on variant: - -| Variant | Path | -|---|---| -| Windsurf | `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` | -| Windsurf Next | `~/Library/Application Support/Windsurf - Next/User/globalStorage/state.vscdb` | - -| Key | Value | -|---|---| -| `windsurfAuthStatus` | JSON: `{ apiKey: "sk-ws-01-...", ... }` | - -## Cloud API (fallback) - -When the language server is not running, the plugin falls back to Codeium's cloud API using the API key from SQLite. - -### GetUserStatus (cloud) - -``` -POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus -``` - -#### Headers - -| Header | Required | Value | -|---|---|---| -| Content-Type | yes | `application/json` | -| Connect-Protocol-Version | yes | `1` | - -#### Request - -```json -{ - "metadata": { - "apiKey": "sk-ws-01-...", - "ideName": "windsurf", - "ideVersion": "0.0.0", - "extensionName": "windsurf", - "extensionVersion": "0.0.0", - "locale": "en" - } -} -``` - -Same metadata shape as the local LS endpoint. Required fields: `apiKey`, `ideName`, `ideVersion`, `extensionName`, and `extensionVersion`. Use semver versions (for example `"0.0.0"`). No CSRF token is required for cloud requests. - -#### Response - -Same `userStatus.planStatus` shape as the local LS response — credit fields in hundredths. - -Cloud fallback uses the same API key from SQLite (`windsurfAuthStatus`). - -## Plugin Strategy - -1. Try each variant in order: Windsurf → Windsurf Next -2. Discover LS process via `ctx.host.ls.discover()` (ps + lsof) with variant-specific marker -3. Read API key from SQLite (`windsurfAuthStatus`) at variant-specific path -4. Probe ports with `GetUnleashData` to find the Connect-RPC endpoint -5. Call local `GetUserStatus` with `apiKey` and variant-specific `ideName` in metadata -6. Build prompt + flex credit lines with billing cycle pacing (÷100 for display) -7. If LS probe fails for all variants, call cloud `GetUserStatus` at `server.codeium.com` with API key in metadata -8. If both local and cloud paths fail: error "Start Windsurf or sign in and try again." diff --git a/plugins/devin/icon.svg b/plugins/devin/icon.svg new file mode 100644 index 00000000..f7dfc277 --- /dev/null +++ b/plugins/devin/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/devin/plugin.js b/plugins/devin/plugin.js new file mode 100644 index 00000000..3388bd7d --- /dev/null +++ b/plugins/devin/plugin.js @@ -0,0 +1,309 @@ +(function () { + var CLOUD_SERVICE = "exa.seat_management_pb.SeatManagementService" + var DEFAULT_API_SERVER_URL = "https://server.codeium.com" + var CLOUD_COMPAT_VERSION = "1.108.2" + var CREDENTIALS_PATH = "~/.local/share/devin/credentials.toml" + var STATE_DB = "~/Library/Application Support/Devin/User/globalStorage/state.vscdb" + var LOGIN_HINT = "Run devin auth login or sign in to Devin and try again." + var QUOTA_HINT = "Devin quota data unavailable. Try again later." + var DAY_MS = 24 * 60 * 60 * 1000 + var WEEK_MS = 7 * DAY_MS + + function readFiniteNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value !== "string") return null + var trimmed = value.trim() + if (!trimmed) return null + var parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + + function clampPercent(value) { + if (!Number.isFinite(value)) return 0 + if (value < 0) return 0 + if (value > 100) return 100 + return value + } + + function readTomlString(text, key) { + var lines = String(text || "").split(/\r?\n/) + var prefix = new RegExp("^\\s*" + key + "\\s*=\\s*(.*)$") + for (var i = 0; i < lines.length; i++) { + var match = prefix.exec(lines[i]) + if (!match) continue + var value = match[1].trim() + if (!value) return null + if (value[0] === '"' || value[0] === "'") { + var quote = value[0] + var out = "" + for (var j = 1; j < value.length; j++) { + var ch = value[j] + if (ch === quote && value[j - 1] !== "\\") return out.trim() || null + out += ch + } + return null + } + var commentIndex = value.indexOf("#") + if (commentIndex >= 0) value = value.slice(0, commentIndex).trim() + return value || null + } + return null + } + + function cleanApiServerUrl(value) { + if (typeof value !== "string") return null + var trimmed = value.trim().replace(/\/+$/, "") + if (!/^https:\/\//.test(trimmed)) return null + return trimmed + } + + function effectiveApiServerUrl(auth) { + return (auth && auth.apiServerUrl) || DEFAULT_API_SERVER_URL + } + + function hasOwn(obj, key) { + return Boolean(obj && Object.prototype.hasOwnProperty.call(obj, key)) + } + + function readHost(value) { + if (typeof value !== "string") return null + var match = /^https?:\/\/([^/]+)/.exec(value.trim()) + return match ? match[1] : null + } + + function valueOrMissing(value) { + return value === null || value === undefined || value === "" ? "missing" : String(value) + } + + function logQuotaDiagnostics(ctx, auth, userStatus) { + var planStatus = (userStatus && userStatus.planStatus) || {} + var planInfo = planStatus.planInfo || {} + var devinInfo = planInfo.devinInfo || {} + var apiServerHost = readHost(auth.apiServerUrl || DEFAULT_API_SERVER_URL) + var webappHost = readHost(devinInfo.webappHost) || devinInfo.webappHost || null + var devinApiHost = readHost(devinInfo.apiUrl) + + ctx.host.log.info( + "Devin quota diagnostics" + + " source=" + auth.source + + " apiServerHost=" + valueOrMissing(apiServerHost) + + " planName=" + valueOrMissing(planInfo.planName) + + " teamsTier=" + valueOrMissing(userStatus && userStatus.teamsTier) + + " planTeamsTier=" + valueOrMissing(planInfo.teamsTier) + + " billingStrategy=" + valueOrMissing(planInfo.billingStrategy) + + " isDevin=" + String(planInfo.isDevin === true) + + " hideDailyQuota=" + String(planInfo.hideDailyQuota === true) + + " hasDailyQuotaPercent=" + String(hasOwn(planStatus, "dailyQuotaRemainingPercent")) + + " hasWeeklyQuotaPercent=" + String(hasOwn(planStatus, "weeklyQuotaRemainingPercent")) + + " hasOverageBalance=" + String(hasOwn(planStatus, "overageBalanceMicros")) + + " hasDailyReset=" + String(hasOwn(planStatus, "dailyQuotaResetAtUnix")) + + " hasWeeklyReset=" + String(hasOwn(planStatus, "weeklyQuotaResetAtUnix")) + + " hasTopUpStatus=" + String(hasOwn(planStatus, "topUpStatus")) + + " availablePromptCredits=" + valueOrMissing(planStatus.availablePromptCredits) + + " canUseCli=" + String(devinInfo.canUseCli === true) + + " canUseCascade=" + String(devinInfo.canUseCascade === true) + + " devinReviewEnabled=" + String(devinInfo.devinReviewEnabled === true) + + " webappHost=" + valueOrMissing(webappHost) + + " devinApiHost=" + valueOrMissing(devinApiHost) + ) + } + + function loadCredentialsFile(ctx) { + if (!ctx.host.fs.exists(CREDENTIALS_PATH)) return null + try { + var text = ctx.host.fs.readText(CREDENTIALS_PATH) + var apiKey = readTomlString(text, "windsurf_api_key") + if (!apiKey) { + ctx.host.log.warn("Devin credentials missing windsurf_api_key") + return null + } + return { + apiKey: apiKey, + apiServerUrl: cleanApiServerUrl(readTomlString(text, "api_server_url")), + source: "credentials.toml", + } + } catch (e) { + ctx.host.log.warn("failed to read Devin credentials: " + String(e)) + return null + } + } + + function loadAppAuth(ctx) { + try { + var rows = ctx.host.sqlite.query( + STATE_DB, + "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" + ) + var parsed = ctx.util.tryParseJson(rows) + if (!parsed || !parsed.length || !parsed[0].value) return null + var auth = ctx.util.tryParseJson(parsed[0].value) + if (!auth || !auth.apiKey) return null + return { + apiKey: auth.apiKey, + apiServerUrl: null, + source: "Devin app", + } + } catch (e) { + ctx.host.log.warn("failed to read Devin app auth: " + String(e)) + return null + } + } + + function callCloud(ctx, auth) { + var apiServerUrl = effectiveApiServerUrl(auth) + try { + var resp = ctx.host.http.request({ + method: "POST", + url: apiServerUrl + "/" + CLOUD_SERVICE + "/GetUserStatus", + headers: { + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + bodyText: JSON.stringify({ + metadata: { + apiKey: auth.apiKey, + ideName: "devin", + ideVersion: CLOUD_COMPAT_VERSION, + extensionName: "devin", + extensionVersion: CLOUD_COMPAT_VERSION, + locale: "en", + }, + }), + timeoutMs: 15000, + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("cloud request returned status " + resp.status + " for " + auth.source) + if (ctx.util && typeof ctx.util.isAuthStatus === "function" && ctx.util.isAuthStatus(resp.status)) { + return { __openusageAuthError: true } + } + return null + } + return ctx.util.tryParseJson(resp.bodyText) + } catch (e) { + ctx.host.log.warn("cloud request failed for " + auth.source + ": " + String(e)) + return null + } + } + + function tryAuth(ctx, auth) { + var data = callCloud(ctx, auth) + if (data && data.__openusageAuthError) { + return { authFailure: true } + } + if (!data || !data.userStatus) return {} + + try { + logQuotaDiagnostics(ctx, auth, data.userStatus) + return { output: buildOutput(ctx, data.userStatus) } + } catch (e) { + if (e === QUOTA_HINT) { + ctx.host.log.warn("quota contract unavailable for " + auth.source) + return {} + } + throw e + } + } + + function unixSecondsToIso(ctx, value) { + var seconds = readFiniteNumber(value) + if (seconds === null) return null + return ctx.util.toIso(seconds * 1000) + } + + function formatDollarsFromMicros(value) { + var micros = readFiniteNumber(value) + if (micros === null) return null + if (!Number.isFinite(micros)) return null + if (micros < 0) micros = 0 + return "$" + (micros / 1000000).toFixed(2) + } + + function buildQuotaLine(ctx, label, remaining, resetsAt, periodDurationMs) { + if (remaining === null) return null + return buildUsedQuotaLine(ctx, label, 100 - remaining, resetsAt, periodDurationMs) + } + + function buildUsedQuotaLine(ctx, label, used, resetsAt, periodDurationMs) { + if (used === null) return null + var line = { + label: label, + used: clampPercent(used), + limit: 100, + format: { kind: "percent" }, + periodDurationMs: periodDurationMs, + } + if (resetsAt) line.resetsAt = resetsAt + return ctx.line.progress(line) + } + + function buildOutput(ctx, userStatus) { + var planStatus = (userStatus && userStatus.planStatus) || {} + + var planInfo = planStatus.planInfo || {} + var planName = typeof planInfo.planName === "string" && planInfo.planName.trim() + ? planInfo.planName.trim() + : "Unknown" + + var hideDailyQuota = planInfo.hideDailyQuota === true + var dailyRemaining = readFiniteNumber(planStatus.dailyQuotaRemainingPercent) + var weeklyRemaining = readFiniteNumber(planStatus.weeklyQuotaRemainingPercent) + var dailyReset = !hideDailyQuota ? unixSecondsToIso(ctx, planStatus.dailyQuotaResetAtUnix) : null + var weeklyReset = unixSecondsToIso(ctx, planStatus.weeklyQuotaResetAtUnix) + var extraUsageBalance = formatDollarsFromMicros(planStatus.overageBalanceMicros) + + var dailyLine = !hideDailyQuota + ? buildQuotaLine(ctx, "Daily quota", dailyRemaining, dailyReset, DAY_MS) + : null + var weeklyLine = weeklyRemaining !== null + ? buildQuotaLine(ctx, "Weekly quota usage", weeklyRemaining, weeklyReset, WEEK_MS) + : hideDailyQuota + ? buildUsedQuotaLine(ctx, "Weekly quota usage", dailyRemaining, weeklyReset, WEEK_MS) + : null + + var lines = [] + if (dailyLine) lines.push(dailyLine) + if (weeklyLine) lines.push(weeklyLine) + if (extraUsageBalance) { + lines.push(ctx.line.text({ label: "Extra usage balance", value: extraUsageBalance })) + } + + if (!lines.length) throw QUOTA_HINT + + return { + plan: planName, + lines: lines, + } + } + + function probe(ctx) { + var sawApiKey = false + var sawAuthFailure = false + var credentials = loadCredentialsFile(ctx) + + if (credentials) { + sawApiKey = true + var credentialsAttempt = tryAuth(ctx, credentials) + if (credentialsAttempt.output) return credentialsAttempt.output + if (credentialsAttempt.authFailure) sawAuthFailure = true + } + + var appAuth = loadAppAuth(ctx) + if ( + appAuth && + (!credentials || + appAuth.apiKey !== credentials.apiKey || + effectiveApiServerUrl(appAuth) !== effectiveApiServerUrl(credentials)) + ) { + sawApiKey = true + var appAttempt = tryAuth(ctx, appAuth) + if (appAttempt.output) return appAttempt.output + if (appAttempt.authFailure) sawAuthFailure = true + } + + if (sawAuthFailure) throw LOGIN_HINT + if (sawApiKey) throw QUOTA_HINT + throw LOGIN_HINT + } + + globalThis.__openusage_plugin = { id: "devin", probe: probe } +})() diff --git a/plugins/windsurf/plugin.json b/plugins/devin/plugin.json similarity index 59% rename from plugins/windsurf/plugin.json rename to plugins/devin/plugin.json index 65a25e9c..e3c60d2a 100644 --- a/plugins/windsurf/plugin.json +++ b/plugins/devin/plugin.json @@ -1,14 +1,14 @@ { "schemaVersion": 1, - "id": "windsurf", - "name": "Windsurf", + "id": "devin", + "name": "Devin", "version": "0.0.1", "entry": "plugin.js", "icon": "icon.svg", - "brandColor": "#111111", + "brandColor": "#000000", "lines": [ - { "type": "progress", "label": "Daily quota", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly quota", "scope": "overview" }, + { "type": "progress", "label": "Weekly quota usage", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Daily quota", "scope": "overview" }, { "type": "text", "label": "Extra usage balance", "scope": "detail" } ] } diff --git a/plugins/devin/plugin.test.js b/plugins/devin/plugin.test.js new file mode 100644 index 00000000..a10197ce --- /dev/null +++ b/plugins/devin/plugin.test.js @@ -0,0 +1,395 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const CREDENTIALS_PATH = "~/.local/share/devin/credentials.toml" +const STATE_DB = "~/Library/Application Support/Devin/User/globalStorage/state.vscdb" +const DEFAULT_API_SERVER_URL = "https://server.codeium.com" +const CLOUD_COMPAT_VERSION = "1.108.2" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +function makeCredentialsToml({ + apiKey = "devin-session-token$cli", + apiServerUrl = "https://server.codeium.test", +} = {}) { + return [ + `windsurf_api_key = "${apiKey}"`, + `api_server_url = "${apiServerUrl}"`, + 'devin_api_url = "https://api.devin.ai"', + ].join("\n") +} + +function makeAuthStatus(apiKey = "devin-session-token$app") { + return JSON.stringify([{ value: JSON.stringify({ apiKey }) }]) +} + +function makeQuotaResponse(overrides = {}) { + const base = { + userStatus: { + planStatus: { + planInfo: { + planName: "Max", + billingStrategy: "BILLING_STRATEGY_QUOTA", + }, + dailyQuotaRemainingPercent: 100, + weeklyQuotaRemainingPercent: 40, + overageBalanceMicros: "964220000", + dailyQuotaResetAtUnix: "1774080000", + weeklyQuotaResetAtUnix: "1774166400", + }, + }, + } + + base.userStatus.planStatus = { + ...base.userStatus.planStatus, + ...overrides, + planInfo: { + ...base.userStatus.planStatus.planInfo, + ...(overrides.planInfo || {}), + }, + } + + return base +} + +function mockAppAuth(ctx, apiKey = "devin-session-token$app") { + ctx.host.sqlite.query.mockImplementation((db, sql) => { + expect(db).toBe(STATE_DB) + expect(String(sql)).toContain("windsurfAuthStatus") + return makeAuthStatus(apiKey) + }) +} + +describe("devin plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("loads CLI credentials first and renders quota lines", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse()), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(plugin.id).toBe("devin") + expect(result.plan).toBe("Max") + expect(result.lines).toEqual([ + { + type: "progress", + label: "Daily quota", + used: 0, + limit: 100, + format: { kind: "percent" }, + resetsAt: "2026-03-21T08:00:00.000Z", + periodDurationMs: 24 * 60 * 60 * 1000, + }, + { + type: "progress", + label: "Weekly quota usage", + used: 60, + limit: 100, + format: { kind: "percent" }, + resetsAt: "2026-03-22T08:00:00.000Z", + periodDurationMs: 7 * 24 * 60 * 60 * 1000, + }, + { + type: "text", + label: "Extra usage balance", + value: "$964.22", + }, + ]) + + expect(ctx.host.sqlite.query).not.toHaveBeenCalled() + const request = ctx.host.http.request.mock.calls[0][0] + expect(request.url).toBe( + "https://server.codeium.test/exa.seat_management_pb.SeatManagementService/GetUserStatus" + ) + const sentBody = JSON.parse(String(request.bodyText)) + expect(sentBody.metadata.apiKey).toBe("devin-session-token$cli") + expect(sentBody.metadata.ideName).toBe("devin") + expect(sentBody.metadata.extensionName).toBe("devin") + expect(sentBody.metadata.ideVersion).toBe(CLOUD_COMPAT_VERSION) + expect(sentBody.metadata.extensionVersion).toBe(CLOUD_COMPAT_VERSION) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("Devin quota diagnostics source=credentials.toml") + ) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("planName=Max") + ) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("hasWeeklyQuotaPercent=true") + ) + }) + + it("falls back to Devin app SQLite auth and the default API server", async () => { + const ctx = makeCtx() + mockAppAuth(ctx) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + const request = ctx.host.http.request.mock.calls[0][0] + expect(request.url).toBe( + "https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus" + ) + const sentBody = JSON.parse(String(request.bodyText)) + expect(sentBody.metadata.apiKey).toBe("devin-session-token$app") + }) + + it("ignores plaintext API server URLs from CLI credentials", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml({ + apiServerUrl: "http://server.codeium.test", + })) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse()), + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.http.request.mock.calls[0][0].url).toBe( + `${DEFAULT_API_SERVER_URL}/exa.seat_management_pb.SeatManagementService/GetUserStatus` + ) + }) + + it("falls back from expired CLI auth to app auth", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + mockAppAuth(ctx) + ctx.host.http.request.mockImplementation((request) => { + const body = JSON.parse(String(request.bodyText)) + if (body.metadata.apiKey === "devin-session-token$cli") { + return { status: 401, bodyText: "{}" } + } + return { + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Teams" } })), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Teams") + expect(ctx.host.http.request).toHaveBeenCalledTimes(2) + }) + + it("does not call the app auth path twice when both sources have the same token", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml({ + apiServerUrl: DEFAULT_API_SERVER_URL, + })) + mockAppAuth(ctx, "devin-session-token$cli") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse()), + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.http.request).toHaveBeenCalledTimes(1) + }) + + it("retries app auth when the same token has a different server URL", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml({ + apiKey: "devin-session-token$same", + apiServerUrl: "https://stale.codeium.test", + })) + mockAppAuth(ctx, "devin-session-token$same") + ctx.host.http.request.mockImplementation((request) => { + if (request.url.startsWith("https://stale.codeium.test/")) { + return { status: 500, bodyText: "{}" } + } + return { + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Teams" } })), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Teams") + expect(ctx.host.http.request).toHaveBeenCalledTimes(2) + expect(ctx.host.http.request.mock.calls.map(([request]) => request.url)).toEqual([ + "https://stale.codeium.test/exa.seat_management_pb.SeatManagementService/GetUserStatus", + `${DEFAULT_API_SERVER_URL}/exa.seat_management_pb.SeatManagementService/GetUserStatus`, + ]) + }) + + it("throws the login hint when no auth source is available", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow("Run devin auth login or sign in to Devin and try again.") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("treats malformed credentials as missing auth", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, 'api_server_url = "https://server.codeium.test"') + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow("Run devin auth login or sign in to Devin and try again.") + expect(ctx.host.log.warn).toHaveBeenCalledWith("Devin credentials missing windsurf_api_key") + }) + + it("uses Devin's hidden daily quota field as weekly usage when weekly percentage is absent", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify( + makeQuotaResponse({ + planInfo: { hideDailyQuota: true }, + weeklyQuotaRemainingPercent: undefined, + }) + ), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Daily quota")).toBeUndefined() + expect(result.lines.find((line) => line.label === "Weekly quota usage")).toMatchObject({ + type: "progress", + used: 100, + limit: 100, + format: { kind: "percent" }, + resetsAt: "2026-03-22T08:00:00.000Z", + periodDurationMs: 7 * 24 * 60 * 60 * 1000, + }) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("hideDailyQuota=true") + ) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("hasWeeklyQuotaPercent=false") + ) + expect(result.lines.find((line) => line.label === "Extra usage balance")?.value).toBe("$964.22") + }) + + it("renders quota percentages when reset timestamps are absent", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify( + makeQuotaResponse({ + dailyQuotaResetAtUnix: undefined, + weeklyQuotaResetAtUnix: undefined, + }) + ), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const dailyLine = result.lines.find((line) => line.label === "Daily quota") + const weeklyLine = result.lines.find((line) => line.label === "Weekly quota usage") + expect(dailyLine).toMatchObject({ + type: "progress", + used: 0, + limit: 100, + format: { kind: "percent" }, + periodDurationMs: 24 * 60 * 60 * 1000, + }) + expect(weeklyLine).toMatchObject({ + type: "progress", + used: 60, + limit: 100, + format: { kind: "percent" }, + periodDurationMs: 7 * 24 * 60 * 60 * 1000, + }) + expect(dailyLine).not.toHaveProperty("resetsAt") + expect(weeklyLine).not.toHaveProperty("resetsAt") + }) + + it("throws quota unavailable when no displayable fields are present", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify( + makeQuotaResponse({ + dailyQuotaRemainingPercent: undefined, + weeklyQuotaRemainingPercent: undefined, + overageBalanceMicros: undefined, + }) + ), + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Devin quota data unavailable. Try again later.") + }) + + it("omits daily quota when Devin marks it hidden", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify( + makeQuotaResponse({ + planInfo: { hideDailyQuota: true }, + dailyQuotaRemainingPercent: undefined, + dailyQuotaResetAtUnix: undefined, + }) + ), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Daily quota")).toBeUndefined() + expect(result.lines.find((line) => line.label === "Weekly quota usage")?.used).toBe(60) + }) + + it("renders quota lines when Devin omits extra usage balance", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ overageBalanceMicros: undefined })), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines).toHaveLength(2) + expect(result.lines.find((line) => line.label === "Extra usage balance")).toBeUndefined() + }) + + it("does not probe the local language server or localhost", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse()), + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.ls.discover).not.toHaveBeenCalled() + const urls = ctx.host.http.request.mock.calls.map((call) => String(call[0].url)) + expect(urls.every((url) => url.includes("exa.seat_management_pb.SeatManagementService"))).toBe(true) + expect(urls.some((url) => url.includes("127.0.0.1"))).toBe(false) + }) +}) diff --git a/plugins/windsurf/icon.svg b/plugins/windsurf/icon.svg deleted file mode 100644 index 7d927c2d..00000000 --- a/plugins/windsurf/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js deleted file mode 100644 index eb167b60..00000000 --- a/plugins/windsurf/plugin.js +++ /dev/null @@ -1,193 +0,0 @@ -(function () { - var CLOUD_SERVICE = "exa.seat_management_pb.SeatManagementService" - var CLOUD_URL = "https://server.self-serve.windsurf.com" - var CLOUD_COMPAT_VERSION = "1.108.2" - var LOGIN_HINT = "Start Windsurf or sign in and try again." - var QUOTA_HINT = "Windsurf quota data unavailable. Try again later." - var DAY_MS = 24 * 60 * 60 * 1000 - var WEEK_MS = 7 * DAY_MS - - var VARIANTS = [ - { - marker: "windsurf", - ideName: "windsurf", - stateDb: "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", - }, - { - marker: "windsurf-next", - ideName: "windsurf-next", - stateDb: "~/Library/Application Support/Windsurf - Next/User/globalStorage/state.vscdb", - }, - ] - - function readFiniteNumber(value) { - if (typeof value === "number") return Number.isFinite(value) ? value : null - if (typeof value !== "string") return null - var trimmed = value.trim() - if (!trimmed) return null - var parsed = Number(trimmed) - return Number.isFinite(parsed) ? parsed : null - } - - function clampPercent(value) { - if (!Number.isFinite(value)) return 0 - if (value < 0) return 0 - if (value > 100) return 100 - return value - } - - function loadApiKey(ctx, variant) { - try { - var rows = ctx.host.sqlite.query( - variant.stateDb, - "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" - ) - var parsed = ctx.util.tryParseJson(rows) - if (!parsed || !parsed.length || !parsed[0].value) return null - var auth = ctx.util.tryParseJson(parsed[0].value) - if (!auth || !auth.apiKey) return null - return auth.apiKey - } catch (e) { - ctx.host.log.warn("failed to read API key from " + variant.marker + ": " + String(e)) - return null - } - } - - function callCloud(ctx, apiKey, variant) { - try { - var resp = ctx.host.http.request({ - method: "POST", - url: CLOUD_URL + "/" + CLOUD_SERVICE + "/GetUserStatus", - headers: { - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - }, - bodyText: JSON.stringify({ - metadata: { - apiKey: apiKey, - ideName: variant.ideName, - ideVersion: CLOUD_COMPAT_VERSION, - extensionName: variant.ideName, - extensionVersion: CLOUD_COMPAT_VERSION, - locale: "en", - }, - }), - timeoutMs: 15000, - }) - if (resp.status < 200 || resp.status >= 300) { - ctx.host.log.warn("cloud request returned status " + resp.status + " for " + variant.marker) - if (ctx.util && typeof ctx.util.isAuthStatus === "function" && ctx.util.isAuthStatus(resp.status)) { - return { __openusageAuthError: true } - } - return null - } - return ctx.util.tryParseJson(resp.bodyText) - } catch (e) { - ctx.host.log.warn("cloud request failed for " + variant.marker + ": " + String(e)) - return null - } - } - - function unixSecondsToIso(ctx, value) { - var seconds = readFiniteNumber(value) - if (seconds === null) return null - return ctx.util.toIso(seconds * 1000) - } - - function formatDollarsFromMicros(value) { - var micros = readFiniteNumber(value) - if (micros === null) return null - if (!Number.isFinite(micros)) return null - if (micros < 0) micros = 0 - return "$" + (micros / 1000000).toFixed(2) - } - - function buildQuotaLine(ctx, label, remainingPercent, resetsAt, periodDurationMs) { - var remaining = readFiniteNumber(remainingPercent) - if (remaining === null) return null - var line = { - label: label, - used: clampPercent(100 - remaining), - limit: 100, - format: { kind: "percent" }, - periodDurationMs: periodDurationMs, - } - if (resetsAt) line.resetsAt = resetsAt - return ctx.line.progress(line) - } - - function hasQuotaContract(planStatus) { - return ( - readFiniteNumber(planStatus && planStatus.dailyQuotaRemainingPercent) !== null && - readFiniteNumber(planStatus && planStatus.weeklyQuotaRemainingPercent) !== null && - readFiniteNumber(planStatus && planStatus.dailyQuotaResetAtUnix) !== null && - readFiniteNumber(planStatus && planStatus.weeklyQuotaResetAtUnix) !== null - ) - } - - function buildOutput(ctx, userStatus) { - var planStatus = (userStatus && userStatus.planStatus) || {} - if (!hasQuotaContract(planStatus)) throw QUOTA_HINT - - var planInfo = planStatus.planInfo || {} - var planName = typeof planInfo.planName === "string" && planInfo.planName.trim() - ? planInfo.planName.trim() - : "Unknown" - - var dailyReset = unixSecondsToIso(ctx, planStatus.dailyQuotaResetAtUnix) - var weeklyReset = unixSecondsToIso(ctx, planStatus.weeklyQuotaResetAtUnix) - var extraUsageBalance = formatDollarsFromMicros(planStatus.overageBalanceMicros) - - if (!dailyReset || !weeklyReset) throw QUOTA_HINT - - var dailyLine = buildQuotaLine(ctx, "Daily quota", planStatus.dailyQuotaRemainingPercent, dailyReset, DAY_MS) - var weeklyLine = buildQuotaLine(ctx, "Weekly quota", planStatus.weeklyQuotaRemainingPercent, weeklyReset, WEEK_MS) - - if (!dailyLine || !weeklyLine) throw QUOTA_HINT - - var lines = [dailyLine, weeklyLine] - if (extraUsageBalance) { - lines.push(ctx.line.text({ label: "Extra usage balance", value: extraUsageBalance })) - } - - return { - plan: planName, - lines: lines, - } - } - - function probe(ctx) { - var sawApiKey = false - var sawAuthFailure = false - - for (var i = 0; i < VARIANTS.length; i++) { - var variant = VARIANTS[i] - var apiKey = loadApiKey(ctx, variant) - if (!apiKey) continue - sawApiKey = true - - var data = callCloud(ctx, apiKey, variant) - if (data && data.__openusageAuthError) { - sawAuthFailure = true - continue - } - if (!data || !data.userStatus) continue - - try { - return buildOutput(ctx, data.userStatus) - } catch (e) { - if (e === QUOTA_HINT) { - ctx.host.log.warn("quota contract unavailable for " + variant.marker) - continue - } - throw e - } - } - - if (sawAuthFailure) throw LOGIN_HINT - if (sawApiKey) throw QUOTA_HINT - throw LOGIN_HINT - } - - globalThis.__openusage_plugin = { id: "windsurf", probe: probe } -})() diff --git a/plugins/windsurf/plugin.test.js b/plugins/windsurf/plugin.test.js deleted file mode 100644 index 49b25b6f..00000000 --- a/plugins/windsurf/plugin.test.js +++ /dev/null @@ -1,816 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" -import { makeCtx } from "../test-helpers.js" - -const CLOUD_COMPAT_VERSION = "1.108.2" - -const loadPlugin = async () => { - await import("./plugin.js") - return globalThis.__openusage_plugin -} - -function makeAuthStatus(apiKey = "sk-ws-01-test") { - return JSON.stringify([{ value: JSON.stringify({ apiKey }) }]) -} - -function makeQuotaResponse(overrides) { - const base = { - userStatus: { - planStatus: { - planInfo: { - planName: "Teams", - billingStrategy: "BILLING_STRATEGY_QUOTA", - }, - dailyQuotaRemainingPercent: 100, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, -} - - if (overrides) { - base.userStatus.planStatus = { - ...base.userStatus.planStatus, - ...overrides, - planInfo: { - ...base.userStatus.planStatus.planInfo, - ...(overrides.planInfo || {}), - }, - } - } - - return base -} - -function setupCloudMock(ctx, { stableAuth, nextAuth, stableResponse, nextResponse }) { - ctx.host.sqlite.query.mockImplementation((db, sql) => { - if (!String(sql).includes("windsurfAuthStatus")) return "[]" - if (String(db).includes("Windsurf - Next")) { - return nextAuth ? makeAuthStatus(nextAuth) : "[]" - } - if (String(db).includes("Windsurf/User/globalStorage")) { - return stableAuth ? makeAuthStatus(stableAuth) : "[]" - } - return "[]" - }) - - ctx.host.http.request.mockImplementation((reqOpts) => { - const body = JSON.parse(String(reqOpts.bodyText || "{}")) - const ideName = body.metadata && body.metadata.ideName - if (ideName === "windsurf-next") { - if (nextResponse instanceof Error) throw nextResponse - return nextResponse || { status: 500, bodyText: "{}" } - } - if (ideName === "windsurf") { - if (stableResponse instanceof Error) throw stableResponse - return stableResponse || { status: 500, bodyText: "{}" } - } - return { status: 500, bodyText: "{}" } - }) -} - -describe("windsurf plugin", () => { - beforeEach(() => { - delete globalThis.__openusage_plugin - vi.resetModules() - }) - - it("renders quota-only lines from the cloud response", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Teams") - expect(result.lines).toHaveLength(3) - - expect(result.lines.find((line) => line.label === "Plan")).toBeUndefined() - - expect(result.lines.find((line) => line.label === "Daily quota")).toEqual({ - type: "progress", - label: "Daily quota", - used: 0, - limit: 100, - format: { kind: "percent" }, - resetsAt: "2026-03-21T08:00:00.000Z", - periodDurationMs: 24 * 60 * 60 * 1000, - }) - - expect(result.lines.find((line) => line.label === "Weekly quota")).toEqual({ - type: "progress", - label: "Weekly quota", - used: 0, - limit: 100, - format: { kind: "percent" }, - resetsAt: "2026-03-22T08:00:00.000Z", - periodDurationMs: 7 * 24 * 60 * 60 * 1000, - }) - - expect(result.lines.find((line) => line.label === "Extra usage balance")).toEqual({ - type: "text", - label: "Extra usage balance", - value: "$964.22", - }) - expect(result.lines.find((line) => line.label === "Plan window")).toBeUndefined() - }) - - it("uses windsurf-next metadata when only the Next auth DB is available", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - nextAuth: "sk-ws-01-next", - nextResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Pro") - - const sentBody = JSON.parse(String(ctx.host.http.request.mock.calls[0][0].bodyText)) - expect(sentBody.metadata.ideName).toBe("windsurf-next") - expect(sentBody.metadata.extensionName).toBe("windsurf-next") - expect(sentBody.metadata.ideVersion).toBe(CLOUD_COMPAT_VERSION) - expect(sentBody.metadata.extensionVersion).toBe(CLOUD_COMPAT_VERSION) - }) - - it("falls through when the first variant returns 200 without userStatus", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { status: 200, bodyText: "{}" }, - nextResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Next" } })), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Next") - expect(ctx.host.http.request).toHaveBeenCalledTimes(2) - }) - - it("falls through when the first variant returns a non-quota payload", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { - status: 200, - bodyText: JSON.stringify({ - userStatus: { - planStatus: { - planInfo: { planName: "Legacy" }, - availablePromptCredits: 50000, - }, - }, - }), - }, - nextResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Next" } })), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Next") - expect(ctx.host.http.request).toHaveBeenCalledTimes(2) - expect(ctx.host.log.warn).toHaveBeenCalledWith("quota contract unavailable for windsurf") - }) - - it("prefers Windsurf over Windsurf Next when both auth DBs are available", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - nextResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Next" } })), - }, - }) - - const plugin = await loadPlugin() - plugin.probe(ctx) - - expect(ctx.host.http.request).toHaveBeenCalledTimes(1) - const sentBody = JSON.parse(String(ctx.host.http.request.mock.calls[0][0].bodyText)) - expect(sentBody.metadata.ideName).toBe("windsurf") - }) - - it("calculates percentage usage from remaining daily and weekly quota", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify( - makeQuotaResponse({ - dailyQuotaRemainingPercent: 65, - weeklyQuotaRemainingPercent: 25, - }) - ), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.lines.find((line) => line.label === "Daily quota")?.used).toBe(35) - expect(result.lines.find((line) => line.label === "Weekly quota")?.used).toBe(75) - }) - - it("clamps out-of-range quota percentages into the 0-100 display range", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify( - makeQuotaResponse({ - dailyQuotaRemainingPercent: -20, - weeklyQuotaRemainingPercent: 150, - }) - ), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.lines.find((line) => line.label === "Daily quota")?.used).toBe(100) - expect(result.lines.find((line) => line.label === "Weekly quota")?.used).toBe(0) - }) - - it("falls back to Unknown plan and clamps negative extra usage balance to zero", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify( - makeQuotaResponse({ - planInfo: { planName: " " }, - overageBalanceMicros: "-5000000", - }) - ), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Unknown") - expect(result.lines.find((line) => line.label === "Extra usage balance")?.value).toBe("$0.00") - }) - - it("renders quota lines when Windsurf omits extra usage balance", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ overageBalanceMicros: undefined })), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Teams") - expect(result.lines).toHaveLength(2) - expect(result.lines.find((line) => line.label === "Daily quota")?.used).toBe(0) - expect(result.lines.find((line) => line.label === "Weekly quota")?.used).toBe(0) - expect(result.lines.find((line) => line.label === "Extra usage balance")).toBeUndefined() - }) - - it("falls back to Unknown plan when planInfo is null", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify({ - userStatus: { - planStatus: { - planInfo: null, - dailyQuotaRemainingPercent: 100, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - }), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Unknown") - }) - - it("does not probe the local language server or localhost", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - }) - - const plugin = await loadPlugin() - plugin.probe(ctx) - - expect(ctx.host.ls.discover).not.toHaveBeenCalled() - const urls = ctx.host.http.request.mock.calls.map((call) => String(call[0].url)) - expect(urls.every((url) => url.startsWith("https://server.self-serve.windsurf.com/"))).toBe(true) - expect(urls.some((url) => url.includes("127.0.0.1"))).toBe(false) - }) - - it("throws the login hint when no API key is available", async () => { - const ctx = makeCtx() - const plugin = await loadPlugin() - - expect(() => plugin.probe(ctx)).toThrow("Start Windsurf or sign in and try again.") - expect(ctx.host.http.request).not.toHaveBeenCalled() - }) - - it("treats SQLite read errors as missing API key and logs a warning", async () => { - const ctx = makeCtx() - ctx.host.sqlite.query.mockImplementation(() => { - throw new Error("db unavailable") - }) - - const plugin = await loadPlugin() - - expect(() => plugin.probe(ctx)).toThrow("Start Windsurf or sign in and try again.") - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("failed to read API key")) - expect(ctx.host.http.request).not.toHaveBeenCalled() - }) - - it("treats malformed nested auth JSON as a missing API key", async () => { - const ctx = makeCtx() - ctx.host.sqlite.query.mockImplementation((db, sql) => { - if (!String(sql).includes("windsurfAuthStatus")) return "[]" - if (String(db).includes("Windsurf/User/globalStorage")) { - return JSON.stringify([{ value: "{not-json}" }]) - } - return "[]" - }) - - const plugin = await loadPlugin() - - expect(() => plugin.probe(ctx)).toThrow("Start Windsurf or sign in and try again.") - expect(ctx.host.http.request).not.toHaveBeenCalled() - }) - - it("falls through when the first auth row is missing a value", async () => { - const ctx = makeCtx() - ctx.host.sqlite.query.mockImplementation((db, sql) => { - if (!String(sql).includes("windsurfAuthStatus")) return "[]" - if (String(db).includes("Windsurf/User/globalStorage")) { - return JSON.stringify([{ value: "" }]) - } - if (String(db).includes("Windsurf - Next")) { - return makeAuthStatus("sk-ws-01-next") - } - return "[]" - }) - ctx.host.http.request.mockImplementation((reqOpts) => { - const body = JSON.parse(String(reqOpts.bodyText || "{}")) - if (body.metadata?.ideName === "windsurf-next") { - return { status: 200, bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })) } - } - return { status: 500, bodyText: "{}" } - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Pro") - expect(ctx.host.http.request).toHaveBeenCalledTimes(1) - }) - - it("throws quota unavailable when the cloud returns only transient failures", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { status: 503, bodyText: "{}" }, - nextResponse: { status: 502, bodyText: "{}" }, - }) - - const plugin = await loadPlugin() - - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("cloud request returned status 503")) - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("cloud request returned status 502")) - }) - - it("throws the login hint when every cloud response is an auth failure", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { status: 401, bodyText: "{}" }, - nextResponse: { status: 403, bodyText: "{}" }, - }) - - const plugin = await loadPlugin() - - expect(() => plugin.probe(ctx)).toThrow("Start Windsurf or sign in and try again.") - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("cloud request returned status 401")) - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("cloud request returned status 403")) - }) - - it("falls through when the first variant returns an auth failure", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: { status: 401, bodyText: "{}" }, - nextResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })), - }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Pro") - expect(ctx.host.http.request).toHaveBeenCalledTimes(2) - }) - - it("falls through to the next variant when a cloud request throws", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - nextAuth: "sk-ws-01-next", - stableResponse: new Error("network error"), - nextResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })) }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.plan).toBe("Pro") - expect(ctx.host.log.warn).toHaveBeenCalledWith(expect.stringContaining("cloud request failed for windsurf")) - }) - - it("throws quota unavailable when reset timestamps cannot be converted", async () => { - const ctx = makeCtx() - ctx.util.toIso = vi.fn(() => null) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when a progress line cannot be built", async () => { - const ctx = makeCtx() - const originalProgress = ctx.line.progress - ctx.line.progress = vi.fn((opts) => { - if (opts.label === "Daily quota") return null - return originalProgress(opts) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when the weekly progress line cannot be built", async () => { - const ctx = makeCtx() - const originalProgress = ctx.line.progress - ctx.line.progress = vi.fn((opts) => { - if (opts.label === "Weekly quota") return null - return originalProgress(opts) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: JSON.stringify(makeQuotaResponse()) }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when quota fields are missing", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ dailyQuotaRemainingPercent: undefined })), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when a quota field is an empty string", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ dailyQuotaRemainingPercent: " " })), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when a quota field is a non-numeric string", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ dailyQuotaRemainingPercent: "not-a-number" })), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when a quota field is a non-finite number", async () => { - const ctx = makeCtx() - const originalTryParseJson = ctx.util.tryParseJson - ctx.util.tryParseJson = vi.fn((text) => { - if (text === "__quota__") { - return { - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - dailyQuotaRemainingPercent: Infinity, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - } - } - return originalTryParseJson(text) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: "__quota__" }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when a daily quota value becomes invalid during line building", async () => { - const ctx = makeCtx() - const originalTryParseJson = ctx.util.tryParseJson - ctx.util.tryParseJson = vi.fn((text) => { - if (text === "__quota__") { - return { - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - dailyQuotaRemainingPercent: NaN, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - } - } - return originalTryParseJson(text) - }) - const originalIsFinite = Number.isFinite - const finiteSpy = vi.spyOn(Number, "isFinite") - let nanChecks = 0 - finiteSpy.mockImplementation((value) => { - if (typeof value === "number" && Number.isNaN(value)) { - nanChecks += 1 - return nanChecks === 1 - } - return originalIsFinite(value) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: "__quota__" }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - finiteSpy.mockRestore() - }) - - it("clamps a daily quota value when it becomes non-finite during clamping", async () => { - const ctx = makeCtx() - const originalTryParseJson = ctx.util.tryParseJson - ctx.util.tryParseJson = vi.fn((text) => { - if (text === "__quota__") { - return { - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - dailyQuotaRemainingPercent: NaN, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - } - } - return originalTryParseJson(text) - }) - const originalIsFinite = Number.isFinite - const finiteSpy = vi.spyOn(Number, "isFinite") - let nanChecks = 0 - finiteSpy.mockImplementation((value) => { - if (typeof value === "number" && value !== value) { - nanChecks += 1 - return nanChecks < 3 - } - return originalIsFinite(value) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: "__quota__" }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - - expect(result.lines.find((line) => line.label === "Daily quota")?.used).toBe(0) - finiteSpy.mockRestore() - }) - - it("throws quota unavailable when a reset field becomes invalid after contract validation", async () => { - const ctx = makeCtx() - const originalTryParseJson = ctx.util.tryParseJson - ctx.util.tryParseJson = vi.fn((text) => { - if (text === "__quota__") { - return { - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - dailyQuotaRemainingPercent: 100, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: "964220000", - dailyQuotaResetAtUnix: NaN, - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - } - } - return originalTryParseJson(text) - }) - const originalIsFinite = Number.isFinite - const finiteSpy = vi.spyOn(Number, "isFinite") - let nanChecks = 0 - finiteSpy.mockImplementation((value) => { - if (typeof value === "number" && value !== value) { - nanChecks += 1 - return nanChecks === 1 - } - return originalIsFinite(value) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: "__quota__" }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - finiteSpy.mockRestore() - }) - - it("omits extra usage balance when it becomes invalid after contract validation", async () => { - const ctx = makeCtx() - const originalTryParseJson = ctx.util.tryParseJson - ctx.util.tryParseJson = vi.fn((text) => { - if (text === "__quota__") { - return { - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - dailyQuotaRemainingPercent: 100, - weeklyQuotaRemainingPercent: 100, - overageBalanceMicros: NaN, - dailyQuotaResetAtUnix: "1774080000", - weeklyQuotaResetAtUnix: "1774166400", - }, - }, - } - } - return originalTryParseJson(text) - }) - const originalIsFinite = Number.isFinite - const finiteSpy = vi.spyOn(Number, "isFinite") - let nanChecks = 0 - finiteSpy.mockImplementation((value) => { - if (typeof value === "number" && value !== value) { - nanChecks += 1 - return nanChecks === 1 - } - return originalIsFinite(value) - }) - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { status: 200, bodyText: "__quota__" }, - }) - - const plugin = await loadPlugin() - const result = plugin.probe(ctx) - expect(result.lines.find((line) => line.label === "Extra usage balance")).toBeUndefined() - finiteSpy.mockRestore() - }) - - it("throws quota unavailable when the weekly reset field is missing", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify(makeQuotaResponse({ weeklyQuotaResetAtUnix: undefined })), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when planStatus is null", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify({ userStatus: { planStatus: null } }), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable when userStatus has no planStatus", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify({ userStatus: {} }), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) - - it("throws quota unavailable for a legacy credit-only payload", async () => { - const ctx = makeCtx() - setupCloudMock(ctx, { - stableAuth: "sk-ws-01-stable", - stableResponse: { - status: 200, - bodyText: JSON.stringify({ - userStatus: { - planStatus: { - planInfo: { planName: "Teams" }, - availablePromptCredits: 50000, - availableFlexCredits: 100000, - planStart: "2026-03-18T09:07:17Z", - planEnd: "2026-04-18T09:07:17Z", - }, - }, - }), - }, - }) - - const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Windsurf quota data unavailable. Try again later.") - }) -}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 8ff78f48..bddaa40e 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -378,6 +378,16 @@ fn redact_body(body: &str) -> String { }) .to_string(); + if let Ok(devin_session_re) = + regex_lite::Regex::new(r#"devin-session-token\$[^\s"',}\]]+"#) + { + result = devin_session_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } + // Redact JSON values for sensitive keys let sensitive_keys = [ "name", @@ -405,6 +415,10 @@ fn redact_body(body: &str) -> String { "accountId", "team_id", "teamId", + "org_id", + "orgId", + "account_display_name", + "accountDisplayName", "payment_id", "paymentId", "profile_arn", @@ -453,6 +467,15 @@ pub(crate) fn redact_log_message(msg: &str) -> String { }) .to_string(); } + if let Ok(devin_session_re) = + regex_lite::Regex::new(r#"devin-session-token\$[^\s"',}\]]+"#) + { + result = devin_session_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } if let Ok(account_re) = regex_lite::Regex::new(r#"(account=)([^,\s]+)"#) { result = account_re .replace_all(&result, |caps: ®ex_lite::Captures| { @@ -3447,6 +3470,22 @@ mod tests { assert!(redacted.contains("sk-1...ghij")); } + #[test] + fn redact_body_redacts_devin_session_token() { + let body = r#"metadata apiKey=devin-session-token$abcdefghijklmnopqrstuvwxyz123456"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("devin-session-token$abcdefghijklmnopqrstuvwxyz123456"), + "Devin session token should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("devi...3456"), + "Devin session token should use first4...last4 redaction, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_json_password_field() { let body = r#"{"password": "supersecretpassword123"}"#; @@ -3511,6 +3550,42 @@ mod tests { ); } + #[test] + fn redact_body_redacts_devin_org_and_account_display_name() { + let body = r#"{"orgId":"org-6b6e9de248db472bb25b296599ea3dc0","accountDisplayName":"rob@sunstory.com","devinInfo":{"org_id":"org-abcdef1234567890","account_display_name":"team@example.com"}}"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("org-6b6e9de248db472bb25b296599ea3dc0"), + "orgId should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "accountDisplayName should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("org-abcdef1234567890"), + "org_id should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("team@example.com"), + "account_display_name should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("org-...3dc0"), + "orgId should show first4...last4, got: {}", + redacted + ); + assert!( + redacted.contains("rob@....com"), + "accountDisplayName should show first4...last4, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_team_id_payment_id_and_paths() { let body = r#"{"teamId":"cc1ac023-9ff5-4c1f-a5a4-ae2a82df4243","paymentId":"cus_S5m1PGxjLWoc1c","binaryPath":"/opt/homebrew/bin/bunx","homePath":"/Users/rebers/.claude"}"#; @@ -3572,6 +3647,22 @@ mod tests { ); } + #[test] + fn redact_log_message_redacts_devin_session_token() { + let msg = "auth=devin-session-token$abcdefghijklmnopqrstuvwxyz123456"; + let redacted = redact_log_message(msg); + assert!( + !redacted.contains("devin-session-token$abcdefghijklmnopqrstuvwxyz123456"), + "Devin session token should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("devi...3456"), + "Devin session token should use first4...last4 redaction, got: {}", + redacted + ); + } + #[test] fn redact_log_message_redacts_account_and_paths() { let msg = "keychain read: service=Claude Code-credentials, account=rebers path=/opt/homebrew/bin/bunx home=/Users/rebers/.claude"; diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 7a5d0679..c03bdcc3 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -19,6 +19,7 @@ const { loadThemeModeMock, loadTimeFormatModeMock, migrateLegacyTraySettingsMock, + migrateWindsurfToDevinMock, normalizePluginSettingsMock, savePluginSettingsMock, } = vi.hoisted(() => ({ @@ -39,6 +40,7 @@ const { loadThemeModeMock: vi.fn(), loadTimeFormatModeMock: vi.fn(), migrateLegacyTraySettingsMock: vi.fn(), + migrateWindsurfToDevinMock: vi.fn(), normalizePluginSettingsMock: vi.fn(), savePluginSettingsMock: vi.fn(), })) @@ -75,6 +77,7 @@ vi.mock("@/lib/settings", () => ({ loadThemeMode: loadThemeModeMock, loadTimeFormatMode: loadTimeFormatModeMock, migrateLegacyTraySettings: migrateLegacyTraySettingsMock, + migrateWindsurfToDevin: migrateWindsurfToDevinMock, normalizePluginSettings: normalizePluginSettingsMock, savePluginSettings: savePluginSettingsMock, })) @@ -118,6 +121,7 @@ describe("useSettingsBootstrap", () => { loadThemeModeMock.mockReset() loadTimeFormatModeMock.mockReset() migrateLegacyTraySettingsMock.mockReset() + migrateWindsurfToDevinMock.mockReset() normalizePluginSettingsMock.mockReset() savePluginSettingsMock.mockReset() @@ -145,6 +149,7 @@ describe("useSettingsBootstrap", () => { loadMenubarIconStyleMock.mockResolvedValue("provider") loadStartOnLoginMock.mockResolvedValue(true) migrateLegacyTraySettingsMock.mockResolvedValue(undefined) + migrateWindsurfToDevinMock.mockImplementation((settings) => settings) savePluginSettingsMock.mockResolvedValue(undefined) getEnabledPluginIdsMock.mockReturnValue(["codex"]) }) @@ -177,4 +182,39 @@ describe("useSettingsBootstrap", () => { errorSpy.mockRestore() }) + + it("migrates windsurf settings before normalizing and saves the first-launch result", async () => { + const args = createArgs() + const storedSettings = { order: ["windsurf"], disabled: [] } + const migratedSettings = { order: ["devin"], disabled: [] } + const availablePlugins = [ + { + id: "devin", + name: "Devin", + iconUrl: "/devin.svg", + brandColor: "#000000", + lines: [], + primaryCandidates: [], + }, + ] + + invokeMock.mockResolvedValueOnce(availablePlugins) + loadPluginSettingsMock.mockResolvedValueOnce(storedSettings) + migrateWindsurfToDevinMock.mockReturnValueOnce(migratedSettings) + normalizePluginSettingsMock.mockReturnValueOnce(migratedSettings) + arePluginSettingsEqualMock.mockReturnValueOnce(false) + getEnabledPluginIdsMock.mockReturnValueOnce(["devin"]) + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => { + expect(normalizePluginSettingsMock).toHaveBeenCalledWith( + migratedSettings, + availablePlugins + ) + expect(savePluginSettingsMock).toHaveBeenCalledWith(migratedSettings) + expect(args.setPluginSettings).toHaveBeenCalledWith(migratedSettings) + expect(args.startBatch).toHaveBeenCalledWith(["devin"]) + }) + }) }) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index 6468fe20..37843cf4 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -22,6 +22,7 @@ import { loadGlobalShortcut, loadMenubarIconStyle, migrateLegacyTraySettings, + migrateWindsurfToDevin, loadPluginSettings, loadResetTimerDisplayMode, loadStartOnLogin, @@ -93,7 +94,8 @@ export function useSettingsBootstrap({ setPluginsMeta(availablePlugins) const storedSettings = await loadPluginSettings() - const normalized = normalizePluginSettings(storedSettings, availablePlugins) + const migratedSettings = migrateWindsurfToDevin(storedSettings) + const normalized = normalizePluginSettings(migratedSettings, availablePlugins) if (!arePluginSettingsEqual(storedSettings, normalized)) { await savePluginSettings(normalized) } @@ -205,6 +207,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setLoadingForPlugins, setMenubarIconStyle, + migrateWindsurfToDevin, migrateLegacyTraySettings, setPluginSettings, setPluginsMeta, diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 27c5995b..91d69fdb 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -20,6 +20,7 @@ import { loadStartOnLogin, loadTimeFormatMode, migrateLegacyTraySettings, + migrateWindsurfToDevin, loadThemeMode, normalizePluginSettings, saveAutoUpdateInterval, @@ -98,11 +99,59 @@ describe("settings", () => { const plugins: PluginMeta[] = [ { id: "claude", name: "Claude", iconUrl: "", lines: [], primaryCandidates: [] }, { id: "copilot", name: "Copilot", iconUrl: "", lines: [], primaryCandidates: [] }, - { id: "windsurf", name: "Windsurf", iconUrl: "", lines: [], primaryCandidates: [] }, + { id: "devin", name: "Devin", iconUrl: "", lines: [], primaryCandidates: [] }, ] const result = normalizePluginSettings({ order: [], disabled: [] }, plugins) - expect(result.order).toEqual(["claude", "copilot", "windsurf"]) - expect(result.disabled).toEqual(["copilot", "windsurf"]) + expect(result.order).toEqual(["claude", "copilot", "devin"]) + expect(result.disabled).toEqual(["copilot", "devin"]) + }) + + it("migrates enabled windsurf settings to enabled devin settings", () => { + const result = migrateWindsurfToDevin({ + order: ["claude", "windsurf", "codex"], + disabled: [], + }) + + expect(result).toEqual({ + order: ["claude", "devin", "codex"], + disabled: [], + }) + }) + + it("keeps devin enabled when enabled windsurf conflicts with a stale disabled devin entry", () => { + const result = migrateWindsurfToDevin({ + order: ["claude", "windsurf", "codex"], + disabled: ["devin"], + }) + + expect(result).toEqual({ + order: ["claude", "devin", "codex"], + disabled: [], + }) + }) + + it("migrates disabled windsurf settings to disabled devin settings", () => { + const result = migrateWindsurfToDevin({ + order: ["windsurf", "claude"], + disabled: ["windsurf"], + }) + + expect(result).toEqual({ + order: ["devin", "claude"], + disabled: ["devin"], + }) + }) + + it("does not disable an existing devin entry when removing old windsurf settings", () => { + const result = migrateWindsurfToDevin({ + order: ["windsurf", "devin", "claude"], + disabled: ["windsurf"], + }) + + expect(result).toEqual({ + order: ["devin", "claude"], + disabled: [], + }) }) it("compares settings equality", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 69ad37b8..6b0caedd 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -110,6 +110,30 @@ export async function savePluginSettings(settings: PluginSettings): Promise Devin settings migration. +export function migrateWindsurfToDevin(settings: PluginSettings): PluginSettings { + const hasDevin = settings.order.includes("devin"); + const hasWindsurf = settings.order.includes("windsurf"); + const windsurfWasDisabled = settings.disabled.includes("windsurf"); + const order = Array.from( + new Set(settings.order.map((id) => (id === "windsurf" ? "devin" : id))) + ); + let disabled = settings.disabled.filter((id) => id !== "windsurf"); + + if (hasWindsurf && !windsurfWasDisabled) { + disabled = disabled.filter((id) => id !== "devin"); + } + + if (!hasDevin && windsurfWasDisabled && !disabled.includes("devin")) { + disabled.push("devin"); + } + + return { + order, + disabled: Array.from(new Set(disabled)), + }; +} + function isAutoUpdateInterval(value: unknown): value is AutoUpdateIntervalMinutes { return ( typeof value === "number" &&