From 0e1f40ed1f3671f301d0804f2bbc7a39632ede50 Mon Sep 17 00:00:00 2001 From: "Andrew B." <37745667+AndrianBalanescu@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:08:44 -0500 Subject: [PATCH] feat(oauth): add Raycast Pro provider with local auto-import (#8895) Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip) --- .env.example | 11 + docs/reference/ENVIRONMENT.md | 4 + open-sse/config/providers/index.ts | 2 + .../providers/registry/raycast/index.ts | 33 +++ open-sse/executors/index.ts | 3 + open-sse/executors/raycast.ts | 224 +++++++++++++++ open-sse/services/raycast.ts | 266 ++++++++++++++++++ scripts/raycast/extract-credentials.mjs | 100 +++++++ scripts/raycast/usage-benchmark.mjs | 165 +++++++++++ .../[id]/components/ProviderModalsPanel.tsx | 8 + .../api/oauth/raycast/auto-import/route.ts | 124 ++++++++ src/app/api/oauth/raycast/import/route.ts | 143 ++++++++++ src/app/api/providers/[id]/models/route.ts | 54 ++++ src/lib/oauth/constants/oauth.ts | 11 + src/lib/oauth/providers/index.ts | 2 + src/lib/oauth/providers/raycast.ts | 37 +++ src/lib/oauth/services/raycast.ts | 65 +++++ src/lib/oauth/services/raycastLocal.ts | 198 +++++++++++++ src/shared/components/RaycastAuthModal.tsx | 213 ++++++++++++++ src/shared/components/index.tsx | 1 + src/shared/constants/providers.ts | 2 +- src/shared/constants/providers/oauth.ts | 13 + src/shared/validation/schemas/auth.ts | 8 + tests/unit/raycast-auth.test.ts | 75 +++++ tests/unit/raycast-local-extract.test.ts | 18 ++ 25 files changed, 1779 insertions(+), 1 deletion(-) create mode 100644 open-sse/config/providers/registry/raycast/index.ts create mode 100644 open-sse/executors/raycast.ts create mode 100644 open-sse/services/raycast.ts create mode 100644 scripts/raycast/extract-credentials.mjs create mode 100644 scripts/raycast/usage-benchmark.mjs create mode 100644 src/app/api/oauth/raycast/auto-import/route.ts create mode 100644 src/app/api/oauth/raycast/import/route.ts create mode 100644 src/lib/oauth/providers/raycast.ts create mode 100644 src/lib/oauth/services/raycast.ts create mode 100644 src/lib/oauth/services/raycastLocal.ts create mode 100644 src/shared/components/RaycastAuthModal.tsx create mode 100644 tests/unit/raycast-auth.test.ts create mode 100644 tests/unit/raycast-local-extract.test.ts diff --git a/.env.example b/.env.example index 6f9be90f29..e0131b2f69 100644 --- a/.env.example +++ b/.env.example @@ -1048,6 +1048,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # VISION_BRIDGE_BASE_URL= # VISION_BRIDGE_API_KEY= +# ── Raycast Pro (local auto-import) ── +# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use +# only (no OAuth client_id/secret; token is captured via macOS Auto-Import +# from the Keychain + local Raycast SQLite DB, or pasted manually). These +# vars are optional manual overrides used by open-sse/services/raycast.ts +# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs. +# RAYCAST_BEARER_TOKEN= +# RAYCAST_DEVICE_ID= +# RAYCAST_AID= +# RAYCAST_SIG_SECRET= + # ───────────────────────────────────────────────────────────────────────────── # ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS # ───────────────────────────────────────────────────────────────────────────── diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3105ceeb2f..ffa959c450 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -520,6 +520,10 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. | | `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` — #1350) so `REQUIRE_API_KEY=true` deployments work. | | `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. | +| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. | +| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. | +| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. | +| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. | > [!WARNING] > diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 6e6677dd6a..47a5896784 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -22,6 +22,7 @@ import { glmProvider } from "./registry/glm/index.ts"; import { glmtProvider } from "./registry/glm/t/index.ts"; import { glm_cnProvider } from "./registry/glm/cn/index.ts"; import { traeProvider } from "./registry/trae/index.ts"; +import { raycastProvider } from "./registry/raycast/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; @@ -245,6 +246,7 @@ export const REGISTRY: Record = { glmt: glmtProvider, "glm-cn": glm_cnProvider, trae: traeProvider, + raycast: raycastProvider, "muse-spark-web": muse_spark_webProvider, lmarena: lmarenaProvider, kilocode: kilocodeProvider, diff --git a/open-sse/config/providers/registry/raycast/index.ts b/open-sse/config/providers/registry/raycast/index.ts new file mode 100644 index 0000000000..cd55a343ff --- /dev/null +++ b/open-sse/config/providers/registry/raycast/index.ts @@ -0,0 +1,33 @@ +/** + * @file index.ts + * @description Raycast Pro AI provider registry entry (reverse-engineered, unofficial API). + * + * @changes + * - [2026-07-28] [Composer] - Initial Raycast provider registry module + */ + +import type { RegistryEntry } from "../../shared.ts"; + +/** Seed catalog — full list synced from Raycast /api/v1/ai/models on connect/import. */ +export const raycastProvider: RegistryEntry = { + id: "raycast", + alias: "rc", + format: "openai", + executor: "raycast", + baseUrl: "https://backend.raycast.com/api/v1/ai", + authType: "oauth", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { id: "openai-gpt-5-mini", name: "GPT-5 Mini" }, + { id: "openai-gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "anthropic-claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "google-gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "raycast-ray1", name: "Ray1" }, + { id: "raycast-ray1-mini", name: "Ray1 Mini" }, + { id: "perplexity-sonar", name: "Sonar" }, + { id: "perplexity-sonar-pro", name: "Sonar Pro" }, + { id: "mistral-open-mistral-nemo", name: "Mistral Nemo" }, + { id: "xai-grok-3-mini", name: "Grok 3 Mini" }, + ], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 89748306a7..b25f4d9555 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -56,6 +56,7 @@ import { CheaperInferenceExecutor } from "./cheaperinference.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; import { QwenWebExecutor } from "./qwen-web.ts"; +import { RaycastExecutor } from "./raycast.ts"; import { HailuoWebExecutor } from "./hailuo-web.ts"; import { ZaiWebExecutor } from "./zai-web.ts"; import { KimiExecutor } from "./kimi.ts"; @@ -178,6 +179,8 @@ const executors = { "doubao-web": new DoubaoWebExecutor(), db: new DoubaoWebExecutor(), // Alias "qwen-web": new QwenWebExecutor(), + raycast: new RaycastExecutor(), + rc: new RaycastExecutor(), // Alias "hailuo-web": new HailuoWebExecutor(), "zai-web": new ZaiWebExecutor(), zw: new ZaiWebExecutor(), // Alias diff --git a/open-sse/executors/raycast.ts b/open-sse/executors/raycast.ts new file mode 100644 index 0000000000..4f788c115c --- /dev/null +++ b/open-sse/executors/raycast.ts @@ -0,0 +1,224 @@ +/** + * @file raycast.ts + * @description Executor for Raycast Pro AI (reverse-engineered backend.raycast.com API). + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast Pro local-dev executor + */ + +import { BaseExecutor, mergeUpstreamExtraHeaders, type ProviderCredentials } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + RAYCAST_CHAT_URL, + buildRaycastChatBody, + buildRaycastHeaders, + parseRaycastSseText, +} from "../services/raycast.ts"; + +type JsonRecord = Record; +type ChatMessage = { role?: string; content?: unknown }; + +export class RaycastExecutor extends BaseExecutor { + constructor() { + super("raycast", PROVIDERS.raycast); + } + + buildUrl(): string { + return RAYCAST_CHAT_URL; + } + + buildHeaders(credentials: ProviderCredentials, payload?: string): Record { + const body = payload || "{}"; + return buildRaycastHeaders(body, credentials as JsonRecord); + } + + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) { + const reqBody = body as { messages?: ChatMessage[]; temperature?: number }; + let payload: string; + + try { + payload = buildRaycastChatBody(model as string, reqBody.messages || [], reqBody.temperature); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + response: new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ), + url: RAYCAST_CHAT_URL, + headers: {}, + transformedBody: body, + }; + } + + const headers = this.buildHeaders(credentials as ProviderCredentials, payload); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record | null); + + let raycastResponse: Response; + try { + raycastResponse = await fetch(RAYCAST_CHAT_URL, { + method: "POST", + headers, + body: payload, + signal: signal || undefined, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + response: new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + url: RAYCAST_CHAT_URL, + headers, + transformedBody: payload, + }; + } + + if (!raycastResponse.ok) { + const errorText = await raycastResponse.text(); + return { + response: new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(`Raycast API error (${raycastResponse.status})`), + type: "api_error", + code: String(raycastResponse.status), + }, + }), + { status: raycastResponse.status, headers: { "Content-Type": "application/json" } } + ), + url: RAYCAST_CHAT_URL, + headers, + transformedBody: payload, + }; + } + + const responseId = `chatcmpl-raycast-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const modelId = model as string; + + if (stream !== false) { + const raycastBody = raycastResponse.body; + if (!raycastBody) { + return { + response: new Response( + JSON.stringify({ + error: { message: "Raycast returned empty stream body", type: "api_error", code: "" }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + url: RAYCAST_CHAT_URL, + headers, + transformedBody: payload, + }; + } + + const sseStream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const reader = raycastBody.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line.startsWith("data:")) continue; + + try { + const data = JSON.parse(line.slice(5).trim()) as { + text?: string; + finish_reason?: string | null; + complete?: boolean; + }; + const hasContent = typeof data.text === "string" && data.text.length > 0; + const hasFinishReason = + data.finish_reason !== undefined && data.finish_reason !== null; + if (data.complete || (!hasContent && !hasFinishReason)) continue; + + const chunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [ + { + index: 0, + delta: { content: data.text || "" }, + finish_reason: hasFinishReason ? data.finish_reason : null, + }, + ], + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } catch { + // Ignore malformed SSE data. + } + } + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: RAYCAST_CHAT_URL, + headers, + transformedBody: payload, + }; + } + + const responseText = await raycastResponse.text(); + const content = parseRaycastSseText(responseText); + + return { + response: new Response( + JSON.stringify({ + id: responseId, + object: "chat.completion", + created, + model: modelId, + choices: [ + { + index: 0, + message: { role: "assistant", content, refusal: null }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: RAYCAST_CHAT_URL, + headers, + transformedBody: payload, + }; + } +} diff --git a/open-sse/services/raycast.ts b/open-sse/services/raycast.ts new file mode 100644 index 0000000000..6e25f2271e --- /dev/null +++ b/open-sse/services/raycast.ts @@ -0,0 +1,266 @@ +/** + * @file raycast.ts + * @description Raycast Pro AI reverse-engineered protocol (backend.raycast.com). + * Ported from szcharlesji/raycast-relay (Node, 2026-06) — V2 HMAC + V1 JWT signatures. + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast Pro local-dev provider protocol + */ + +import { createHmac, createHash, randomUUID } from "node:crypto"; + +export const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions"; +export const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models"; +export const RAYCAST_DEFAULT_USER_AGENT = + "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))"; +export const RAYCAST_DEFAULT_EXPERIMENTAL = "chatBranching, mcpHTTPServer"; + +/** Community-extracted default; override via providerSpecificData.sigSecret or RAYCAST_SIG_SECRET. */ +export const RAYCAST_DEFAULT_SIG_SECRET = + "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"; + +export type RaycastCredentials = { + accessToken?: string; + providerSpecificData?: { + deviceId?: string; + aid?: string; + sigSecret?: string; + userAgent?: string; + experimental?: string; + }; +}; + +export type RaycastModelEntry = { + id: string; + model: string; + name: string; + provider: string; + requires_better_ai?: boolean; + availability?: string; +}; + +type ChatMessage = { role?: string; content?: unknown }; + +export function rot13rot5(input: string): string { + return input.replace(/[A-Za-z0-9]/g, (char) => { + const code = char.charCodeAt(0); + if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + 13) % 26) + 65); + if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + 13) % 26) + 97); + return String.fromCharCode(((code - 48 + 5) % 10) + 48); + }); +} + +export function signatureV2( + timestamp: string, + deviceId: string, + payload: string, + secret: string +): string { + const bodyHash = createHash("sha256").update(payload).digest("hex"); + const message = [timestamp, deviceId, bodyHash].map(rot13rot5).join("."); + return createHmac("sha256", secret).update(message).digest("hex"); +} + +function base64UrlJson(value: Record): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +export function raycastJwt(aid: string, secret: string): string { + const iat = Date.now() / 1000; + const header = base64UrlJson({ typ: "JWT", alg: "HS256" }); + const payload = base64UrlJson({ aid, exp: iat + 60, iat }); + const signature = createHmac("sha256", secret) + .update(`${header}.${payload}`) + .digest("base64url"); + return `${header}.${payload}.${signature}`; +} + +export function decodeAidFromRaycastJwt(jwt: string): string | null { + const parts = jwt.trim().split("."); + if (parts.length < 2) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { + aid?: string; + }; + return payload.aid || null; + } catch { + return null; + } +} + +export function resolveRaycastSecrets(credentials: RaycastCredentials): { + bearerToken: string; + deviceId: string; + aid: string; + sigSecret: string; +} { + const psd = credentials.providerSpecificData || {}; + const bearerToken = (credentials.accessToken || "").trim(); + const deviceId = (psd.deviceId || "").trim(); + const aid = (psd.aid || deviceId || "").trim(); + const sigSecret = ( + psd.sigSecret || + process.env.RAYCAST_SIG_SECRET || + RAYCAST_DEFAULT_SIG_SECRET + ).trim(); + + if (!bearerToken) throw new Error("Raycast bearer token is required"); + if (!deviceId) throw new Error("Raycast device ID is required"); + if (!sigSecret) throw new Error("Raycast signature secret is required"); + + return { bearerToken, deviceId, aid, sigSecret }; +} + +export function buildRaycastHeaders(payload: string, credentials: RaycastCredentials): Record { + const { bearerToken, deviceId, aid, sigSecret } = resolveRaycastSecrets(credentials); + const psd = credentials.providerSpecificData || {}; + const timestamp = Math.floor(Date.now() / 1000).toString(); + + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "X-Raycast-Timestamp": timestamp, + "Accept-Language": "en-US,en;q=0.9", + "X-Raycast-DeviceId": deviceId, + "Content-Type": "application/json", + "X-Raycast-Signature-v2": signatureV2(timestamp, deviceId, payload, sigSecret), + "X-Raycast-Experimental": psd.experimental || RAYCAST_DEFAULT_EXPERIMENTAL, + "X-Raycast-Signature": raycastJwt(aid, sigSecret), + "User-Agent": psd.userAgent || RAYCAST_DEFAULT_USER_AGENT, + }; +} + +export function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return JSON.stringify(content ?? ""); + + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && "type" in part && (part as { type?: string }).type === "text") { + return String((part as { text?: string }).text || ""); + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export function convertOpenAiMessages(messages: ChatMessage[]): { + raycastMessages: Array<{ author: string; content: { text: string } }>; + systemInstruction: string; +} { + let systemInstruction = "markdown"; + const raycastMessages: Array<{ author: string; content: { text: string } }> = []; + + for (const [index, message] of messages.entries()) { + if (message.role === "system" && index === 0) { + systemInstruction = contentToText(message.content); + continue; + } + + if (message.role === "user" || message.role === "assistant") { + raycastMessages.push({ + author: message.role, + content: { text: contentToText(message.content) }, + }); + } + } + + return { raycastMessages, systemInstruction }; +} + +export function inferProviderInfo(modelId: string): { provider: string; model: string } { + if (modelId.startsWith("openai_o1-")) { + return { provider: "openai", model: modelId.slice("openai_o1-".length) }; + } + + const providers = [ + "anthropic", + "baseten", + "google", + "groq", + "mistral", + "openai", + "perplexity", + "raycast", + "together", + "xai", + ]; + + for (const provider of providers) { + const prefix = `${provider}-`; + if (modelId.startsWith(prefix)) { + return { provider, model: modelId.slice(prefix.length) }; + } + } + + if (modelId.includes("/")) return { provider: "baseten", model: modelId }; + return { provider: "openai", model: modelId || "gpt-5-mini" }; +} + +export function buildRaycastChatBody( + modelId: string, + messages: ChatMessage[], + temperature?: number +): string { + const { provider, model } = inferProviderInfo(modelId); + const { raycastMessages, systemInstruction } = convertOpenAiMessages(messages); + + if (raycastMessages.length === 0) { + throw new Error("Raycast requires at least one user or assistant message"); + } + + return JSON.stringify({ + model, + provider, + messages: raycastMessages, + system_instruction: systemInstruction, + temperature: temperature ?? 0.5, + additional_system_instructions: "", + debug: false, + locale: "en-US", + source: "ai_chat", + thread_id: randomUUID(), + tools: [], + }); +} + +export function parseRaycastSseText(responseText: string): string { + let fullText = ""; + + for (const line of responseText.split("\n")) { + if (!line.startsWith("data:")) continue; + try { + const data = JSON.parse(line.slice(5).trim()) as { text?: string }; + if (data.text) fullText += data.text; + } catch { + // Ignore non-JSON SSE lines. + } + } + + return fullText; +} + +export async function fetchRaycastModels( + credentials: RaycastCredentials, + options?: { includePremium?: boolean; includeDeprecated?: boolean } +): Promise { + const payload = "{}"; + const headers = buildRaycastHeaders(payload, credentials); + const res = await fetch(RAYCAST_MODELS_URL, { method: "GET", headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Raycast models error [${res.status}]: ${text.slice(0, 300)}`); + } + + const data = (await res.json()) as { models?: RaycastModelEntry[] }; + const includePremium = options?.includePremium ?? true; + const includeDeprecated = options?.includeDeprecated ?? true; + + return (data.models || []).filter((model) => { + if (!includePremium && model.requires_better_ai) return false; + if (!includeDeprecated && model.availability === "deprecated") return false; + return true; + }); +} diff --git a/scripts/raycast/extract-credentials.mjs b/scripts/raycast/extract-credentials.mjs new file mode 100644 index 0000000000..44d3866239 --- /dev/null +++ b/scripts/raycast/extract-credentials.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * @file extract-credentials.mjs + * @description Print Raycast Pro credentials from local macOS install (redacted preview). + * + * Usage: node scripts/raycast/extract-credentials.mjs + * + * @changes + * - [2026-07-27] [Composer] - CLI credential extractor for local Raycast + */ + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +const RAYCAST_SALT = "yvkwWXzxPPBAqY2tmaKrB*DvYjjMaeEf"; +const RAYCAST_SUPPORT = join(homedir(), "Library", "Application Support", "com.raycast.macos"); +const RAYCAST_DB = join(RAYCAST_SUPPORT, "raycast-enc.sqlite"); + +function redact(s, keep = 8) { + if (!s || s.length <= keep * 2) return "***"; + return `${s.slice(0, keep)}…${s.slice(-4)}`; +} + +function readKeychain(account) { + return JSON.parse( + execFileSync("security", ["find-generic-password", "-s", "Raycast", "-a", account, "-w"], { + encoding: "utf-8", + }).trim() + ); +} + +function dbPassphrase() { + const keyHex = execFileSync( + "security", + ["find-generic-password", "-s", "Raycast", "-a", "database_key", "-w"], + { encoding: "utf-8" } + ).trim(); + return createHash("sha256") + .update(keyHex + RAYCAST_SALT) + .digest("hex"); +} + +function queryDb(sql) { + const tmpDir = mkdtempSync(join(tmpdir(), "raycast-extract-")); + const tmpDb = join(tmpDir, "db.sqlite"); + copyFileSync(RAYCAST_DB, tmpDb); + for (const ext of ["-wal", "-shm"]) { + const src = RAYCAST_DB + ext; + if (existsSync(src)) copyFileSync(src, tmpDb + ext); + } + const passphrase = dbPassphrase(); + const input = `PRAGMA key = '${passphrase}';\n.mode json\n${sql}`; + const out = execFileSync("sqlcipher", [tmpDb], { input, encoding: "utf-8" }); + for (const ext of ["", "-wal", "-shm"]) { + try { + unlinkSync(tmpDb + ext); + } catch {} + } + try { + rmdirSync(tmpDir); + } catch {} + const jsonStr = out.startsWith("ok\n") ? out.slice(3) : out; + return JSON.parse(jsonStr.trim() || "[]"); +} + +if (process.platform !== "darwin") { + console.error("macOS only"); + process.exit(1); +} + +const store = readKeychain("raycast-store_credentials"); +const token = store?.oauth?.access_token; +if (!token) { + console.error("No Raycast bearer token in Keychain — open Raycast and sign in"); + process.exit(1); +} + +const users = queryDb("SELECT analyticsId, email, username, hasProFeatures, hasBetterAI FROM user LIMIT 1;"); +const user = users[0] || {}; +const deviceId = + user.analyticsId || + JSON.parse(readFileSync(join(RAYCAST_SUPPORT, "posthog.distinctId"), "utf-8"))["posthog.distinctId"]; + +console.log(JSON.stringify({ + accessTokenPreview: redact(token), + accessToken: token, + deviceId, + aid: deviceId, + email: user.email || store?.user?.email, + username: user.username || store?.user?.username, + hasProFeatures: !!user.hasProFeatures, + hasBetterAI: !!user.hasBetterAI, + sources: { + bearer: "Keychain Raycast / raycast-store_credentials", + deviceId: "raycast-enc.sqlite user.analyticsId", + }, +}, null, 2)); diff --git a/scripts/raycast/usage-benchmark.mjs b/scripts/raycast/usage-benchmark.mjs new file mode 100644 index 0000000000..85c30232d6 --- /dev/null +++ b/scripts/raycast/usage-benchmark.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * @file usage-benchmark.mjs + * @description Battle-test Raycast Pro usage via OmniRoute local endpoint. + * + * Env (required): + * OMNIROUTE_URL default http://127.0.0.1:20128/v1 + * OMNIROUTE_API_KEY OmniRoute API key (if REQUIRE_API_KEY) + * + * Env (optional — direct Raycast probe without OmniRoute): + * RAYCAST_BEARER_TOKEN + * RAYCAST_DEVICE_ID + * RAYCAST_AID + * RAYCAST_SIG_SECRET + * + * Usage: + * node scripts/raycast/usage-benchmark.mjs --models 5 --rounds 3 + * node scripts/raycast/usage-benchmark.mjs --model openai-gpt-5-mini --rounds 10 + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast Pro usage benchmark script + */ + +import { createHmac, createHash } from "node:crypto"; + +const args = process.argv.slice(2); +function arg(name, fallback) { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +} + +const rounds = Number(arg("rounds", "3")); +const model = arg("model", ""); +const modelCount = Number(arg("models", "5")); +const omnirouteUrl = (process.env.OMNIROUTE_URL || "http://127.0.0.1:20128/v1").replace(/\/$/, ""); +const apiKey = process.env.OMNIROUTE_API_KEY || ""; + +const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions"; +const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models"; +const SIG_SECRET = + process.env.RAYCAST_SIG_SECRET || + "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"; + +function rot13rot5(input) { + return input.replace(/[A-Za-z0-9]/g, (char) => { + const code = char.charCodeAt(0); + if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + 13) % 26) + 65); + if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + 13) % 26) + 97); + return String.fromCharCode(((code - 48 + 5) % 10) + 48); + }); +} + +function signatureV2(timestamp, deviceId, payload, secret) { + const bodyHash = createHash("sha256").update(payload).digest("hex"); + const message = [timestamp, deviceId, bodyHash].map(rot13rot5).join("."); + return createHmac("sha256", secret).update(message).digest("hex"); +} + +function raycastJwt(aid, secret) { + const iat = Date.now() / 1000; + const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ aid, exp: iat + 60, iat })).toString("base64url"); + const signature = createHmac("sha256", secret) + .update(`${header}.${payload}`) + .digest("base64url"); + return `${header}.${payload}.${signature}`; +} + +function raycastHeaders(payload) { + const bearerToken = process.env.RAYCAST_BEARER_TOKEN; + const deviceId = process.env.RAYCAST_DEVICE_ID; + const aid = process.env.RAYCAST_AID; + if (!bearerToken || !deviceId || !aid) { + throw new Error("Set RAYCAST_BEARER_TOKEN, RAYCAST_DEVICE_ID, RAYCAST_AID for direct probe"); + } + const timestamp = Math.floor(Date.now() / 1000).toString(); + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "X-Raycast-Timestamp": timestamp, + "X-Raycast-DeviceId": deviceId, + "Content-Type": "application/json", + "X-Raycast-Signature-v2": signatureV2(timestamp, deviceId, payload, SIG_SECRET), + "X-Raycast-Signature": raycastJwt(aid, SIG_SECRET), + "X-Raycast-Experimental": "chatBranching, mcpHTTPServer", + "User-Agent": "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))", + }; +} + +async function fetchRaycastModels() { + const payload = "{}"; + const res = await fetch(RAYCAST_MODELS_URL, { method: "GET", headers: raycastHeaders(payload) }); + const text = await res.text(); + if (!res.ok) throw new Error(`models [${res.status}]: ${text.slice(0, 200)}`); + const data = JSON.parse(text); + return (data.models || []).map((m) => m.id); +} + +async function chatOmniroute(modelId, prompt) { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const started = Date.now(); + const res = await fetch(`${omnirouteUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: `raycast/${modelId}`, + messages: [{ role: "user", content: prompt }], + stream: false, + max_tokens: 32, + }), + }); + const ms = Date.now() - started; + const body = await res.text(); + return { ok: res.ok, status: res.status, ms, body: body.slice(0, 300) }; +} + +async function main() { + console.log(`OmniRoute: ${omnirouteUrl}`); + console.log(`Rounds per model: ${rounds}`); + + let models = []; + if (model) { + models = [model]; + } else if (process.env.RAYCAST_BEARER_TOKEN) { + models = (await fetchRaycastModels()).slice(0, modelCount); + console.log(`Direct Raycast model probe — testing ${models.length} models via OmniRoute`); + } else { + models = ["openai-gpt-5-mini"]; + console.log("No RAYCAST_* env — using default model openai-gpt-5-mini via OmniRoute combo id"); + } + + const results = []; + for (const modelId of models) { + let ok = 0; + let fail = 0; + const latencies = []; + for (let i = 0; i < rounds; i++) { + const prompt = `Raycast benchmark round ${i + 1} — reply with exactly: pong`; + try { + const r = await chatOmniroute(modelId, prompt); + latencies.push(r.ms); + if (r.ok) ok++; + else { + fail++; + console.error(` FAIL ${modelId} #${i + 1} [${r.status}]: ${r.body}`); + } + } catch (err) { + fail++; + console.error(` ERR ${modelId} #${i + 1}:`, err.message); + } + } + const avg = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0; + results.push({ modelId, ok, fail, avgMs: avg }); + console.log(`${modelId}: ${ok}/${rounds} ok, avg ${avg}ms`); + } + + console.log("\nSummary:"); + console.table(results); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 6403d69de4..92a2c40169 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -8,6 +8,7 @@ import { KiroOAuthWrapper, CursorAuthModal, TraeAuthModal, + RaycastAuthModal, ProxyConfigModal, } from "@/shared/components"; import RiskNoticeModal from "../../components/RiskNoticeModal"; @@ -276,6 +277,13 @@ export default function ProviderModalsPanel({ onSuccess={handleOAuthSuccess} onClose={() => setShowOAuthModal(false)} /> + ) : providerId === "raycast" ? ( + setShowOAuthModal(false)} + /> ) : ( + raycastService.probeModels({ + accessToken: local.accessToken, + deviceId: local.deviceId, + aid: resolved.aid, + }) + ); + + const connection: any = await createProviderConnection({ + provider: "raycast", + authType: "oauth", + accessToken: local.accessToken, + refreshToken: null, + email: local.email || null, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + providerSpecificData: { + deviceId: local.deviceId, + aid: resolved.aid, + authMethod: "auto_imported", + username: local.username, + hasProFeatures: local.hasProFeatures, + hasBetterAI: local.hasBetterAI, + extractSource: local.source, + modelCount: models.length, + premiumModelCount: models.filter((m) => m.requires_better_ai).length, + }, + testStatus: "active", + }); + + await replaceSyncedAvailableModelsForConnection( + "raycast", + connection.id, + models.map((model) => ({ + id: model.id, + name: model.name || model.id, + })) + ); + + return NextResponse.json({ + success: true, + source: local.source, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + models: { + total: models.length, + premium: models.filter((m) => m.requires_better_ai).length, + sample: models.slice(0, 12).map((m) => m.id), + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error("Raycast auto-import error:", message); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/oauth/raycast/import/route.ts b/src/app/api/oauth/raycast/import/route.ts new file mode 100644 index 0000000000..0ff02764fa --- /dev/null +++ b/src/app/api/oauth/raycast/import/route.ts @@ -0,0 +1,143 @@ +/** + * @file route.ts + * @description Import Raycast Pro credentials captured from macOS app traffic. + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast token import route (local dev) + */ + +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { RaycastService } from "@/lib/oauth/services/raycast"; +import { raycastImportSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { resolveProxyForProvider } from "@/models"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; + +async function requireOAuthImportAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function POST(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(raycastImportSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { accessToken, deviceId, aid, signatureJwt, sigSecret } = validation.data; + const raycastService = new RaycastService(); + const resolved = raycastService.validateCredentials({ + accessToken, + deviceId, + aid, + signatureJwt, + sigSecret, + }); + + const proxy = await resolveProxyForProvider("raycast"); + const models = await runWithProxyContext(proxy, () => + raycastService.probeModels({ + accessToken: accessToken.trim(), + deviceId: deviceId.trim(), + aid: resolved.aid, + sigSecret: sigSecret?.trim(), + }) + ); + + const connection: any = await createProviderConnection({ + provider: "raycast", + authType: "oauth", + accessToken: accessToken.trim(), + refreshToken: null, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + providerSpecificData: { + deviceId: deviceId.trim(), + aid: resolved.aid, + sigSecret: sigSecret?.trim() || "", + authMethod: "imported", + modelCount: models.length, + premiumModelCount: models.filter((m) => m.requires_better_ai).length, + }, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + }, + models: { + total: models.length, + premium: models.filter((m) => m.requires_better_ai).length, + sample: models.slice(0, 8).map((m) => m.id), + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error("Raycast import token error:", message); + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function GET(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + const raycastService = new RaycastService(); + + return NextResponse.json({ + provider: "raycast", + method: "import_token", + localDevOnly: true, + instructions: raycastService.getCaptureInstructions(), + requiredFields: [ + { + name: "accessToken", + label: "Bearer Token", + description: "From Authorization: Bearer header on backend.raycast.com requests", + type: "textarea", + }, + { + name: "deviceId", + label: "Device ID", + description: "From X-Raycast-DeviceId header", + type: "text", + }, + { + name: "signatureJwt", + label: "Signature JWT", + description: "From X-Raycast-Signature header (AID decoded automatically)", + type: "textarea", + }, + { + name: "sigSecret", + label: "Signature Secret", + description: "Optional override — defaults to community-extracted SIG_SECRET", + type: "text", + }, + ], + }); +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c7d74a1f9d..331814cc15 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -93,6 +93,8 @@ import { } from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; +import { fetchRaycastModels } from "@omniroute/open-sse/services/raycast.ts"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { type JsonRecord, asRecord, @@ -1345,6 +1347,58 @@ export async function GET( }); } + if (provider === "raycast") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + const psd = asRecord(connection.providerSpecificData); + const deviceId = toNonEmptyString(psd.deviceId); + const aid = toNonEmptyString(psd.aid) || deviceId; + if (!accessToken || !deviceId) { + const fallback = buildDiscoveryFallbackResponse({ + localWarning: "Raycast credentials incomplete — using local catalog", + }); + if (fallback) return fallback; + return NextResponse.json({ error: "Raycast credentials incomplete" }, { status: 400 }); + } + + try { + const raycastModels = await runWithProxyContext(proxy, () => + fetchRaycastModels({ + accessToken, + providerSpecificData: { + deviceId, + aid: aid || deviceId, + sigSecret: toNonEmptyString(psd.sigSecret) || undefined, + }, + }) + ); + const models = raycastModels.map((model) => ({ + id: model.id, + name: model.name || model.id, + owned_by: model.provider || provider, + ...(model.requires_better_ai ? { premium: true } : {}), + ...(model.availability ? { availability: model.availability } : {}), + })); + return buildApiDiscoveryResponse(models); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log("[models] raycast fetch failed:", message); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: `Raycast API unavailable (${message}) — using cached catalog`, + localWarning: `Raycast API unavailable (${message}) — using local catalog`, + }); + if (fallback) return fallback; + return NextResponse.json( + { error: `Failed to fetch Raycast models: ${message}` }, + { status: 502 } + ); + } + } + if (provider === "cursor") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 1fa5a9f3dc..f9aee266a0 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -414,6 +414,17 @@ export const TRAE_CONFIG = { "Authorize via trae.ai in the popup, or sign in to solo.trae.ai and paste the Cloud-IDE-JWT from the Authorization header (~14-day lifetime).", }; +// Raycast Pro AI — reverse-engineered, unofficial API. LOCAL / PERSONAL USE ONLY. +// See docs/security/PUBLIC_CREDS.md pattern: no secrets in repo; credentials from user's Mac. +export const RAYCAST_CONFIG = { + apiEndpoint: "https://backend.raycast.com", + chatEndpoint: "/api/v1/ai/chat_completions", + modelsEndpoint: "/api/v1/ai/models", + clientType: "macos-app", + captureInstructions: + "macOS only: use Auto-Import (Keychain + Raycast DB) or capture Bearer, X-Raycast-DeviceId, and optional X-Raycast-Signature JWT from backend.raycast.com traffic.", +}; + // Windsurf / Devin CLI Configuration // // 2026-05-29 (Phase 1 hotfix): diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 9a8ede1c4e..06e4813042 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -22,6 +22,7 @@ import { gitlabDuo } from "./gitlab-duo"; import { kiro } from "./kiro"; import { cursor } from "./cursor"; import { trae } from "./trae"; +import { raycast } from "./raycast"; import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { windsurf } from "./windsurf"; @@ -45,6 +46,7 @@ export const PROVIDERS = { "amazon-q": kiro, cursor, trae, + raycast, kilocode, cline, // clinepass reuses the Cline WorkOS OAuth flow 1:1 (same api.cline.bot host, same token diff --git a/src/lib/oauth/providers/raycast.ts b/src/lib/oauth/providers/raycast.ts new file mode 100644 index 0000000000..85f15bd35c --- /dev/null +++ b/src/lib/oauth/providers/raycast.ts @@ -0,0 +1,37 @@ +/** + * @file raycast.ts + * @description Raycast Pro token-import OAuth provider (reverse-engineered, local dev only). + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast Pro import_token provider + */ + +import { RAYCAST_CONFIG } from "../constants/oauth"; + +type RaycastRawTokens = { + accessToken?: string; + access_token?: string; + deviceId?: string; + device_id?: string; + aid?: string; + sigSecret?: string; + signatureSecret?: string; + signatureJwt?: string; + expiresIn?: number; +}; + +export const raycast = { + config: RAYCAST_CONFIG, + flowType: "import_token", + mapTokens: (tokens: RaycastRawTokens) => ({ + accessToken: tokens.accessToken || tokens.access_token, + refreshToken: null, + expiresIn: tokens.expiresIn || 30 * 24 * 60 * 60, + providerSpecificData: { + deviceId: tokens.deviceId || tokens.device_id || "", + aid: tokens.aid || "", + sigSecret: tokens.sigSecret || tokens.signatureSecret || "", + authMethod: "imported", + }, + }), +}; diff --git a/src/lib/oauth/services/raycast.ts b/src/lib/oauth/services/raycast.ts new file mode 100644 index 0000000000..cadd9d82a8 --- /dev/null +++ b/src/lib/oauth/services/raycast.ts @@ -0,0 +1,65 @@ +/** + * @file raycast.ts + * @description Raycast Pro credential validation via live models API probe. + * + * @changes + * - [2026-07-27] [Composer] - Initial Raycast import validation service + */ + +import { + decodeAidFromRaycastJwt, + fetchRaycastModels, + type RaycastModelEntry, +} from "@omniroute/open-sse/services/raycast.ts"; + +export class RaycastService { + validateCredentials(input: { + accessToken: string; + deviceId: string; + aid?: string; + signatureJwt?: string; + sigSecret?: string; + }): { aid: string } { + const accessToken = input.accessToken.trim(); + const deviceId = input.deviceId.trim(); + let aid = (input.aid || "").trim(); + + if (!aid && input.signatureJwt?.trim()) { + aid = decodeAidFromRaycastJwt(input.signatureJwt.trim()) || ""; + } + + if (!accessToken) throw new Error("Bearer token is required"); + if (!deviceId) throw new Error("Device ID is required"); + + // AID is optional for current Raycast API — fall back to deviceId when not captured manually. + if (!aid) aid = deviceId; + + return { aid }; + } + + async probeModels(credentials: { + accessToken: string; + deviceId: string; + aid: string; + sigSecret?: string; + }): Promise { + return fetchRaycastModels({ + accessToken: credentials.accessToken, + providerSpecificData: { + deviceId: credentials.deviceId, + aid: credentials.aid, + sigSecret: credentials.sigSecret, + }, + }); + } + + getCaptureInstructions(): string[] { + return [ + "Easiest: click Auto-Import (macOS) — reads Keychain + local Raycast DB.", + "Manual fallback: Proxyman/Charles SSL proxy on backend.raycast.com.", + "Bearer token lives in Keychain: Raycast / raycast-store_credentials.", + "Device ID = analyticsId in ~/Library/Application Support/com.raycast.macos/posthog.distinctId.", + "Signature JWT is optional with current Raycast builds.", + ]; + } +} diff --git a/src/lib/oauth/services/raycastLocal.ts b/src/lib/oauth/services/raycastLocal.ts new file mode 100644 index 0000000000..976a023a39 --- /dev/null +++ b/src/lib/oauth/services/raycastLocal.ts @@ -0,0 +1,198 @@ +/** + * @file raycastLocal.ts + * @description Extract Raycast Pro credentials from local macOS install (Keychain + encrypted DB). + * + * @changes + * - [2026-07-27] [Composer] - Auto-extract bearer token and device ID from local Raycast + */ + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +const RAYCAST_SALT = "yvkwWXzxPPBAqY2tmaKrB*DvYjjMaeEf"; +const RAYCAST_SUPPORT = join(homedir(), "Library", "Application Support", "com.raycast.macos"); +const RAYCAST_DB = join(RAYCAST_SUPPORT, "raycast-enc.sqlite"); +const POSTHOG_DISTINCT = join(RAYCAST_SUPPORT, "posthog.distinctId"); + +export type RaycastLocalCredentials = { + accessToken: string; + deviceId: string; + aid: string; + email?: string; + username?: string; + hasProFeatures?: boolean; + hasBetterAI?: boolean; + source: "keychain+analyticsId" | "keychain+posthog"; +}; + +function readKeychainJson(account: string): Record | null { + try { + const raw = execFileSync( + "security", + ["find-generic-password", "-s", "Raycast", "-a", account, "-w"], + { encoding: "utf-8" } + ).trim(); + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +function getDatabasePassphrase(): string { + const keyHex = execFileSync( + "security", + ["find-generic-password", "-s", "Raycast", "-a", "database_key", "-w"], + { encoding: "utf-8" } + ).trim(); + return createHash("sha256") + .update(keyHex + RAYCAST_SALT) + .digest("hex"); +} + +function queryEncryptedDb(passphrase: string, sql: string): unknown[] { + if (!existsSync(RAYCAST_DB)) return []; + + const tmpDir = mkdtempSync(join(tmpdir(), "omniroute-raycast-")); + const tmpDb = join(tmpDir, "raycast-enc.sqlite"); + + const cleanup = () => { + for (const ext of ["", "-wal", "-shm"]) { + try { + unlinkSync(tmpDb + ext); + } catch { + // ignore + } + } + try { + rmdirSync(tmpDir); + } catch { + // ignore + } + }; + + try { + copyFileSync(RAYCAST_DB, tmpDb); + for (const ext of ["-wal", "-shm"]) { + const src = RAYCAST_DB + ext; + if (existsSync(src)) copyFileSync(src, tmpDb + ext); + } + + const input = `PRAGMA key = '${passphrase}';\n.mode json\n${sql}`; + const result = execFileSync("sqlcipher", [tmpDb], { input, encoding: "utf-8" }); + const jsonStr = result.startsWith("ok\n") ? result.slice(3) : result; + return JSON.parse(jsonStr.trim() || "[]") as unknown[]; + } catch { + return []; + } finally { + cleanup(); + } +} + +function readAnalyticsIdFromDb(): string | null { + try { + const passphrase = getDatabasePassphrase(); + const rows = queryEncryptedDb( + passphrase, + "SELECT analyticsId FROM user WHERE analyticsId IS NOT NULL LIMIT 1;" + ) as Array<{ analyticsId?: string }>; + const id = rows[0]?.analyticsId?.trim(); + return id || null; + } catch { + return null; + } +} + +function readAnalyticsIdFromPosthog(): string | null { + try { + if (!existsSync(POSTHOG_DISTINCT)) return null; + const parsed = JSON.parse(readFileSync(POSTHOG_DISTINCT, "utf-8")) as { + "posthog.distinctId"?: string; + }; + const id = parsed["posthog.distinctId"]?.trim(); + return id || null; + } catch { + return null; + } +} + +function readUserProfile(): { email?: string; username?: string; hasProFeatures?: boolean; hasBetterAI?: boolean } { + try { + const passphrase = getDatabasePassphrase(); + const rows = queryEncryptedDb( + passphrase, + "SELECT email, username, hasProFeatures, hasBetterAI FROM user LIMIT 1;" + ) as Array<{ + email?: string; + username?: string; + hasProFeatures?: number; + hasBetterAI?: number; + }>; + const row = rows[0]; + if (!row) return {}; + return { + email: row.email, + username: row.username, + hasProFeatures: !!row.hasProFeatures, + hasBetterAI: !!row.hasBetterAI, + }; + } catch { + return {}; + } +} + +export function isRaycastLocalExtractAvailable(): boolean { + if (process.platform !== "darwin") return false; + try { + execFileSync("which", ["sqlcipher"], { encoding: "utf-8" }); + } catch { + return false; + } + return existsSync(RAYCAST_DB) || existsSync(POSTHOG_DISTINCT); +} + +/** + * Pull Raycast Pro credentials from the local macOS install. + * Bearer token: Keychain entry `raycast-store_credentials` → oauth.access_token + * Device ID: user.analyticsId (same as posthog.distinctId) + */ +export function extractLocalRaycastCredentials(): RaycastLocalCredentials { + if (process.platform !== "darwin") { + throw new Error("Raycast auto-import is macOS-only"); + } + + const store = readKeychainJson("raycast-store_credentials"); + const oauth = (store?.oauth || {}) as { access_token?: string }; + const accessToken = oauth.access_token?.trim(); + if (!accessToken) { + throw new Error( + "Raycast bearer token not found in Keychain — open Raycast and sign in first" + ); + } + + const analyticsFromDb = readAnalyticsIdFromDb(); + const analyticsFromPosthog = readAnalyticsIdFromPosthog(); + const deviceId = analyticsFromDb || analyticsFromPosthog; + if (!deviceId) { + throw new Error( + "Raycast device/analytics ID not found — launch Raycast once so it writes local state" + ); + } + + const profile = readUserProfile(); + const user = (store?.user || {}) as { email?: string; username?: string }; + + return { + accessToken, + deviceId, + // V1 JWT aid — chat works without a captured signature JWT; deviceId is a stable fallback. + aid: deviceId, + email: profile.email || user.email, + username: profile.username || user.username, + hasProFeatures: profile.hasProFeatures, + hasBetterAI: profile.hasBetterAI, + source: analyticsFromDb ? "keychain+analyticsId" : "keychain+posthog", + }; +} diff --git a/src/shared/components/RaycastAuthModal.tsx b/src/shared/components/RaycastAuthModal.tsx new file mode 100644 index 0000000000..7d1461294f --- /dev/null +++ b/src/shared/components/RaycastAuthModal.tsx @@ -0,0 +1,213 @@ +"use client"; + +/** + * @file RaycastAuthModal.tsx + * @description Import Raycast Pro AI credentials (auto-detect from local macOS install). + * + * @changes + * - [2026-07-27] [Composer] - Add one-click auto-import from Keychain + Raycast DB + */ + +import { useEffect, useState } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import Input from "./Input"; + +type RaycastAuthModalProps = { + isOpen: boolean; + reauthConnection?: unknown; + onSuccess?: () => void; + onClose: () => void; +}; + +export default function RaycastAuthModal({ + isOpen, + onSuccess, + onClose, +}: RaycastAuthModalProps) { + const [accessToken, setAccessToken] = useState(""); + const [deviceId, setDeviceId] = useState(""); + const [signatureJwt, setSignatureJwt] = useState(""); + const [sigSecret, setSigSecret] = useState(""); + const [importing, setImporting] = useState(false); + const [autoAvailable, setAutoAvailable] = useState(false); + const [showManual, setShowManual] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + fetch("/api/oauth/raycast/auto-import") + .then((r) => r.json()) + .then((d) => setAutoAvailable(!!d.available)) + .catch(() => setAutoAvailable(false)); + }, [isOpen]); + + const handleAutoImport = async () => { + setImporting(true); + setError(null); + try { + const res = await fetch("/api/oauth/raycast/auto-import", { method: "POST" }); + const data = await res.json(); + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : data.error?.message || "Auto-import failed" + ); + } + onSuccess?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + }; + + const handleImportToken = async () => { + if (!accessToken.trim() || !deviceId.trim()) { + setError("Bearer token and device ID are required."); + return; + } + + setImporting(true); + setError(null); + + try { + const body: Record = { + accessToken: accessToken.trim(), + deviceId: deviceId.trim(), + }; + if (signatureJwt.trim()) body.signatureJwt = signatureJwt.trim(); + if (sigSecret.trim()) body.sigSecret = sigSecret.trim(); + + const res = await fetch("/api/oauth/raycast/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : data.error?.message || "Import failed" + ); + } + + onSuccess?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + }; + + return ( + +
+
+

+ Auto-import (recommended on macOS): reads your local Raycast login from + Keychain + analytics device ID. No proxy needed. +

+ + {!autoAvailable && ( +

+ Install sqlcipher: brew install sqlcipher +

+ )} +
+ +
+

+ Local dev only. Uses your Raycast Pro subscription via reverse-engineered + API. Not official — may break on Raycast updates. +

+ +
+ + {showManual && ( + <> +
+ +