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)
This commit is contained in:
Andrew B.
2026-08-06 09:08:44 -05:00
committed by GitHub
parent 2a94cbfe14
commit 0e1f40ed1f
25 changed files with 1779 additions and 1 deletions

View File

@@ -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
# ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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]
>

View File

@@ -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<string, RegistryEntry> = {
glmt: glmtProvider,
"glm-cn": glm_cnProvider,
trae: traeProvider,
raycast: raycastProvider,
"muse-spark-web": muse_spark_webProvider,
lmarena: lmarenaProvider,
kilocode: kilocodeProvider,

View File

@@ -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" },
],
};

View File

@@ -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

View File

@@ -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<string, unknown>;
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<string, string> {
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<string, string> | 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,
};
}
}

View File

@@ -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, unknown>): 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<string, string> {
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<RaycastModelEntry[]> {
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;
});
}

View File

@@ -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));

View File

@@ -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);
});

View File

@@ -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" ? (
<RaycastAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}

View File

@@ -0,0 +1,124 @@
/**
* @file route.ts
* @description Auto-import Raycast Pro credentials from local macOS Keychain + DB.
*
* @changes
* - [2026-07-27] [Composer] - One-click local Raycast credential extraction
*/
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
import { RaycastService } from "@/lib/oauth/services/raycast";
import {
extractLocalRaycastCredentials,
isRaycastLocalExtractAvailable,
} from "@/lib/oauth/services/raycastLocal";
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 GET(request: Request) {
const authResponse = await requireOAuthImportAuth(request);
if (authResponse) return authResponse;
return NextResponse.json({
available: isRaycastLocalExtractAvailable(),
platform: process.platform,
requires: ["macOS", "Raycast.app installed", "sqlcipher CLI (brew install sqlcipher)"],
sources: {
bearerToken: "Keychain → Raycast / raycast-store_credentials → oauth.access_token",
deviceId:
"Raycast encrypted DB user.analyticsId (same as posthog.distinctId on disk)",
},
});
}
export async function POST(request: Request) {
const authResponse = await requireOAuthImportAuth(request);
if (authResponse) return authResponse;
if (!isRaycastLocalExtractAvailable()) {
return NextResponse.json(
{
error:
"Raycast auto-import unavailable — need macOS, Raycast installed, and sqlcipher (`brew install sqlcipher`)",
},
{ status: 400 }
);
}
try {
const local = extractLocalRaycastCredentials();
const raycastService = new RaycastService();
const resolved = raycastService.validateCredentials({
accessToken: local.accessToken,
deviceId: local.deviceId,
aid: local.aid,
});
const proxy = await resolveProxyForProvider("raycast");
const models = await runWithProxyContext(proxy, () =>
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 });
}
}

View File

@@ -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",
},
],
});
}

View File

@@ -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;

View File

@@ -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):

View File

@@ -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

View File

@@ -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",
},
}),
};

View File

@@ -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<RaycastModelEntry[]> {
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.",
];
}
}

View File

@@ -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<string, unknown> | null {
try {
const raw = execFileSync(
"security",
["find-generic-password", "-s", "Raycast", "-a", account, "-w"],
{ encoding: "utf-8" }
).trim();
return JSON.parse(raw) as Record<string, unknown>;
} 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",
};
}

View File

@@ -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<string | null>(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<string, string> = {
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 (
<Modal isOpen={isOpen} title="Connect Raycast Pro AI" onClose={onClose}>
<div className="flex flex-col gap-4">
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-4 rounded-lg border border-emerald-200 dark:border-emerald-800">
<p className="text-sm text-emerald-900 dark:text-emerald-200 mb-3">
<strong>Auto-import (recommended on macOS):</strong> reads your local Raycast login from
Keychain + analytics device ID. No proxy needed.
</p>
<Button onClick={handleAutoImport} fullWidth disabled={importing || !autoAvailable}>
{importing
? "Importing…"
: autoAvailable
? "Auto-Import from Local Raycast"
: "Auto-Import unavailable (need macOS + sqlcipher)"}
</Button>
{!autoAvailable && (
<p className="text-xs text-emerald-800 dark:text-emerald-300 mt-2">
Install sqlcipher: <span className="font-mono">brew install sqlcipher</span>
</p>
)}
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-900 dark:text-amber-200">
<strong>Local dev only.</strong> Uses your Raycast Pro subscription via reverse-engineered
API. Not official may break on Raycast updates.
</p>
<button
type="button"
className="text-xs text-amber-800 dark:text-amber-300 underline mt-2"
onClick={() => setShowManual((v) => !v)}
>
{showManual ? "Hide manual import" : "Manual import (proxy capture)"}
</button>
</div>
{showManual && (
<>
<div>
<label className="block text-sm font-medium mb-2">
Bearer Token <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="rca_..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Device ID <span className="text-red-500">*</span>
</label>
<Input
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
placeholder="analyticsId / X-Raycast-DeviceId"
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Signature JWT <span className="text-text-muted text-xs">optional</span>
</label>
<textarea
value={signatureJwt}
onChange={(e) => setSignatureJwt(e.target.value)}
placeholder="X-Raycast-Signature (optional on current builds)"
rows={2}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
SIG_SECRET <span className="text-text-muted text-xs">optional</span>
</label>
<Input
value={sigSecret}
onChange={(e) => setSigSecret(e.target.value)}
placeholder="Override if Raycast rotated signing key"
className="font-mono text-sm"
/>
</div>
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !accessToken.trim() || !deviceId.trim()}
>
{importing ? "Importing…" : "Import Manually"}
</Button>
</>
)}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</Modal>
);
}

