mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
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:
11
.env.example
11
.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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
open-sse/config/providers/registry/raycast/index.ts
Normal file
33
open-sse/config/providers/registry/raycast/index.ts
Normal 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" },
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
224
open-sse/executors/raycast.ts
Normal file
224
open-sse/executors/raycast.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
266
open-sse/services/raycast.ts
Normal file
266
open-sse/services/raycast.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
100
scripts/raycast/extract-credentials.mjs
Normal file
100
scripts/raycast/extract-credentials.mjs
Normal 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));
|
||||
165
scripts/raycast/usage-benchmark.mjs
Normal file
165
scripts/raycast/usage-benchmark.mjs
Normal 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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
124
src/app/api/oauth/raycast/auto-import/route.ts
Normal file
124
src/app/api/oauth/raycast/auto-import/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
143
src/app/api/oauth/raycast/import/route.ts
Normal file
143
src/app/api/oauth/raycast/import/route.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
37
src/lib/oauth/providers/raycast.ts
Normal file
37
src/lib/oauth/providers/raycast.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
65
src/lib/oauth/services/raycast.ts
Normal file
65
src/lib/oauth/services/raycast.ts
Normal 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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
198
src/lib/oauth/services/raycastLocal.ts
Normal file
198
src/lib/oauth/services/raycastLocal.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
213
src/shared/components/RaycastAuthModal.tsx
Normal file
213
src/shared/components/RaycastAuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
75
tests/unit/raycast-auth.test.ts
Normal file
75
tests/unit/raycast-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
18
tests/unit/raycast-local-extract.test.ts
Normal file
18
tests/unit/raycast-local-extract.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user