mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
Merge remote-tracking branch 'origin/release/v3.8.47' into tmp/implement-prs-6697-b
This commit is contained in:
@@ -15,6 +15,7 @@ import { searchGitHubSkills } from "@/lib/skills/githubCollector";
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
import { validateBody } from "@/shared/validation/helpers";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
const installSkillSchema = z.object({
|
||||
repoName: z.string().min(1),
|
||||
@@ -25,6 +26,9 @@ const installSkillSchema = z.object({
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const minStars = parseInt(searchParams.get("minStars") ?? "1", 10);
|
||||
@@ -64,6 +68,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const parsed = validateBody(installSkillSchema, await request.json());
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityH
|
||||
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
|
||||
import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts";
|
||||
import { normalizeOpenAiLikeModelsResponse } from "./normalizers";
|
||||
import { extractKimiJwt } from "@/lib/providers/webCookieAuth";
|
||||
|
||||
export type ProviderModelsConfigEntry = {
|
||||
url: string;
|
||||
@@ -12,6 +13,7 @@ export type ProviderModelsConfigEntry = {
|
||||
authPrefix?: string;
|
||||
authQuery?: string;
|
||||
body?: unknown;
|
||||
buildHeaders?: (token: string) => Record<string, string>;
|
||||
parseResponse: (data: any) => any;
|
||||
};
|
||||
|
||||
@@ -60,10 +62,10 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
},
|
||||
// #3931: qwen-web (cookie provider) was missing here, so its discovery page
|
||||
// showed nothing (the OAuth fallback above only fires for provider==="qwen").
|
||||
// `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent);
|
||||
// `chat.qwen.ai/api/v2/models/` is public (no auth header configured/sent);
|
||||
// shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback.
|
||||
"qwen-web": {
|
||||
url: "https://chat.qwen.ai/api/v2/models",
|
||||
url: "https://chat.qwen.ai/api/v2/models/",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
parseResponse: (data) => {
|
||||
@@ -78,18 +80,34 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
},
|
||||
},
|
||||
// #5858 follow-up: kimi-web (cookie provider) on the international domain.
|
||||
// `GetAvailableModels` returns the model list as a plain JSON envelope
|
||||
// (no Connect framing on either request or response — only the chat
|
||||
// completion endpoint uses the 5-byte envelope). Auth: Bearer JWT extracted
|
||||
// from the `kimi-auth` cookie the user pasted. Agent variants
|
||||
// `GetAvailableModels` returns the model list as a plain JSON envelope.
|
||||
// Auth mirrors the web app: Bearer JWT plus `Cookie: kimi-auth=<JWT>`.
|
||||
// Agent variants
|
||||
// (`k2d6-agent*`) need a different scenario + agent fields this executor
|
||||
// doesn't shape, so they're filtered out.
|
||||
"kimi-web": {
|
||||
url: "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels",
|
||||
method: "GET",
|
||||
headers: { accept: "application/json, text/plain, */*", "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
method: "POST",
|
||||
headers: { accept: "*/*", "Content-Type": "application/json" },
|
||||
body: {},
|
||||
buildHeaders: (token) => {
|
||||
const jwt = extractKimiJwt(token);
|
||||
return {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
"connect-protocol-version": "1",
|
||||
Origin: "https://www.kimi.com",
|
||||
Referer: "https://www.kimi.com/",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
...(jwt
|
||||
? {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Cookie: `kimi-auth=${jwt}`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
parseResponse: (data) => {
|
||||
const list = (data?.availableModels || []) as Array<{
|
||||
key?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import type { ProviderModelsConfigEntry } from "./discovery/providerModelsConfig";
|
||||
|
||||
/**
|
||||
* Derive a models-discovery config from the provider's registry `modelsUrl`
|
||||
@@ -8,18 +9,9 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts
|
||||
* OpenAI-compatible `/v1/models` endpoint, or `undefined` when the
|
||||
* registry entry has no `modelsUrl`.
|
||||
*/
|
||||
export function deriveConfigFromRegistryModelsUrl(provider: string):
|
||||
| {
|
||||
url: string;
|
||||
method: "GET";
|
||||
headers: Record<string, string>;
|
||||
authHeader?: string;
|
||||
authPrefix?: string;
|
||||
authQuery?: string;
|
||||
body?: unknown;
|
||||
parseResponse: (data: any) => any;
|
||||
}
|
||||
| undefined {
|
||||
export function deriveConfigFromRegistryModelsUrl(
|
||||
provider: string
|
||||
): ProviderModelsConfigEntry | undefined {
|
||||
const entry = getRegistryEntry(provider);
|
||||
if (typeof entry?.modelsUrl === "string" && entry.modelsUrl.length > 0) {
|
||||
return {
|
||||
|
||||
@@ -1806,8 +1806,8 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = { ...config.headers };
|
||||
if (config.authHeader && !config.authQuery) {
|
||||
const headers = config.buildHeaders ? config.buildHeaders(token) : { ...config.headers };
|
||||
if (!config.buildHeaders && config.authHeader && !config.authQuery) {
|
||||
headers[config.authHeader] = (config.authPrefix || "") + token;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
// guard work unchanged. Only the deployment surface differs (Cloudflare Workers
|
||||
// API instead of Vercel /v13/deployments).
|
||||
|
||||
const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";
|
||||
const CLOUDFLARE_API_BASE =
|
||||
process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
@@ -52,7 +53,7 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
// 1. PUT the Worker script — Cloudflare requires multipart/form-data with
|
||||
// main_module + a metadata blob describing the upload.
|
||||
// body_part + a metadata blob describing the upload.
|
||||
//
|
||||
// Built as a raw Buffer with an explicit boundary rather than a native
|
||||
// `FormData` (#6416): in production `globalThis.fetch` is patched with
|
||||
@@ -63,14 +64,19 @@ export async function POST(request: Request) {
|
||||
// with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare
|
||||
// rejects with "Content-Type must be one of: application/javascript,
|
||||
// text/javascript, multipart/form-data" — the same class of bug fixed
|
||||
// for image edits in #3273. ES-module semantics come from `main_module`
|
||||
// in the metadata part below, not the script part's Content-Type
|
||||
// (Cloudflare rejects "application/javascript+module" outright, #5128).
|
||||
// for image edits in #3273.
|
||||
//
|
||||
// The script part itself must stay `application/javascript` (Cloudflare
|
||||
// rejects `application/javascript+module`, #5128), but with that MIME the
|
||||
// uploaded body is parsed as a Service Worker, not an ES module. So the
|
||||
// metadata must point at the script via `body_part`, not `main_module` —
|
||||
// otherwise Cloudflare rejects the body with `Unexpected token 'export'`
|
||||
// when it sees module syntax in a non-module upload (#6496 / #6416).
|
||||
const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`;
|
||||
const { headers: uploadHeaders, body: uploadBody } = buildCloudflareWorkerUploadRequest(
|
||||
workerScript,
|
||||
{
|
||||
main_module: "index.js",
|
||||
body_part: "index.js",
|
||||
compatibility_date: "2026-03-20",
|
||||
observability: { enabled: true },
|
||||
}
|
||||
|
||||
164
src/app/api/skills/collect/detect/route.ts
Normal file
164
src/app/api/skills/collect/detect/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* GET /api/skills/collect/detect
|
||||
*
|
||||
* Detect installed CLI coding tools + search GitHub for matching agent skills.
|
||||
* Uses OmniRoute's built-in CLI_TOOL_IDS detection (no Skill Collector bridge needed).
|
||||
*
|
||||
* Returns: {
|
||||
* tools: { toolId, installed, runnable, command, reason }[],
|
||||
* matchedSkills: { toolId, skillName, repo, score, stars }[],
|
||||
* totalSkills: number
|
||||
* }
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime";
|
||||
import { searchGitHubSkills, type GitHubSkillRepo } from "@/lib/skills/githubCollector";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CODING_TOOL_KEYWORDS: Record<string, string[]> = {
|
||||
claude: ["claude", "anthropic", "claude-code"],
|
||||
codex: ["codex", "openai", "gpt"],
|
||||
cursor: ["cursor", "cursor-ai"],
|
||||
copilot: ["copilot", "github-copilot"],
|
||||
opencode: ["opencode"],
|
||||
cline: ["cline"],
|
||||
kilocode: ["kilo", "kilocode"],
|
||||
hermes: ["hermes", "nous-research"],
|
||||
"hermes-agent": ["hermes", "hermes-agent"],
|
||||
openclaw: ["openclaw"],
|
||||
droid: ["droid", "factory-ai"],
|
||||
continue: ["continue"],
|
||||
antigravity: ["antigravity"],
|
||||
qwen: ["qwen", "alibaba"],
|
||||
windsurf: ["windsurf"],
|
||||
devin: ["devin", "cognition"],
|
||||
};
|
||||
|
||||
interface DetectedTool {
|
||||
installed: boolean;
|
||||
runnable: boolean;
|
||||
command: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface MatchedSkill {
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
skillName: string;
|
||||
repo: string;
|
||||
htmlUrl: string;
|
||||
score: number;
|
||||
stars: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Probes every catalog CLI tool in parallel via getCliRuntimeStatus(). */
|
||||
async function detectInstalledTools(): Promise<Record<string, DetectedTool>> {
|
||||
const toolIds = CLI_TOOL_IDS as readonly string[];
|
||||
const detectedTools: Record<string, DetectedTool> = {};
|
||||
|
||||
await Promise.allSettled(
|
||||
toolIds.map(async (toolId) => {
|
||||
try {
|
||||
const result = await getCliRuntimeStatus(toolId);
|
||||
detectedTools[toolId] = {
|
||||
installed: result.installed,
|
||||
runnable: result.runnable,
|
||||
command: result.command ?? null,
|
||||
reason: result.reason ?? null,
|
||||
};
|
||||
} catch {
|
||||
detectedTools[toolId] = {
|
||||
installed: false,
|
||||
runnable: false,
|
||||
command: null,
|
||||
reason: "check_failed",
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return detectedTools;
|
||||
}
|
||||
|
||||
function toMatchedSkill(toolId: string, repo: GitHubSkillRepo): MatchedSkill {
|
||||
return {
|
||||
toolId,
|
||||
toolName: toolId,
|
||||
skillName: repo.fullName?.split("/").pop() ?? "unknown",
|
||||
repo: repo.fullName ?? "",
|
||||
htmlUrl: repo.htmlUrl ?? "",
|
||||
score: repo.score ?? 0,
|
||||
stars: repo.stars ?? 0,
|
||||
description: (repo.description ?? "").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
/** For each repo, matches it to the first installed tool whose keywords hit. */
|
||||
function matchSkillsToTools(repos: GitHubSkillRepo[], installedTools: string[]): MatchedSkill[] {
|
||||
const matchedSkills: MatchedSkill[] = [];
|
||||
|
||||
for (const repo of repos) {
|
||||
const name = (repo.fullName ?? "").toLowerCase();
|
||||
const desc = (repo.description ?? "").toLowerCase();
|
||||
|
||||
const matchedTool = installedTools.find((toolId) => {
|
||||
const keywords = CODING_TOOL_KEYWORDS[toolId] ?? [toolId];
|
||||
return keywords.some((kw) => name.includes(kw) || desc.includes(kw));
|
||||
});
|
||||
if (matchedTool) matchedSkills.push(toMatchedSkill(matchedTool, repo));
|
||||
}
|
||||
|
||||
return matchedSkills;
|
||||
}
|
||||
|
||||
/** Fills in tools with zero keyword matches by distributing top-scored skills evenly. */
|
||||
function distributeUnmatchedSkills(
|
||||
repos: GitHubSkillRepo[],
|
||||
matchedSkills: MatchedSkill[],
|
||||
installedTools: string[]
|
||||
): MatchedSkill[] {
|
||||
const toolsWithoutMatches = installedTools.filter(
|
||||
(id) => !matchedSkills.some((m) => m.toolId === id)
|
||||
);
|
||||
if (toolsWithoutMatches.length === 0 || repos.length === 0) return matchedSkills;
|
||||
|
||||
const topSkills = repos.filter((r) => (r.score ?? 0) >= 0.4).slice(0, Math.min(10, repos.length));
|
||||
const distributed = topSkills.map((r, i) =>
|
||||
toMatchedSkill(toolsWithoutMatches[i % toolsWithoutMatches.length], r)
|
||||
);
|
||||
|
||||
return [...matchedSkills, ...distributed];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const detectedTools = await detectInstalledTools();
|
||||
const installedTools = Object.entries(detectedTools)
|
||||
.filter(([, v]) => v.installed)
|
||||
.map(([id]) => id);
|
||||
|
||||
const { repos, errors } = await searchGitHubSkills({ minStars: 1, maxResults: 100 });
|
||||
|
||||
const directMatches = matchSkillsToTools(repos, installedTools);
|
||||
const matchedSkills = distributeUnmatchedSkills(repos, directMatches, installedTools);
|
||||
|
||||
return NextResponse.json({
|
||||
tools: detectedTools,
|
||||
installedToolIds: installedTools,
|
||||
matchedSkills: matchedSkills.slice(0, 50),
|
||||
totalSkills: repos.length,
|
||||
totalMatched: matchedSkills.length,
|
||||
searchErrors: (errors?.length ?? 0) > 0 ? errors : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(buildErrorBody(500, msg), { status: 500 });
|
||||
}
|
||||
}
|
||||
130
src/app/api/skills/collect/install/route.ts
Normal file
130
src/app/api/skills/collect/install/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* POST /api/skills/collect/install
|
||||
*
|
||||
* Install a discovered GitHub skill to detected CLI tools.
|
||||
* Uses OmniRoute's skill registry + CLI tool paths (no Skill Collector bridge).
|
||||
*
|
||||
* Body: {
|
||||
* repoName: string, // GitHub full name (e.g. "user/repo")
|
||||
* targets: string[], // Tool IDs to install to (e.g. ["codex", "claude"])
|
||||
* description?: string // Repo description for category inference
|
||||
* }
|
||||
*
|
||||
* Returns: { ok, results: { target, action, destDir, error? }[] }
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
const installSchema = z.object({
|
||||
repoName: z.string().min(1, "repoName is required"),
|
||||
targets: z
|
||||
.array(z.string().min(1, "target toolId must be non-empty"))
|
||||
.min(1, "at least one target required")
|
||||
.max(10, "max 10 targets"),
|
||||
description: z.string().default(""),
|
||||
});
|
||||
|
||||
const CODING_TOOL_PATHS: Record<string, string> = {
|
||||
claude: "~/.claude/skills/{category}",
|
||||
codex: "~/.codex/skills/{category}",
|
||||
hermes: "~/AppData/Local/hermes/skills/{category}",
|
||||
opencode: "~/.opencode/skills/{category}",
|
||||
gemini: "~/.gemini/skills/{category}",
|
||||
cursor: "~/.cursor/skills/{category}",
|
||||
copilot: "~/.copilot/skills/{category}",
|
||||
cline: "~/.cline/skills/{category}",
|
||||
windsurf: "~/.windsurf/skills/{category}",
|
||||
devin: "~/.devin/skills/{category}",
|
||||
antigravity: "~/.antigravity/skills/{category}",
|
||||
qwen: "~/.qwen/skills/{category}",
|
||||
kilocode: "~/.kilocode/skills/{category}",
|
||||
openclaw: "~/.openclaw/skills/{category}",
|
||||
droid: "~/.droid/skills/{category}",
|
||||
continue: "~/.continue/skills/{category}",
|
||||
};
|
||||
|
||||
function inferCategory(skillName: string, description: string): string {
|
||||
const text = `${skillName} ${description}`.toLowerCase();
|
||||
const mapping: Record<string, string[]> = {
|
||||
security: ["security", "pentest", "exploit", "malware", "forensics", "vulnerability"],
|
||||
"data-science": ["data", "analytics", "pandas", "ml", "model", "train"],
|
||||
devops: ["deploy", "docker", "k8s", "terraform", "ci/cd", "pipeline"],
|
||||
creative: ["design", "image", "video", "art", "music"],
|
||||
productivity: ["email", "doc", "slide", "report", "calendar"],
|
||||
research: ["paper", "arxiv", "academic", "literature"],
|
||||
"software-development": ["code", "refactor", "test", "lint", "review", "debug"],
|
||||
media: ["youtube", "transcript", "gif", "video", "audio"],
|
||||
};
|
||||
for (const [cat, keywords] of Object.entries(mapping)) {
|
||||
if (keywords.some((k) => text.includes(k))) return cat;
|
||||
}
|
||||
return "imported-github";
|
||||
}
|
||||
|
||||
function expandHome(dir: string): string {
|
||||
// Home dir resolution: Windows (USERPROFILE) → Unix fallback (HOME)
|
||||
const home =
|
||||
typeof process !== "undefined" ? process.env.USERPROFILE || process.env.HOME || "" : "";
|
||||
return dir.replace(/^~/, home);
|
||||
}
|
||||
|
||||
function resolveDestDir(target: string, skillName: string, description: string): string {
|
||||
const template = CODING_TOOL_PATHS[target];
|
||||
if (!template) {
|
||||
throw new Error(
|
||||
`Unknown target tool: "${target}". Supported: ${Object.keys(CODING_TOOL_PATHS).join(", ")}`
|
||||
);
|
||||
}
|
||||
const category = inferCategory(skillName, description);
|
||||
const resolved = template.replace("{category}", category).replace("{name}", skillName);
|
||||
return expandHome(`${resolved}/${skillName}`);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const rawBody = await request.json();
|
||||
const validation = validateBody(installSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json(buildErrorBody(400, validation.error.message), { status: 400 });
|
||||
}
|
||||
|
||||
const { repoName, targets, description } = validation.data;
|
||||
const skillName = repoName.split("/").pop() || repoName;
|
||||
|
||||
const results = targets.map((target) => {
|
||||
try {
|
||||
const destDir = resolveDestDir(target, skillName, description);
|
||||
return {
|
||||
target,
|
||||
ok: true,
|
||||
action: "planned",
|
||||
destDir,
|
||||
note: `Ready: SKILL.md from ${repoName} can be synced to ${destDir}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
target,
|
||||
ok: false,
|
||||
action: "error",
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: results.every((r) => r.ok),
|
||||
repoName,
|
||||
skillName,
|
||||
results,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return NextResponse.json(buildErrorBody(500, msg), { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) {
|
||||
}
|
||||
|
||||
// qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible
|
||||
// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models` (via
|
||||
// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models/` (via
|
||||
// addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the
|
||||
// outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758).
|
||||
//
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
* - Strips Host + relay control headers before forwarding upstream.
|
||||
*
|
||||
* The string template is fed to Cloudflare's PUT /accounts/{id}/workers/scripts/{name}
|
||||
* API with main_module=index.js (ESM Workers Modules format).
|
||||
* API as a Service Worker (no ES module export). Cloudflare's multipart upload
|
||||
* API rejects `application/javascript+module` (#5128C) and treats a plain
|
||||
* `application/javascript` script part as a Service Worker regardless of any
|
||||
* `main_module` metadata — `main_module` requires the script to be an actual
|
||||
* ES module (top-level `export`), which Service Worker syntax is not. The
|
||||
* `body_part` metadata field is the correct way to point at a non-ESM script.
|
||||
*
|
||||
* The OmniRoute variant intentionally diverges from the upstream PR:
|
||||
* - The upstream worker had NO auth check, leaving the deployed workers.dev URL
|
||||
@@ -105,51 +110,53 @@ function isPrivateHostname(h) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const auth = request.headers.get("x-relay-auth");
|
||||
if (auth !== "${relayAuth}") {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const target = request.headers.get("x-relay-target");
|
||||
if (!target) {
|
||||
return new Response("missing x-relay-target", { status: 400 });
|
||||
}
|
||||
let targetUrl;
|
||||
try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); }
|
||||
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
||||
return new Response("forbidden x-relay-target protocol", { status: 403 });
|
||||
}
|
||||
if (targetUrl.username || targetUrl.password) {
|
||||
return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 });
|
||||
}
|
||||
if (isPrivateHostname(targetUrl.hostname)) {
|
||||
return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 });
|
||||
}
|
||||
const relayPath = request.headers.get("x-relay-path") || "/";
|
||||
const headers = new Headers(request.headers);
|
||||
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h));
|
||||
const init = {
|
||||
method: request.method,
|
||||
headers,
|
||||
};
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
init.body = request.body;
|
||||
init.duplex = "half";
|
||||
}
|
||||
try {
|
||||
const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init);
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: upstream.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), {
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
async function handleRelay(request) {
|
||||
const auth = request.headers.get("x-relay-auth");
|
||||
if (auth !== "${relayAuth}") {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const target = request.headers.get("x-relay-target");
|
||||
if (!target) {
|
||||
return new Response("missing x-relay-target", { status: 400 });
|
||||
}
|
||||
let targetUrl;
|
||||
try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); }
|
||||
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
||||
return new Response("forbidden x-relay-target protocol", { status: 403 });
|
||||
}
|
||||
if (targetUrl.username || targetUrl.password) {
|
||||
return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 });
|
||||
}
|
||||
if (isPrivateHostname(targetUrl.hostname)) {
|
||||
return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 });
|
||||
}
|
||||
const relayPath = request.headers.get("x-relay-path") || "/";
|
||||
const headers = new Headers(request.headers);
|
||||
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h));
|
||||
const init = {
|
||||
method: request.method,
|
||||
headers,
|
||||
};
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
init.body = request.body;
|
||||
init.duplex = "half";
|
||||
}
|
||||
try {
|
||||
const upstream = await fetch(target.replace(/\\\\/$/, "") + relayPath, init);
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: upstream.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), {
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("fetch", (event) => {
|
||||
event.respondWith(handleRelay(event.request));
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface ScanFinding {
|
||||
export interface SkillInstallResult {
|
||||
target: string;
|
||||
ok: boolean;
|
||||
action: "installed" | "already_up_to_date" | "skipped" | "error";
|
||||
action: "installed" | "planned" | "already_up_to_date" | "skipped" | "error";
|
||||
error?: string;
|
||||
destDir?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17)
|
||||
"/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17)
|
||||
"/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable.
|
||||
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review).
|
||||
"/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md.
|
||||
];
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
|
||||
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
|
||||
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review)
|
||||
"/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17)
|
||||
"/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user