From 5875c7993faa624f7e3e44df731992142e32cef8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:05:22 -0300 Subject: [PATCH] fix(providers): surface real Devin error + fix Windsurf auth instructions (#3324) (#3829) --- CHANGELOG.md | 1 + file-size-baseline.json | 6 +- open-sse/handlers/chatCore.ts | 15 +- open-sse/handlers/sseParser.ts | 66 +++++++ .../api/oauth/[provider]/[action]/route.ts | 12 +- src/lib/oauth/constants/oauth.ts | 10 +- src/lib/oauth/providers/windsurf.ts | 10 +- src/shared/components/OAuthModal.tsx | 10 +- src/shared/constants/providers.ts | 5 +- tests/unit/sse-error-passthrough-3324.test.ts | 162 ++++++++++++++++++ 10 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 tests/unit/sse-error-passthrough-3324.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c4624eab..b3be4993c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🐛 Fixed - **fix(intelligence): run pricing + models.dev sync from the live startup path** — like the Arena ELO sync (v3.8.24), the external **pricing sync** (`PRICING_SYNC_ENABLED`) and the **models.dev capability sync** (Settings → AI toggle) were only initialized from `server-init.ts`, which the Next standalone runtime never executes — and models.dev had no caller at all. Their toggles were inert in production. Both are now initialized from `instrumentation-node.ts` (self-gated, opt-in preserved, non-blocking, never fatal). (thanks @diegosouzapw) +- **fix(providers): surface the real Devin error + correct the Windsurf auth instructions** — Devin chat returned a generic 502 "Invalid SSE response for non-streaming request" that swallowed the real cause (e.g. "Devin CLI not found"): an error-only SSE chunk (no `choices`) is now propagated with its sanitized message. The Windsurf "Visit windsurf.com/show-auth-token" instruction (the bare URL shows no token without an IDE-supplied `?state=`) now directs users to the `Windsurf: Provide Auth Token` command-palette flow. ([#3324](https://github.com/diegosouzapw/OmniRoute/issues/3324) — thanks @mikmaneggahommie) - **fix(grok-web): clearer 403 message for anti-bot / IP-reputation blocks** — a Grok Web subscription validating from a flagged datacenter/VPS IP got a 403 that read like an invalid cookie, sending users to chase a cookie that was actually fine. A non-auth 403 (Cloudflare challenge / anti-bot body) now returns a message stating the cookie is likely OK and the block is IP-reputation-based — retry from a residential IP or configure a proxy (auth-shaped 403s keep the re-paste guidance). ([#3474](https://github.com/diegosouzapw/OmniRoute/issues/3474) — thanks @friedtofu1608) - **fix(db): make the mass-pending-migrations safety threshold env-overridable** — restoring a backup DB from an older version could trip "Detected N pending migrations … threshold is 50" with no way to override the hardcoded `50`. The threshold is now configurable via `OMNIROUTE_MAX_PENDING_MIGRATIONS` (resolved at startup; `0` disables the check). ([#3416](https://github.com/diegosouzapw/OmniRoute/issues/3416) — thanks @samuraiIT) - **test(proxy): cover the Vercel-relay `proxyFetch` path** — net-new tests for `buildVercelRelayHeaders` and the `vercel`-type relay short-circuit (`x-relay-target`/`-path`/`-auth`, TCP-skip, missing-auth fail-closed), closing one of the deferred test gaps tracked in [#2743](https://github.com/diegosouzapw/OmniRoute/issues/2743). (thanks @diegosouzapw) diff --git a/file-size-baseline.json b/file-size-baseline.json index cecf5cf59e..156eb1003a 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -19,6 +19,7 @@ "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1442, + "open-sse/handlers/sseParser.ts": 812, "open-sse/handlers/videoGeneration.ts": 1026, "open-sse/mcp-server/schemas/tools.ts": 1437, "open-sse/mcp-server/server.ts": 1457, @@ -70,7 +71,7 @@ "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069, - "src/app/api/oauth/[provider]/[action]/route.ts": 897, + "src/app/api/oauth/[provider]/[action]/route.ts": 903, "src/app/api/providers/[id]/models/route.ts": 2426, "src/app/api/providers/[id]/test/route.ts": 842, "src/app/api/usage/analytics/route.ts": 941, @@ -117,5 +118,6 @@ "_rebaseline_2026_06_13_3782_hide_persist": "Re-baseline #3782 (preservar modelos eye-hidden no auto-sync): models.ts 1132→1180 (+48). Crescimento = flag distinto isDeleted em ModelCompatOverride/ModelCompatPatch + handling em mergeModelCompatOverride + helper getModelIsDeleted, para separar 'deletado' (trash, dropado no re-sync #3199) de 'oculto' (eye toggle, preservado). Lógica coesa de visibilidade no módulo db; não-extraível. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.", "_rebaseline_2026_06_13_3758_chat_early_eof": "Re-baseline #3758 (#3817 mergeado): chat.ts 1392→1425 (+33). Crescimento = retry bounded de STREAM_EARLY_EOF no handleSingleModelChat (contador streamEarlyEofRetries + bloco de retry guardado por shouldRetryStreamEarlyEof). Lógica coesa no handler de chat; não-extraível. Reconciliação tardia — o bump foi esquecido no PR do fix (o de antigravity/models foi feito).", "_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível.", - "_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível." + "_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível.", + "_rebaseline_2026_06_13_3324_windsurf_devin": "Re-baseline #3324 (windsurf auth text + devin error propagation): route.ts 897→903 (+6, texto da instrução windsurf→fluxo command-palette) + sseParser.ts ADICIONADO como frozen 812 (era 746, +66 = helper extractSSEErrorMessage que faz surface do erro real SSE em vez do 502 genérico). 812 fica 12 acima do cap 800 — helper coeso no parser de SSE, congelado com justificativa (precedente providerLimits/useProviderConnections)." } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6291b3b018..583796cf87 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -161,6 +161,7 @@ import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { + extractSSEErrorMessage, parseSSEToClaudeResponse, parseSSEToOpenAIResponse, parseSSEToResponsesOutput, @@ -4870,7 +4871,14 @@ export async function handleChatCore({ connectionId, status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, }).catch(() => {}); - const invalidSseMessage = "Invalid SSE response for non-streaming request"; + // Some executors (e.g. the Devin/Windsurf CLI) always emit + // text/event-stream, signalling failure with an error-only chunk + // (`data: {"error":{"message":"Devin CLI not found..."}}`) that carries + // no `choices`. Surface that real, sanitized message instead of the + // generic 502 so the actionable error is not swallowed (#3324). + const surfacedSseError = extractSSEErrorMessage(streamPayload); + const invalidSseMessage = + surfacedSseError || "Invalid SSE response for non-streaming request"; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, error: invalidSseMessage, @@ -5268,9 +5276,8 @@ export async function handleChatCore({ // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open === if (apiKeyInfo?.id && credentials?.connectionId) { try { - const { scheduleRecordConsumption, buildConsumptionCost } = await import( - "@/lib/quota/spendRecorder" - ); + const { scheduleRecordConsumption, buildConsumptionCost } = + await import("@/lib/quota/spendRecorder"); scheduleRecordConsumption( { apiKeyId: apiKeyInfo.id, diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 712efc45fd..b6d0b3ad75 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -1,4 +1,70 @@ import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +/** + * Extract a provider error message from a buffered SSE stream that carries an + * error-only chunk (`data: {"error":...}`) and no content chunks. + * + * Some executors always return `text/event-stream` even on failure (e.g. the + * Devin/Windsurf CLI executors emit `data: {"error":{"message":"Devin CLI not + * found..."}}`). Those chunks have no `choices`/Claude/Responses content, so the + * content parsers (parseSSEToOpenAIResponse etc.) correctly return `null`. Without + * this helper the caller would replace the real upstream error with a generic + * "Invalid SSE response" 502, swallowing the actionable message (#3324). + * + * Provider-agnostic: matches any `data:` chunk that has an `error` field but no + * `choices` array. The returned message is always run through sanitizeErrorMessage + * so stack traces / absolute source paths never leak (Hard Rule #12). Returns + * `null` when no error-only chunk is present (so valid-content streams are left + * to the normal parsers). + */ +export function extractSSEErrorMessage(rawSSE: unknown): string | null { + const lines = String(rawSSE || "").split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + continue; // Ignore malformed lines and keep scanning. + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const record = parsed as Record; + + // A chunk with content (choices) is not an error-only chunk — defer to the + // normal content parsers so the valid-SSE path is never short-circuited. + if (Array.isArray(record.choices)) continue; + + const err = record.error; + if (err == null) continue; + + let message = ""; + if (typeof err === "string") { + message = err; + } else if (typeof err === "object" && !Array.isArray(err)) { + const errRecord = err as Record; + if (typeof errRecord.message === "string") { + message = errRecord.message; + } else { + message = JSON.stringify(err); + } + } else { + message = String(err); + } + + const sanitized = sanitizeErrorMessage(message); + if (sanitized) return sanitized; + } + + return null; +} + /** * Convert OpenAI-style SSE chunks into a single non-streaming JSON response. * Used as a fallback when upstream returns text/event-stream for stream=false. diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index c087a7ae77..1ebd127c23 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -126,7 +126,9 @@ export async function GET( error: `Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` + `/api/oauth/${earlyParams.provider}/import-token. ` + - `Visit https://windsurf.com/show-auth-token to obtain a token.`, + `In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` + + `(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` + + `Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`, }, { status: 410 } ); @@ -328,7 +330,9 @@ export async function POST( error: `Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` + `/api/oauth/${earlyParams.provider}/import-token. ` + - `Visit https://windsurf.com/show-auth-token to obtain a token.`, + `In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` + + `(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` + + `Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`, }, { status: 410 } ); @@ -353,7 +357,9 @@ export async function POST( error: `Browser OAuth disabled for ${provider} — use import-token via ` + `/api/oauth/${provider}/import-token. ` + - `Visit https://windsurf.com/show-auth-token to obtain a token.`, + `In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` + + `(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` + + `Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`, }, { status: 410 } ); diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index c372f385df..fcd07ef76e 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -378,7 +378,9 @@ export const TRAE_CONFIG = { // // Active fields: // - inferenceUrl → used by WindsurfExecutor (open-sse/executors/windsurf.ts) -// - showAuthTokenUrl → linked from OAuthModal "Get token" button +// - showAuthTokenUrl → reference URL; the real token only renders when the +// IDE "Windsurf: Provide Auth Token" command opens it +// with an IDE-supplied ?state= param (see field below) // - firebaseApiKey → reserved for Phase 2 // - ideName → sent in extension headers export const WINDSURF_CONFIG = { @@ -397,7 +399,11 @@ export const WINDSURF_CONFIG = { // ── Active fields (still consumed by runtime) ───────────────────────────── // Inference server URL (gRPC-web requests go here) inferenceUrl: "https://server.self-serve.windsurf.com", - // Primary login path: user visits this page, copies token, pastes it + // Primary login path: the user runs the "Windsurf: Provide Auth Token" command + // inside the Windsurf/VS Code IDE (or clicks the Jupyter "Get Windsurf + // Authentication Token" button), which opens this URL WITH an IDE-supplied + // `?state=` param and renders the token. Opening this bare URL directly + // only shows a "Redirecting" page with no token (#3324). showAuthTokenUrl: "https://windsurf.com/show-auth-token", // Token refresh via Firebase Secure Token Service (reserved for Phase 2). // Default is the public Firebase Web client identifier embedded in the diff --git a/src/lib/oauth/providers/windsurf.ts b/src/lib/oauth/providers/windsurf.ts index 0328d9764a..9590fab246 100644 --- a/src/lib/oauth/providers/windsurf.ts +++ b/src/lib/oauth/providers/windsurf.ts @@ -8,7 +8,12 @@ import { WINDSURF_CONFIG } from "../constants/oauth"; * Firebase OAuth + RegisterUser flow (see docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md), * the only supported login path is import-token: * - * 1. User opens https://windsurf.com/show-auth-token in a browser + * 1. In the Windsurf / VS Code IDE, the user runs the + * "Windsurf: Provide Auth Token" command from the command palette + * (or clicks the Jupyter "Get Windsurf Authentication Token" button). + * NOTE: opening https://windsurf.com/show-auth-token directly only renders + * a "Redirecting" page with no token — the IDE must initiate the flow + * (it adds a `?state=` param) for the token to be displayed. * 2. Copies the displayed Windsurf API key (`sk-ws-...` style) * 3. Pastes it into OmniRoute via /api/oauth/windsurf/import-token * @@ -22,7 +27,8 @@ export const windsurf = { /** * Validate a pasted Windsurf API key. Accepts the `sk-ws-...` format issued by - * windsurf.com/show-auth-token and the legacy raw-token format. Empty or + * the IDE "Windsurf: Provide Auth Token" command (via windsurf.com/show-auth-token + * with the IDE-supplied `?state=...`) and the legacy raw-token format. Empty or * whitespace-only tokens are rejected. */ validateImportToken(token: string): { valid: boolean; reason?: string } { diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index c431c5c7b8..65b01a4c48 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -553,7 +553,11 @@ export default function OAuthModal({ const isGoogleLoopbackRelay = GOOGLE_OAUTH_PROVIDERS.has(provider) && isLoopbackOrigin && hasMatchingState; - if (event.origin !== window.location.origin && !isLocalhostSamePort && !isGoogleLoopbackRelay) { + if ( + event.origin !== window.location.origin && + !isLocalhostSamePort && + !isGoogleLoopbackRelay + ) { return; } if (event.data?.type === "oauth_callback") { @@ -733,8 +737,8 @@ export default function OAuthModal({

{provider === "windsurf" - ? "Visit windsurf.com/show-auth-token, copy your Windsurf API key, and paste it below." - : "Provide your WINDSURF_API_KEY (obtained via `devin auth login` or windsurf.com/show-auth-token)."} + ? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.' + : 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}

{ + const rawSSE = [ + 'data: {"error":{"message":"Devin CLI not found on PATH"}}', + "", + "data: [DONE]", + "", + ].join("\n"); + + const surfaced = extractSSEErrorMessage(rawSSE); + + assert.ok(surfaced, "expected a surfaced error message, got null"); + assert.ok( + surfaced.includes("Devin CLI not found on PATH"), + `expected surfaced message to contain the real Devin error, got: ${surfaced}` + ); + assert.notEqual(surfaced, GENERIC); +}); + +test("extractSSEErrorMessage surfaces the real Devin spawn error shape (type+code)", () => { + const rawSSE = [ + 'data: {"error":{"message":"Devin CLI not found: devin. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.","type":"devin_cli_error","code":"spawn_failed"}}', + "", + "data: [DONE]", + "", + ].join("\n"); + + const surfaced = extractSSEErrorMessage(rawSSE); + + assert.ok(surfaced); + assert.ok(surfaced.includes("Devin CLI not found")); +}); + +test("extractSSEErrorMessage accepts a top-level string error field", () => { + const rawSSE = ['data: {"error":"upstream blew up"}', "", "data: [DONE]"].join("\n"); + + const surfaced = extractSSEErrorMessage(rawSSE); + + assert.ok(surfaced); + assert.ok(surfaced.includes("upstream blew up")); +}); + +test("extractSSEErrorMessage returns null when a valid choices chunk is present (no false positive)", () => { + const rawSSE = [ + 'data: {"id":"chatcmpl_ok","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}', + "data: [DONE]", + ].join("\n"); + + assert.equal(extractSSEErrorMessage(rawSSE), null); +}); + +test("extractSSEErrorMessage returns null for a stream with no error and no choices", () => { + const rawSSE = ['data: {"foo":"bar"}', "data: [DONE]"].join("\n"); + + assert.equal(extractSSEErrorMessage(rawSSE), null); +}); + +test("extractSSEErrorMessage sanitizes stack-trace-like error messages (no `at /` leak)", () => { + // Genuine V8 stack shape: real newline between the message and the frames, so + // a JSON.stringify of a real Error.stack-style string round-trips with the + // newline intact (sanitizeErrorMessage drops everything after the first line). + const stacky = + "ENOENT: spawn devin\n at ChildProcess._handle.onexit (/home/me/app/open-sse/executors/devin-cli.ts:170:5)"; + // A bare absolute source path on the surviving first line must also be redacted. + const bareInline = "Devin failed loading /home/me/app/open-sse/executors/devin-cli.ts module"; + const rawSSE = [ + `data: ${JSON.stringify({ error: { message: stacky } })}`, + "", + `data: ${JSON.stringify({ error: { message: bareInline } })}`, + "", + "data: [DONE]", + ].join("\n"); + + const surfaced = extractSSEErrorMessage(rawSSE); + + assert.ok(surfaced, "expected a sanitized error message"); + // The first error-only chunk wins; its stack tail is dropped at the newline. + assert.equal(surfaced, "ENOENT: spawn devin"); + assert.ok(!surfaced.includes("at /"), `surfaced message leaked a stack frame path: ${surfaced}`); + assert.ok( + !/\/[^\s]+\.ts/.test(surfaced), + `surfaced message leaked an absolute source path: ${surfaced}` + ); + + // A bare absolute source path on a single line is redacted to . + const bareSurfaced = extractSSEErrorMessage( + [`data: ${JSON.stringify({ error: { message: bareInline } })}`, "", "data: [DONE]"].join("\n") + ); + assert.ok(bareSurfaced); + assert.ok( + bareSurfaced.includes(""), + `expected the bare absolute path to be redacted, got: ${bareSurfaced}` + ); + assert.ok(!/\/[^\s]+\.ts/.test(bareSurfaced), `bare-path message leaked: ${bareSurfaced}`); +}); + +// ─── Regression: the normal valid-SSE parse path still works ─────────────────── + +test("parseSSEToOpenAIResponse still parses a normal valid SSE (no regression)", () => { + const rawSSE = [ + 'data: {"id":"chatcmpl_ok","model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model"); + + assert.ok(parsed); + assert.equal(parsed.choices[0].message.content, "hello"); +}); + +test("parseSSEToOpenAIResponse still returns null for an error-only SSE (boundary owns the error path)", () => { + const rawSSE = ['data: {"error":{"message":"Devin CLI not found on PATH"}}', "data: [DONE]"].join( + "\n" + ); + + // The valid-SSE parser intentionally returns null here (no `choices`); the + // error surfacing is the job of extractSSEErrorMessage at the boundary. + assert.equal(parseSSEToOpenAIResponse(rawSSE, "fallback-model"), null); +}); + +// ─── PART 1: windsurf instruction text references the IDE command-palette flow ─ + +test("PART 1: windsurf authHint references the `Windsurf: Provide Auth Token` command", () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const providers = readFileSync( + path.join(here, "../../src/shared/constants/providers.ts"), + "utf8" + ); + + // The windsurf authHint must lead with the IDE command-palette flow. + assert.match( + providers, + /Windsurf: Provide Auth Token/, + "providers.ts windsurf authHint should reference the `Windsurf: Provide Auth Token` command" + ); +}); + +test("PART 1: oauth route 410 errors reference the IDE command-palette flow", () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const route = readFileSync( + path.join(here, "../../src/app/api/oauth/[provider]/[action]/route.ts"), + "utf8" + ); + + assert.match( + route, + /Windsurf: Provide Auth Token/, + "oauth route should direct users to the `Windsurf: Provide Auth Token` command" + ); +});