View File

@@ -32,6 +32,7 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as TraeAuthModal } from "./TraeAuthModal";
export { default as RaycastAuthModal } from "./RaycastAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Breadcrumbs } from "./Breadcrumbs";
export { default as EmptyState } from "./EmptyState";

View File

@@ -123,7 +123,7 @@ export const VIDEO_PROVIDER_IDS = new Set([
// IDE Providers: editors with built-in AI subscription (separate section in UI).
// These providers live in OAUTH_PROVIDERS but render under "IDE Providers"
// instead of "OAuth Providers" to avoid visual duplication.
export const IDE_PROVIDER_IDS = new Set(["cursor", "zed", "trae"]);
export const IDE_PROVIDER_IDS = new Set(["cursor", "zed", "trae", "raycast"]);
export const EMBEDDING_RERANK_PROVIDER_IDS = new Set(["voyage-ai", "jina-ai"]);

View File

@@ -171,6 +171,19 @@ export const OAUTH_PROVIDERS = {
authHint:
"Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <token>', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry.",
},
raycast: {
id: "raycast",
alias: "rc",
name: "Raycast Pro AI",
icon: "terminal",
color: "#FF6363",
textIcon: "RC",
website: "https://raycast.com/ai",
subscriptionRisk: true,
riskNoticeVariant: "oauth",
authHint:
"Unofficial integration — uses your Raycast Pro subscription via credentials from the macOS app (Auto-Import or manual capture). May break on Raycast updates. Not for redistribution; personal use only.",
},
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",

View File

@@ -184,6 +184,14 @@ export const traeImportSchema = z.object({
region: z.string().trim().optional(),
});
export const raycastImportSchema = z.object({
accessToken: z.string().trim().min(1, "Raycast bearer token is required"),
deviceId: z.string().trim().min(1, "Raycast device ID is required"),
aid: z.string().trim().optional(),
signatureJwt: z.string().trim().optional(),
sigSecret: z.string().trim().optional(),
});
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),

View File

@@ -0,0 +1,75 @@
/**
* @file raycast-auth.test.ts
* @description Unit tests for Raycast V2 signature + message conversion.
*
* @changes
* - [2026-07-27] [Composer] - Initial Raycast protocol tests
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildRaycastChatBody,
convertOpenAiMessages,
decodeAidFromRaycastJwt,
inferProviderInfo,
rot13rot5,
signatureV2,
} from "../../open-sse/services/raycast.ts";
describe("raycast auth protocol", () => {
it("rot13rot5 encodes alphanumerics", () => {
assert.equal(rot13rot5("ABCabc123"), "NOPnop678");
});
it("signatureV2 matches raycast-relay fixture shape", () => {
const secret = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408";
const timestamp = "1720000000";
const deviceId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const payload = "{}";
const sig = signatureV2(timestamp, deviceId, payload, secret);
assert.match(sig, /^[a-f0-9]{64}$/);
assert.equal(sig, signatureV2(timestamp, deviceId, payload, secret));
});
it("decodes aid from JWT payload", () => {
const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ aid: "test-aid-123", exp: 9999999999, iat: 1 })).toString(
"base64url"
);
const jwt = `${header}.${payload}.fake-sig`;
assert.equal(decodeAidFromRaycastJwt(jwt), "test-aid-123");
});
it("converts OpenAI messages to Raycast shape", () => {
const { raycastMessages, systemInstruction } = convertOpenAiMessages([
{ role: "system", content: "Be concise" },
{ role: "user", content: "Hello" },
]);
assert.equal(systemInstruction, "Be concise");
assert.deepEqual(raycastMessages, [{ author: "user", content: { text: "Hello" } }]);
});
it("infers provider prefixes from model ids", () => {
assert.deepEqual(inferProviderInfo("openai-gpt-5-mini"), {
provider: "openai",
model: "gpt-5-mini",
});
assert.deepEqual(inferProviderInfo("anthropic-claude-sonnet-4-6"), {
provider: "anthropic",
model: "claude-sonnet-4-6",
});
assert.deepEqual(inferProviderInfo("raycast-ray1"), { provider: "raycast", model: "ray1" });
});
it("buildRaycastChatBody includes thread_id and provider split", () => {
const body = JSON.parse(
buildRaycastChatBody("openai-gpt-5-mini", [{ role: "user", content: "ping" }], 0.7)
);
assert.equal(body.model, "gpt-5-mini");
assert.equal(body.provider, "openai");
assert.equal(body.temperature, 0.7);
assert.equal(typeof body.thread_id, "string");
assert.equal(body.messages[0].author, "user");
});
});

View File

@@ -0,0 +1,18 @@
/**
* @file raycast-local-extract.test.ts
* @description Tests for Raycast local credential extraction (mocked where needed).
*
* @changes
* - [2026-07-27] [Composer] - Raycast local extract unit tests
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isRaycastLocalExtractAvailable } from "../../src/lib/oauth/services/raycastLocal.ts";
describe("raycast local extract", () => {
it("reports availability on darwin when Raycast paths exist", () => {
const result = isRaycastLocalExtractAvailable();
assert.equal(typeof result, "boolean");
});
});