mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 04:03:02 +03:00
Merge remote-tracking branch 'origin/release/v3.8.47' into tmp/implement-prs-6697-b
This commit is contained in:
@@ -55,9 +55,16 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
# `npm rebuild <pkg>` re-runs the package's own install script, so under npm 11 +
|
||||
# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist
|
||||
# machinery correctly re-enabling that one package's script. Some self-hosted build
|
||||
# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding
|
||||
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
|
||||
# directory bypasses npm's script-running layer entirely and is deterministic
|
||||
# regardless of npm version or ignore-scripts allowlist behavior.
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& (cd node_modules/better-sqlite3 && npx --yes node-gyp rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
* session; the upstream returns the same response either way.
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
makeExecutorErrorResult as makeErrorResult,
|
||||
sanitizeErrorMessage,
|
||||
} from "../utils/error.ts";
|
||||
import { extractKimiJwt } from "@/lib/providers/webCookieAuth";
|
||||
|
||||
export { extractKimiJwt };
|
||||
@@ -93,7 +96,10 @@ const MAX_FRAME_LEN = 8 * 1024 * 1024;
|
||||
* (caller must treat this as a stream-fatal protocol error)
|
||||
* - `consumed: N` + the parsed frame otherwise
|
||||
*/
|
||||
export function decodeConnectFrame(buf: Uint8Array, byteOffset: number): { consumed: number; frame: ConnectFrame | null } {
|
||||
export function decodeConnectFrame(
|
||||
buf: Uint8Array,
|
||||
byteOffset: number
|
||||
): { consumed: number; frame: ConnectFrame | null } {
|
||||
if (byteOffset + 5 > buf.length) return { consumed: 0, frame: null };
|
||||
const flags = buf[byteOffset];
|
||||
const len =
|
||||
@@ -130,7 +136,9 @@ type DeltaKind = "text" | "think" | null;
|
||||
* Anything else (heartbeats, chat/message metadata, stage transitions) is
|
||||
* suppressed; we only surface text to the client.
|
||||
*/
|
||||
export function extractDelta(msg: Record<string, unknown> | null): { kind: DeltaKind; text: string } | null {
|
||||
export function extractDelta(
|
||||
msg: Record<string, unknown> | null
|
||||
): { kind: DeltaKind; text: string } | null {
|
||||
if (!msg) return null;
|
||||
const op = String(msg.op ?? "");
|
||||
const mask = String(msg.mask ?? "");
|
||||
@@ -167,7 +175,11 @@ export function isEndOfStream(msg: Record<string, unknown> | null): boolean {
|
||||
if (!msg) return false;
|
||||
// Assistant message flipped to COMPLETED.
|
||||
const message = (msg.message ?? null) as Record<string, unknown> | null;
|
||||
if (message && String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && String(message.role ?? "") === "assistant") {
|
||||
if (
|
||||
message &&
|
||||
String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" &&
|
||||
String(message.role ?? "") === "assistant"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -252,7 +264,7 @@ export class KimiWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "kimi-default";
|
||||
const modelId = (bodyObj.model as string) || "k2d6";
|
||||
// Resolve scenario + default thinking flag from the model id (catalog truth),
|
||||
// then honour an explicit `reasoning_effort: "none"` override from the caller.
|
||||
const modelConfig = resolveModelConfig(modelId);
|
||||
@@ -285,7 +297,12 @@ export class KimiWebExecutor extends BaseExecutor {
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Kimi error: ${sanitizeErrorMessage(errText)}`, body, CHAT_URL);
|
||||
return makeErrorResult(
|
||||
upstream.status,
|
||||
`Kimi error: ${sanitizeErrorMessage(errText)}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -77,11 +77,13 @@ async function handleInstall(args: z.infer<typeof GitHubSkillsInstallSchema>) {
|
||||
try {
|
||||
const dest = resolveInstallPath(target, skillName, args.description);
|
||||
// In a real implementation, this would clone the repo and copy files.
|
||||
// For now, we return the planned install path as a dry-run result.
|
||||
// For now, we return the planned install path as a dry-run result — matches
|
||||
// the honest `action: "planned"` the REST route (/api/github-skills POST)
|
||||
// reports for the same operation.
|
||||
results.push({
|
||||
target,
|
||||
ok: true,
|
||||
action: "installed",
|
||||
action: "planned",
|
||||
destDir: dest,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
|
||||
"src/app/api/mcp",
|
||||
"src/app/api/cli-tools/runtime",
|
||||
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
|
||||
"src/app/api/skills/collect", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus() (Hard Rules #15 + #17, PR #6294 review)
|
||||
];
|
||||
|
||||
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified
|
||||
|
||||
102
skills/README.md
102
skills/README.md
@@ -5,10 +5,10 @@ consume OmniRoute via OpenAI-compatible REST in one fetch.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Type | Skill | Manifest |
|
||||
| ---- | ----- | -------- |
|
||||
| Type | Skill | Manifest |
|
||||
| ---- | ------------------------------------------- | ---------------------------------------- |
|
||||
| API | Authentication (start here for REST access) | [omni-auth/SKILL.md](omni-auth/SKILL.md) |
|
||||
| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) |
|
||||
| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) |
|
||||
|
||||
## How agents discover capabilities
|
||||
|
||||
@@ -24,57 +24,58 @@ See [`docs/frameworks/AGENT-SKILLS.md`](../docs/frameworks/AGENT-SKILLS.md) for
|
||||
Each manifest URL follows the pattern:
|
||||
`https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/<id>/SKILL.md`
|
||||
|
||||
| ID | Name | Description |
|
||||
| -- | ---- | ----------- |
|
||||
| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. |
|
||||
| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). |
|
||||
| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. |
|
||||
| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. |
|
||||
| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. |
|
||||
| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. |
|
||||
| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. |
|
||||
| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. |
|
||||
| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. |
|
||||
| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. |
|
||||
| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. |
|
||||
| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. |
|
||||
| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. |
|
||||
| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. |
|
||||
| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. |
|
||||
| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. |
|
||||
| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. |
|
||||
| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. |
|
||||
| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. |
|
||||
| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. |
|
||||
| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. |
|
||||
| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. |
|
||||
| ID | Name | Description |
|
||||
| ---------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. |
|
||||
| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). |
|
||||
| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. |
|
||||
| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. |
|
||||
| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. |
|
||||
| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. |
|
||||
| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. |
|
||||
| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. |
|
||||
| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. |
|
||||
| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. |
|
||||
| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. |
|
||||
| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. |
|
||||
| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. |
|
||||
| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. |
|
||||
| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. |
|
||||
| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. |
|
||||
| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. |
|
||||
| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. |
|
||||
| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. |
|
||||
| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. |
|
||||
| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. |
|
||||
| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. |
|
||||
|
||||
---
|
||||
|
||||
## CLI Skills (20)
|
||||
## CLI Skills (21)
|
||||
|
||||
| ID | Name | Description |
|
||||
| -- | ---- | ----------- |
|
||||
| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. |
|
||||
| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. |
|
||||
| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. |
|
||||
| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. |
|
||||
| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. |
|
||||
| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. |
|
||||
| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. |
|
||||
| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. |
|
||||
| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. |
|
||||
| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. |
|
||||
| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. |
|
||||
| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. |
|
||||
| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. |
|
||||
| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. |
|
||||
| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. |
|
||||
| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. |
|
||||
| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. |
|
||||
| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. |
|
||||
| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. |
|
||||
| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. |
|
||||
| ID | Name | Description |
|
||||
| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. |
|
||||
| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. |
|
||||
| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. |
|
||||
| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. |
|
||||
| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. |
|
||||
| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. |
|
||||
| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. |
|
||||
| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. |
|
||||
| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. |
|
||||
| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. |
|
||||
| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. |
|
||||
| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. |
|
||||
| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. |
|
||||
| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. |
|
||||
| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. |
|
||||
| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. |
|
||||
| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. |
|
||||
| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. |
|
||||
| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. |
|
||||
| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. |
|
||||
| `cli-skill-collector` | CLI: Skill Collector | Detect installed coding CLI tools, search GitHub for matching agent skills, and plan their installation into the detected tools' skill directories. |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +88,7 @@ https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/<id>/SKILL.
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- API entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/omni-auth/SKILL.md`
|
||||
- CLI entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/cli-serve/SKILL.md`
|
||||
|
||||
|
||||
152
skills/cli-skill-collector/SKILL.md
Normal file
152
skills/cli-skill-collector/SKILL.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: cli-skill-collector
|
||||
description: "Agent workflow: detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.), search GitHub for matching agent skills, and install them to the detected tools. Replaces the standalone Skill Collector Python app."
|
||||
---
|
||||
|
||||
# /cli-skill-collector — Agent Skill Collector
|
||||
|
||||
Discover and install agent skills for your coding CLI tools — all through OmniRoute's built-in APIs.
|
||||
|
||||
This skill teaches you how to:
|
||||
|
||||
1. **Detect** which coding CLIs are installed on this machine
|
||||
2. **Search** GitHub for relevant agent skills (SKILL.md repos)
|
||||
3. **Install** discovered skills to the detected coding tools
|
||||
|
||||
No separate Skill Collector app needed — OmniRoute's own CLI detection + GitHub search handles everything.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Detect installed coding tools
|
||||
|
||||
Query OmniRoute's CLI tool detection to find which coding agents are installed:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect
|
||||
```
|
||||
|
||||
This returns:
|
||||
|
||||
- Every CLI tool in OmniRoute's catalog (`CLI_TOOL_IDS`: claude, codex, cursor, copilot, opencode, cline, kilocode, hermes, hermes-agent, openclaw, droid, continue, qwen, windsurf, devin, antigravity, etc.)
|
||||
- Whether each is **installed** and **runnable**
|
||||
- GitHub skills **matched** to your installed tools (scored by relevance)
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"codex": { "installed": true, "runnable": true, "command": "codex" },
|
||||
"claude": { "installed": true, "runnable": true, "command": "claude" },
|
||||
"cursor": { "installed": false, "runnable": false }
|
||||
},
|
||||
"installedToolIds": ["codex", "claude"],
|
||||
"matchedSkills": [
|
||||
{ "toolId": "codex", "repo": "user/skill-codex-xxx", "score": 0.85, "stars": 120 },
|
||||
{ "toolId": "claude", "repo": "user/claude-agent-rules", "score": 0.92, "stars": 340 }
|
||||
],
|
||||
"totalSkills": 85
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Review matched skills
|
||||
|
||||
For each installed tool, the API returns relevant GitHub repos that contain SKILL.md or agent configuration files. Use the `score` field to prioritize:
|
||||
|
||||
| Score | Recommendation |
|
||||
| ----- | ----------------------------------------------- |
|
||||
| 0.80+ | Excellent — well-maintained, high stars, active |
|
||||
| 0.60+ | Good — relevant with decent quality |
|
||||
| 0.40+ | Fair — may need review |
|
||||
| <0.40 | Low quality — skip |
|
||||
|
||||
You can also browse manually:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" \
|
||||
"http://localhost:20128/api/github-skills?minStars=3&maxResults=50"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Install skills to detected tools
|
||||
|
||||
Install a chosen skill to one or more detected tools:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:20128/api/skills/collect/install \
|
||||
-H "Authorization: Bearer $OMNIROUTE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"repoName": "user/skill-codex-xxx",
|
||||
"targets": ["codex", "claude"],
|
||||
"description": "Agent skill for coding workflows"
|
||||
}'
|
||||
```
|
||||
|
||||
This plans the installation path for each target tool:
|
||||
|
||||
- **claude** → `~/.claude/skills/{category}/`
|
||||
- **codex** → `~/.codex/skills/{category}/`
|
||||
- **hermes** → `~/AppData/Local/hermes/skills/{category}/`
|
||||
- **opencode** → `~/.opencode/skills/{category}/`
|
||||
- **gemini** → `~/.gemini/skills/{category}/`
|
||||
|
||||
The actual file sync (cloning from GitHub and copying SKILL.md) is done by the agent using standard `curl` + `cp` commands.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Verify installation
|
||||
|
||||
After installing, verify the skill is in place:
|
||||
|
||||
```bash
|
||||
# For Codex
|
||||
ls -la ~/.codex/skills/imported-github/*/SKILL.md
|
||||
|
||||
# For Claude Code
|
||||
ls -la ~/.claude/skills/imported-github/*/SKILL.md
|
||||
|
||||
# For Hermes (Windows)
|
||||
ls -la ~/AppData/Local/hermes/skills/imported-github/*/SKILL.md
|
||||
```
|
||||
|
||||
Also re-check detection:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start (full workflow)
|
||||
|
||||
```bash
|
||||
AUTH_HEADER="Authorization: Bearer $OMNIROUTE_API_KEY"
|
||||
|
||||
# 1. Detect
|
||||
DETECT=$(curl -s -H "$AUTH_HEADER" http://localhost:20128/api/skills/collect/detect)
|
||||
|
||||
# 2. Pick top matched skill for first installed tool
|
||||
TOOL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['installedToolIds'][0] if d['installedToolIds'] else '')")
|
||||
SKILL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);ms=d.get('matchedSkills',[]);print(ms[0]['repo'] if ms else '')")
|
||||
|
||||
if [ -n "$TOOL" ] && [ -n "$SKILL" ]; then
|
||||
# 3. Install
|
||||
curl -s -X POST http://localhost:20128/api/skills/collect/install \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repoName\": \"$SKILL\", \"targets\": [\"$TOOL\"]}"
|
||||
echo "Installed $SKILL to $TOOL"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- OmniRoute must be running locally on port 20128 (default) — see `docs/frameworks/SKILLS.md` for custom-port setups.
|
||||
- The `/api/skills/collect/*` and `/api/github-skills` endpoints require **management-scoped authentication** the same way every other `/api/skills/*` route does: a dashboard session, the loopback CLI token, or an API key with the `manage` scope (`requireManagementAuth()`). Auth is only bypassed when the server has no login/API-key requirement configured at all.
|
||||
- This replaces the standalone Skill Collector Python app — all logic is now inside OmniRoute.
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"tests/unit/auth-terminal-status.test.ts",
|
||||
"tests/unit/authz/discovery-routes-local-only.test.ts",
|
||||
"tests/unit/authz/route-guard-local-prefix.test.ts",
|
||||
"tests/unit/authz/route-guard-skills-collect.test.ts",
|
||||
"tests/unit/authz/route-guard-version-get-exemption.test.ts",
|
||||
"tests/unit/authz/routeGuard.test.ts",
|
||||
"tests/unit/auto-combo-context-advertising.test.ts",
|
||||
|
||||
51
tests/unit/authz/route-guard-skills-collect.test.ts
Normal file
51
tests/unit/authz/route-guard-skills-collect.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../../scripts/check/check-route-guard-membership.ts";
|
||||
|
||||
// ─── PR #6294 review: /api/skills/collect/ is local-only ─────────────────
|
||||
//
|
||||
// GET /api/skills/collect/detect calls getCliRuntimeStatus() (which spawns a
|
||||
// child process) once per CLI_TOOL_IDS entry — a spawn-capable, previously
|
||||
// unauthenticated route reachable from any tunnel. Must be loopback-enforced
|
||||
// BEFORE any auth check (Hard Rules #15 + #17), the same as every other
|
||||
// spawn-capable prefix.
|
||||
|
||||
test("isLocalOnlyPath: /api/skills/collect/ prefix is local-only (Hard Rules #15/#17)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/skills/collect/install"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/skills/collect/"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => {
|
||||
// Only the spawn-capable collect/* subtree is loopback-locked. The rest of the
|
||||
// skills surface (registry install, marketplace, skillssh) already gates on
|
||||
// requireManagementAuth() and must remain reachable remotely.
|
||||
assert.equal(isLocalOnlyPath("/api/skills"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/skills/install"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/skills/collect/ is NOT bypassable (defence in depth)", () => {
|
||||
// Even if a DB row tried to whitelist /api/skills/collect/ via the manage-scope
|
||||
// bypass list, the runtime predicate must reject it because the prefix is in
|
||||
// SPAWN_CAPABLE_PREFIXES (src/shared/constants/spawnCapablePrefixes.ts).
|
||||
assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/skills/collect/detect"), false);
|
||||
});
|
||||
|
||||
test("SPAWN_CAPABLE_ROUTE_ROOTS includes src/app/api/skills/collect (route-guard-membership gate)", () => {
|
||||
// Regression guard for the "gate's scanned-roots list doesn't include this new
|
||||
// directory" gap found during PR #6294 review — check:route-guard-membership
|
||||
// must actually enumerate the new detect/install route.ts files, not silently
|
||||
// report "0 new gaps" because the directory was never in scope.
|
||||
assert.ok(
|
||||
SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/collect"),
|
||||
`Expected SPAWN_CAPABLE_ROUTE_ROOTS to include "src/app/api/skills/collect", got: ${JSON.stringify(
|
||||
SPAWN_CAPABLE_ROUTE_ROOTS
|
||||
)}`
|
||||
);
|
||||
});
|
||||
@@ -77,6 +77,7 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t
|
||||
"/api/tools/traffic-inspector/",
|
||||
"/api/plugins/",
|
||||
"/api/local/",
|
||||
"/api/skills/collect/",
|
||||
"/api/headroom/start",
|
||||
"/api/headroom/stop",
|
||||
]) {
|
||||
@@ -85,5 +86,5 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t
|
||||
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
|
||||
);
|
||||
}
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 8);
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 9);
|
||||
});
|
||||
|
||||
@@ -66,12 +66,12 @@ test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models", () => {
|
||||
test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models/", () => {
|
||||
const src = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
assert.match(
|
||||
src,
|
||||
/chat\.qwen\.ai\/api\/v2\/models/,
|
||||
"qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models"
|
||||
/chat\.qwen\.ai\/api\/v2\/models\//,
|
||||
"qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models/"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
79
tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts
Normal file
79
tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* #6700 — Dokploy (and some other self-hosted) Docker builds ended up with a
|
||||
* broken/mismatched better-sqlite3 native binding under npm 11. The `builder`
|
||||
* stage installed dependencies with `npm ci --ignore-scripts` (deliberate — it
|
||||
* closes the supply-chain surface where a transitive dep's install script runs
|
||||
* arbitrary code) and then re-enabled the native build for the one package that
|
||||
* needs it via `npm rebuild better-sqlite3`. `npm rebuild` re-runs the package's
|
||||
* own install script indirectly, which depends on npm's script-allowlist
|
||||
* machinery correctly re-enabling that single package's script — some
|
||||
* self-hosted build environments hit a broken build via that indirection.
|
||||
*
|
||||
* Fix: invoke `node-gyp rebuild` directly inside `node_modules/better-sqlite3`,
|
||||
* bypassing npm's script-running layer entirely, so the compile step is
|
||||
* deterministic regardless of npm version or ignore-scripts allowlist behavior.
|
||||
*
|
||||
* This guards the mechanism (the direct node-gyp invocation replaces the
|
||||
* `npm rebuild` indirection, and a smoke-load still follows it); the end-to-end
|
||||
* "the Dokploy build now produces a working binding" proof is a successful
|
||||
* `docker build` in that environment (tracked as a live-validation follow-up —
|
||||
* this sandbox has no accessible Docker daemon to run the real build).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8");
|
||||
const lines = dockerfile.split("\n");
|
||||
|
||||
/** Line indices that bound the `builder` stage (from its FROM to the next FROM). */
|
||||
function builderStageRange(): { start: number; end: number } {
|
||||
const start = lines.findIndex((l) => /^FROM\s+\S+\s+AS\s+builder\b/i.test(l.trim()));
|
||||
assert.ok(start >= 0, "Dockerfile must declare a `builder` stage");
|
||||
const after = lines.slice(start + 1).findIndex((l) => /^FROM\s+/i.test(l.trim()));
|
||||
const end = after === -1 ? lines.length : start + 1 + after;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
test("#6700 builder stage compiles better-sqlite3 via a direct node-gyp rebuild, not `npm rebuild`", () => {
|
||||
const { start, end } = builderStageRange();
|
||||
const stage = lines.slice(start, end).join("\n");
|
||||
|
||||
assert.match(
|
||||
stage,
|
||||
/cd node_modules\/better-sqlite3\s*&&\s*npx\s+(--yes\s+)?node-gyp rebuild/,
|
||||
"builder stage must compile better-sqlite3 by invoking node-gyp directly inside its " +
|
||||
"package directory (bypasses npm's rebuild-script indirection)"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
stage,
|
||||
/npm rebuild better-sqlite3/,
|
||||
"builder stage must not fall back to `npm rebuild better-sqlite3` — that indirection " +
|
||||
"is the #6700 Dokploy build failure mode"
|
||||
);
|
||||
});
|
||||
|
||||
test("#6700 the better-sqlite3 rebuild happens after `npm ci --ignore-scripts` and before the smoke-load", () => {
|
||||
const { start, end } = builderStageRange();
|
||||
// Ignore comment lines (`#…`) so prose that merely mentions these commands
|
||||
// (e.g. explaining *why* in a comment above the RUN step) is not mistaken
|
||||
// for the real instruction when checking ordering.
|
||||
const stage = lines.slice(start, end).filter((l) => !l.trim().startsWith("#"));
|
||||
|
||||
const ignoreScriptsIdx = stage.findIndex((l) => /npm ci\b.*--ignore-scripts/.test(l));
|
||||
const rebuildIdx = stage.findIndex((l) => /node-gyp rebuild/.test(l));
|
||||
const smokeLoadIdx = stage.findIndex((l) =>
|
||||
/node -e ".*require\('better-sqlite3'\)\(':memory:'\)\.close\(\)"/.test(l)
|
||||
);
|
||||
|
||||
assert.ok(ignoreScriptsIdx >= 0, "builder stage must run `npm ci --ignore-scripts`");
|
||||
assert.ok(rebuildIdx >= 0, "builder stage must run the better-sqlite3 node-gyp rebuild");
|
||||
assert.ok(smokeLoadIdx >= 0, "builder stage must smoke-load better-sqlite3 after the rebuild");
|
||||
assert.ok(
|
||||
ignoreScriptsIdx <= rebuildIdx && rebuildIdx <= smokeLoadIdx,
|
||||
"order must be: npm ci --ignore-scripts -> node-gyp rebuild -> smoke-load"
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../open-sse/executors/kimi-web.ts");
|
||||
const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
describe("KimiWebExecutor", () => {
|
||||
it("can be instantiated", () => {
|
||||
@@ -79,6 +80,24 @@ describe("resolveModelConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("kimi-web catalog", () => {
|
||||
it("lists only currently supported non-agent web models", () => {
|
||||
const models = getModelsByProviderId("kimi-web");
|
||||
assert.deepEqual(
|
||||
models.map((model) => ({ id: model.id, name: model.name })),
|
||||
[
|
||||
{ id: "k2d6", name: "K2.6 Instant" },
|
||||
{ id: "k2d6-thinking", name: "K2.6 Thinking" },
|
||||
]
|
||||
);
|
||||
assert.ok(models.find((model) => model.id === "k2d6-thinking")?.supportsReasoning);
|
||||
assert.ok(!models.some((model) => model.id.includes("agent")));
|
||||
assert.ok(
|
||||
!models.some((model) => ["kimi-default", "kimi-k2.6", "kimi-128k"].includes(model.id))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKimiJwt", () => {
|
||||
const { extractKimiJwt } = mod;
|
||||
|
||||
|
||||
104
tests/unit/github-skill-tools-mcp.test.ts
Normal file
104
tests/unit/github-skill-tools-mcp.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Unit tests for the MCP tool handlers in open-sse/mcp-server/tools/githubSkillTools.ts:
|
||||
*
|
||||
* - omniroute_github_skills_search
|
||||
* - omniroute_github_skills_scan
|
||||
* - omniroute_github_skills_install
|
||||
*
|
||||
* global.fetch is monkey-patched for the duration of this file to avoid live
|
||||
* GitHub API calls from searchGitHubSkills() (20+ queries per invocation).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { githubSkillTools } = await import("../../open-sse/mcp-server/tools/githubSkillTools.ts");
|
||||
const { GitHubSkillsSearchSchema, GitHubSkillsScanSchema, GitHubSkillsInstallSchema } =
|
||||
await import("../../src/lib/skills/githubCollector.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.before(() => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ─── omniroute_github_skills_search ────────────────────────────────────────
|
||||
|
||||
test("omniroute_github_skills_search: returns a well-shaped result for a valid search", async () => {
|
||||
const args = GitHubSkillsSearchSchema.parse({ minStars: 1, maxResults: 5 });
|
||||
const result = await githubSkillTools.omniroute_github_skills_search.handler(args);
|
||||
|
||||
assert.ok(Array.isArray(result.skills));
|
||||
assert.equal(typeof result.total, "number");
|
||||
});
|
||||
|
||||
// ─── omniroute_github_skills_scan ──────────────────────────────────────────
|
||||
|
||||
test("omniroute_github_skills_scan: flags a blocked pattern as unclean", async () => {
|
||||
// Inert string fixture only — never executed. scanText() pattern-matches this
|
||||
// text against BLOCKED_PATTERNS (src/lib/skills/githubCollector.ts); no eval() runs.
|
||||
const args = GitHubSkillsScanSchema.parse({
|
||||
repoName: "user/malicious-skill",
|
||||
content: "run this: eval(base64_decode('...'))",
|
||||
});
|
||||
const result = await githubSkillTools.omniroute_github_skills_scan.handler(args);
|
||||
|
||||
assert.equal(result.repoName, "user/malicious-skill");
|
||||
assert.equal(result.clean, false);
|
||||
assert.ok(result.findings.length > 0);
|
||||
});
|
||||
|
||||
test("omniroute_github_skills_scan: reports clean for benign content", async () => {
|
||||
const args = GitHubSkillsScanSchema.parse({
|
||||
repoName: "user/benign-skill",
|
||||
content: "# My Skill\n\nThis skill helps you write better commit messages.",
|
||||
});
|
||||
const result = await githubSkillTools.omniroute_github_skills_scan.handler(args);
|
||||
|
||||
assert.equal(result.clean, true);
|
||||
assert.deepEqual(result.findings, []);
|
||||
});
|
||||
|
||||
// ─── omniroute_github_skills_install ───────────────────────────────────────
|
||||
|
||||
test("omniroute_github_skills_install: reports action 'planned' (honest — no file is actually cloned)", async () => {
|
||||
const args = GitHubSkillsInstallSchema.parse({
|
||||
repoName: "user/skill-example",
|
||||
targets: ["claude"],
|
||||
description: "an example agent skill",
|
||||
});
|
||||
const result = await githubSkillTools.omniroute_github_skills_install.handler(args);
|
||||
|
||||
assert.equal(result.allOk, true);
|
||||
assert.equal(result.results.length, 1);
|
||||
assert.equal(result.results[0].action, "planned");
|
||||
assert.ok(result.results[0].destDir);
|
||||
});
|
||||
|
||||
test("omniroute_github_skills_install: error path never leaks a stack trace", async () => {
|
||||
// GitHubSkillsInstallSchema.targets is an enum of INSTALL_TARGETS, so a genuinely
|
||||
// unknown target can't reach the handler through the schema — but resolveInstallPath
|
||||
// can still throw for other reasons. Exercise the catch branch directly by using a
|
||||
// valid enum target and asserting the success path never has a raw error either.
|
||||
const args = GitHubSkillsInstallSchema.parse({
|
||||
repoName: "user/skill-example",
|
||||
targets: ["hermes", "gemini"],
|
||||
});
|
||||
const result = await githubSkillTools.omniroute_github_skills_install.handler(args);
|
||||
|
||||
for (const r of result.results) {
|
||||
if (r.error) {
|
||||
assert.ok(
|
||||
!r.error.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain a stack trace: "${r.error}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
74
tests/unit/kimi-web-models-discovery.test.ts
Normal file
74
tests/unit/kimi-web-models-discovery.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-web-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("kimi-web model discovery sends Kimi auth as bearer and cookie", async () => {
|
||||
await resetStorage();
|
||||
const jwt = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIn0.signature";
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "kimi-web",
|
||||
authType: "apikey",
|
||||
name: "kimi-web-discovery",
|
||||
apiKey: `_ga=ignored; theme=dark; kimi-auth=${jwt}; __cf_bm=ignored`,
|
||||
});
|
||||
|
||||
let captured: { url: string; init?: RequestInit } | null = null;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
captured = { url: String(url), init };
|
||||
return Response.json({
|
||||
availableModels: [
|
||||
{ key: "k2d6", displayName: "K2.6 Instant" },
|
||||
{ key: "k2d6-thinking", displayName: "K2.6 Thinking", thinking: true },
|
||||
{ key: "k2d6-agent", displayName: "K2.6 Agent" },
|
||||
{ key: "k2d6-agent-ultra", displayName: "K2.6 Agent Swarm" },
|
||||
],
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const response = await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(
|
||||
body.models.map((model: { id: string }) => model.id),
|
||||
["k2d6", "k2d6-thinking"]
|
||||
);
|
||||
assert.equal(
|
||||
captured?.url,
|
||||
"https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels"
|
||||
);
|
||||
assert.equal(captured?.init?.method, "POST");
|
||||
assert.equal(captured?.init?.body, "{}");
|
||||
const headers = captured?.init?.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, `Bearer ${jwt}`);
|
||||
assert.equal(headers.Cookie, `kimi-auth=${jwt}`);
|
||||
assert.equal(headers["connect-protocol-version"], "1");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -117,7 +117,7 @@ test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => {
|
||||
|
||||
test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => {
|
||||
assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models");
|
||||
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models");
|
||||
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/");
|
||||
});
|
||||
|
||||
test("providerModelsConfig keeps the aimlapi live catalog entry", () => {
|
||||
|
||||
@@ -2766,7 +2766,7 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => {
|
||||
test("isSecurityBlockError: public-host redirect block is NOT a security block", () => {
|
||||
const publicRedirect = new SafeOutboundFetchError("Redirect blocked", {
|
||||
code: "REDIRECT_BLOCKED",
|
||||
url: "https://chat.qwen.ai/api/v2/models",
|
||||
url: "https://chat.qwen.ai/api/v2/models/",
|
||||
method: "GET",
|
||||
attempts: 1,
|
||||
status: 307,
|
||||
|
||||
@@ -87,13 +87,22 @@ test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (
|
||||
assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts");
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => {
|
||||
// Cloudflare's PUT /workers/scripts API expects a module-format worker
|
||||
// (main_module = index.js, content-type application/javascript+module).
|
||||
// The handler must be exposed as `export default { fetch }`.
|
||||
test("buildCloudflareWorkerScript uses Service Worker syntax, not an ES module (#6416/#6496)", () => {
|
||||
// Cloudflare's PUT /workers/scripts API parses a plain `application/javascript`
|
||||
// script part as Service Worker syntax regardless of any `main_module`
|
||||
// metadata — `main_module` requires the script to actually be an ES module
|
||||
// (top-level `export`), which rejects the upload with "Unexpected token
|
||||
// 'export'" (#6496). The handler must instead register a `fetch` event
|
||||
// listener (`addEventListener("fetch", ...)`), with no top-level `export`.
|
||||
const src = buildCloudflareWorkerScript("tok");
|
||||
assert.ok(/export\s+default/.test(src), "must be an ES module (export default)");
|
||||
assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler");
|
||||
assert.ok(
|
||||
!/^\s*export\s+default/m.test(src),
|
||||
"must not be an ES module (no top-level `export default`)"
|
||||
);
|
||||
assert.ok(
|
||||
/addEventListener\(\s*["']fetch["']/.test(src),
|
||||
"must register a fetch event listener (Service Worker syntax)"
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* streaming endpoint — is a separate upstream/stealth concern, still open.)
|
||||
*
|
||||
* Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public
|
||||
* `https://chat.qwen.ai/api/v2/models` endpoint, parsing the
|
||||
* `https://chat.qwen.ai/api/v2/models/` endpoint, parsing the
|
||||
* `{ data: { data: [{ id, name, owned_by }] } }` shape.
|
||||
*/
|
||||
import test from "node:test";
|
||||
@@ -45,7 +45,7 @@ interface ModelsBody {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models";
|
||||
const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models/";
|
||||
|
||||
test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => {
|
||||
await resetStorage();
|
||||
@@ -83,7 +83,11 @@ test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog",
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as ModelsBody;
|
||||
assert.equal(body.provider, "qwen-web");
|
||||
assert.equal(body.source, "api", "should serve the live qwen-web catalog, not local_catalog/empty");
|
||||
assert.equal(
|
||||
body.source,
|
||||
"api",
|
||||
"should serve the live qwen-web catalog, not local_catalog/empty"
|
||||
);
|
||||
assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`);
|
||||
const ids = body.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Regression tests for #5128 — one-click relay deployments (Deno + Cloudflare +
|
||||
// Vercel) broken in v3.8.37. Four distinct, independently-reproducible bugs:
|
||||
@@ -95,9 +96,7 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a
|
||||
const bodyText = Buffer.isBuffer(init.body)
|
||||
? (init.body as Buffer).toString("utf8")
|
||||
: String(init.body);
|
||||
const match = bodyText.match(
|
||||
/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/
|
||||
);
|
||||
const match = bodyText.match(/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/);
|
||||
scriptPartContentType = match?.[1];
|
||||
// Simulate the CF API rejecting the upload so the route short-circuits
|
||||
// without making the follow-up subdomain calls.
|
||||
@@ -138,6 +137,88 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// E) Cloudflare worker script uses Service Worker syntax with body_part (#6416)
|
||||
// --------------------------------------------------------------------------
|
||||
test("#6416: Cloudflare worker script body is Service Worker syntax (no top-level export) + metadata uses body_part", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let capturedScriptBody = "";
|
||||
let capturedMetadata: Record<string, unknown> | undefined;
|
||||
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
if (init.method === "PUT" && url.includes("/workers/scripts/") && !url.includes("/subdomain")) {
|
||||
const bodyText = Buffer.isBuffer(init.body)
|
||||
? (init.body as Buffer).toString("utf8")
|
||||
: String(init.body);
|
||||
const scriptMatch = bodyText.match(
|
||||
/name="index\.js"[^]*?Content-Type: [^\r\n]+\r\n\r\n([^]*?)\r\n--/
|
||||
);
|
||||
const metadataMatch = bodyText.match(
|
||||
/name="metadata"[^]*?Content-Type: application\/json\r\n\r\n([^]*?)\r\n--/
|
||||
);
|
||||
capturedScriptBody = scriptMatch?.[1] ?? "";
|
||||
capturedMetadata = metadataMatch?.[1]
|
||||
? (JSON.parse(metadataMatch[1]) as Record<string, unknown>)
|
||||
: undefined;
|
||||
return Response.json({ errors: [{ message: "stubbed" }] }, { status: 400 });
|
||||
}
|
||||
return Response.json({ result: {} });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const route = await import("../../src/app/api/settings/proxy/cloudflare-deploy/route.ts");
|
||||
await route.POST(
|
||||
new Request("http://localhost/api/settings/proxy/cloudflare-deploy", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
accountId: "abcdef0123456789",
|
||||
apiToken: "cf-token-aaaaaaaaaaaaaaaaaaaaaa",
|
||||
projectName: "omniroute-relay",
|
||||
}),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
|
||||
// The Cloudflare multipart upload API parses `application/javascript` script
|
||||
// parts as Service Workers, so the body must NOT use ES-module syntax
|
||||
// (`export default {...}`). It must register a fetch event listener instead.
|
||||
assert.ok(
|
||||
!/^\s*export\s+default/m.test(capturedScriptBody),
|
||||
"Cloudflare worker script must not use `export default` (#6416 — CF parses non-`+module` MIME types as Service Workers)"
|
||||
);
|
||||
assert.ok(
|
||||
/addEventListener\(\s*["']fetch["']/.test(capturedScriptBody),
|
||||
"Cloudflare worker script must register a fetch event listener"
|
||||
);
|
||||
|
||||
const privateHostnameFnSource = capturedScriptBody.match(
|
||||
/function isPrivateHostname\(h\) \{[\s\S]*?\n\}/
|
||||
)?.[0];
|
||||
assert.ok(privateHostnameFnSource, "emitted worker script should contain isPrivateHostname");
|
||||
const isPrivateHostname = vm.runInNewContext(
|
||||
`${privateHostnameFnSource}; isPrivateHostname;`,
|
||||
{}
|
||||
) as (host: string) => boolean;
|
||||
assert.equal(isPrivateHostname("[::1]"), true, "bracketed IPv6 loopback must stay blocked");
|
||||
assert.equal(isPrivateHostname("[fd00::1]"), true, "bracketed IPv6 ULA must stay blocked");
|
||||
|
||||
// Metadata must use `body_part` (Service Worker entry) rather than
|
||||
// `main_module` (which requires an actual ES module).
|
||||
assert.equal(
|
||||
capturedMetadata?.body_part,
|
||||
"index.js",
|
||||
"metadata.body_part must point at the script part"
|
||||
);
|
||||
assert.equal(
|
||||
capturedMetadata?.main_module,
|
||||
undefined,
|
||||
"metadata must not use main_module — that requires an ES module script body (#6416)"
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// D) proxy-registry schema accepts deno/cloudflare relay types + sources
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
268
tests/unit/skills-collect-routes.test.ts
Normal file
268
tests/unit/skills-collect-routes.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Unit tests for the skill-collector CLI-detection REST surface (PR #6294 review):
|
||||
*
|
||||
* - GET/POST /api/github-skills
|
||||
* - GET /api/skills/collect/detect
|
||||
* - POST /api/skills/collect/install
|
||||
*
|
||||
* Coverage goals (mandatory per PR #6294 plan-file):
|
||||
* - Auth-required assertion: every route returns 401/403 when management auth is
|
||||
* required and no credential is provided (requireManagementAuth wiring).
|
||||
* - No-stack-trace-leak assertion (Hard Rule #12): error responses never contain
|
||||
* `err.stack`/absolute-path fragments.
|
||||
* - Happy-path smoke test for each route.
|
||||
*
|
||||
* global.fetch is monkey-patched for the duration of this file to avoid live
|
||||
* GitHub API calls from searchGitHubSkills() (20+ queries per invocation) —
|
||||
* this keeps the suite fast and network-independent.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// ── DB / auth setup ───────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-collect-routes-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "skills-collect-routes-test-secret";
|
||||
|
||||
// Import DB first (order matters — sets DATA_DIR before localDb loads)
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
// Import routes AFTER env vars are set
|
||||
const githubSkillsRoute = await import("../../src/app/api/github-skills/route.ts");
|
||||
const detectRoute = await import("../../src/app/api/skills/collect/detect/route.ts");
|
||||
const installRoute = await import("../../src/app/api/skills/collect/install/route.ts");
|
||||
|
||||
// ── fetch mock — avoid live GitHub API calls ────────────────────────────────
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.before(() => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): Request {
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// GET/POST /api/github-skills and GET /api/skills/collect/detect are typed as
|
||||
// NextRequest; POST /api/skills/collect/install is typed as plain Request. A
|
||||
// standard Request satisfies every property NextRequest handlers actually read
|
||||
// (method/url/headers/json()) — the same cast pattern as tests/unit/a2a-enabled-route.test.ts.
|
||||
function asNextRequest(req: Request): NextRequest {
|
||||
return req as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function assertNoStackTrace(message: string) {
|
||||
assert.ok(
|
||||
!message.match(/\bat \/|\bat file:\/\//),
|
||||
`Error message must not contain a stack trace: "${message}"`
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET/POST /api/github-skills — auth guard
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/github-skills — 401/403 when auth is required and no token provided", async () => {
|
||||
process.env.INITIAL_PASSWORD = "test-password-requires-login";
|
||||
|
||||
const req = makeRequest("GET", "http://localhost/api/github-skills");
|
||||
const res = await githubSkillsRoute.GET(asNextRequest(req));
|
||||
|
||||
assert.ok(
|
||||
res.status === 401 || res.status === 403,
|
||||
`Expected 401 or 403 without auth, got ${res.status}`
|
||||
);
|
||||
const body = (await res.json()) as { error: { message: string } | string };
|
||||
const errorMsg =
|
||||
typeof body.error === "string" ? body.error : (body.error as { message: string }).message;
|
||||
assertNoStackTrace(errorMsg);
|
||||
});
|
||||
|
||||
test("POST /api/github-skills — 401/403 when auth is required and no token provided", async () => {
|
||||
process.env.INITIAL_PASSWORD = "test-password-requires-login";
|
||||
|
||||
const req = makeRequest("POST", "http://localhost/api/github-skills", {
|
||||
repoName: "user/repo",
|
||||
});
|
||||
const res = await githubSkillsRoute.POST(asNextRequest(req));
|
||||
|
||||
assert.ok(
|
||||
res.status === 401 || res.status === 403,
|
||||
`Expected 401 or 403 without auth, got ${res.status}`
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/github-skills — 200 happy path when auth is not required", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/github-skills?minStars=1&maxResults=5");
|
||||
const res = await githubSkillsRoute.GET(asNextRequest(req));
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: unknown[]; total: number };
|
||||
assert.ok(Array.isArray(body.skills));
|
||||
assert.equal(typeof body.total, "number");
|
||||
});
|
||||
|
||||
test("POST /api/github-skills — 400 when repoName is missing", async () => {
|
||||
const req = makeRequest("POST", "http://localhost/api/github-skills", {});
|
||||
const res = await githubSkillsRoute.POST(asNextRequest(req));
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("POST /api/github-skills — 200 plans install for a valid repoName", async () => {
|
||||
const req = makeRequest("POST", "http://localhost/api/github-skills", {
|
||||
repoName: "user/skill-example",
|
||||
targets: ["claude"],
|
||||
});
|
||||
const res = await githubSkillsRoute.POST(asNextRequest(req));
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { results: { target: string; action: string }[] };
|
||||
assert.equal(body.results[0].action, "planned");
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/skills/collect/detect — auth guard + happy path
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/skills/collect/detect — 401/403 when auth is required and no token provided", async () => {
|
||||
process.env.INITIAL_PASSWORD = "test-password-requires-login";
|
||||
|
||||
const req = makeRequest("GET", "http://localhost/api/skills/collect/detect");
|
||||
const res = await detectRoute.GET(asNextRequest(req));
|
||||
|
||||
assert.ok(
|
||||
res.status === 401 || res.status === 403,
|
||||
`Expected 401 or 403 without auth, got ${res.status}`
|
||||
);
|
||||
const body = (await res.json()) as { error: { message: string } | string };
|
||||
const errorMsg =
|
||||
typeof body.error === "string" ? body.error : (body.error as { message: string }).message;
|
||||
assertNoStackTrace(errorMsg);
|
||||
});
|
||||
|
||||
test("GET /api/skills/collect/detect — 200 happy path when auth is not required", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/skills/collect/detect");
|
||||
const res = await detectRoute.GET(asNextRequest(req));
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
tools: Record<string, unknown>;
|
||||
installedToolIds: string[];
|
||||
matchedSkills: unknown[];
|
||||
totalSkills: number;
|
||||
};
|
||||
assert.ok(typeof body.tools === "object" && body.tools !== null);
|
||||
assert.ok(Array.isArray(body.installedToolIds));
|
||||
assert.ok(Array.isArray(body.matchedSkills));
|
||||
assert.equal(typeof body.totalSkills, "number");
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/skills/collect/install — auth guard + happy path
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("POST /api/skills/collect/install — 401/403 when auth is required and no token provided", async () => {
|
||||
process.env.INITIAL_PASSWORD = "test-password-requires-login";
|
||||
|
||||
const req = makeRequest("POST", "http://localhost/api/skills/collect/install", {
|
||||
repoName: "user/skill-example",
|
||||
targets: ["claude"],
|
||||
});
|
||||
const res = await installRoute.POST(req);
|
||||
|
||||
assert.ok(
|
||||
res.status === 401 || res.status === 403,
|
||||
`Expected 401 or 403 without auth, got ${res.status}`
|
||||
);
|
||||
const body = (await res.json()) as { error: { message: string } | string };
|
||||
const errorMsg =
|
||||
typeof body.error === "string" ? body.error : (body.error as { message: string }).message;
|
||||
assertNoStackTrace(errorMsg);
|
||||
});
|
||||
|
||||
test("POST /api/skills/collect/install — 400 on invalid body (missing repoName)", async () => {
|
||||
const req = makeRequest("POST", "http://localhost/api/skills/collect/install", {
|
||||
targets: ["claude"],
|
||||
});
|
||||
const res = await installRoute.POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("POST /api/skills/collect/install — 200 plans install for a valid body", async () => {
|
||||
const req = makeRequest("POST", "http://localhost/api/skills/collect/install", {
|
||||
repoName: "user/skill-example",
|
||||
targets: ["claude", "codex"],
|
||||
description: "an example agent skill",
|
||||
});
|
||||
const res = await installRoute.POST(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
results: { target: string; action: string; destDir?: string }[];
|
||||
};
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.results.length, 2);
|
||||
for (const r of body.results) {
|
||||
assert.equal(r.action, "planned");
|
||||
assert.ok(r.destDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/skills/collect/install — 200 with a per-target error for an unknown tool", async () => {
|
||||
const req = makeRequest("POST", "http://localhost/api/skills/collect/install", {
|
||||
repoName: "user/skill-example",
|
||||
targets: ["totally-unknown-tool"],
|
||||
});
|
||||
const res = await installRoute.POST(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { ok: boolean; results: { ok: boolean; action: string }[] };
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.results[0].action, "error");
|
||||
});
|
||||
Reference in New Issue
Block a user