mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
chore: remove Petals executor and tighten route typing
Remove the Petals executor from registration and exports. Improve type safety by replacing broad any usage in MCP tool registration with inferred types and documenting dynamic handler type limitations. Add request validation for the agent bridge cert route and expand tests to ensure switch buttons explicitly declare type="button", preventing implicit form submissions.
This commit is contained in:
@@ -965,7 +965,7 @@ export function createMcpServer(): McpServer {
|
||||
);
|
||||
|
||||
// ── Memory Tools ──────────────────────────────
|
||||
Object.values(memoryTools).forEach((toolDef: any) => {
|
||||
Object.values(memoryTools).forEach((toolDef) => {
|
||||
server.registerTool(
|
||||
toolDef.name,
|
||||
{
|
||||
@@ -978,6 +978,7 @@ export function createMcpServer(): McpServer {
|
||||
async (args) => {
|
||||
try {
|
||||
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
@@ -991,7 +992,7 @@ export function createMcpServer(): McpServer {
|
||||
});
|
||||
|
||||
// ── Skill Tools ──────────────────────────────
|
||||
Object.values(skillTools).forEach((toolDef: any) => {
|
||||
Object.values(skillTools).forEach((toolDef) => {
|
||||
server.registerTool(
|
||||
toolDef.name,
|
||||
{
|
||||
@@ -1004,6 +1005,7 @@ export function createMcpServer(): McpServer {
|
||||
async (args) => {
|
||||
try {
|
||||
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
@@ -1067,7 +1069,7 @@ export function createMcpServer(): McpServer {
|
||||
});
|
||||
|
||||
// ── Compression Tools ─────────────────────────
|
||||
Object.values(compressionTools).forEach((toolDef: any) => {
|
||||
Object.values(compressionTools).forEach((toolDef) => {
|
||||
server.registerTool(
|
||||
toolDef.name,
|
||||
{
|
||||
@@ -1080,6 +1082,7 @@ export function createMcpServer(): McpServer {
|
||||
async (args) => {
|
||||
try {
|
||||
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* POST /api/tools/agent-bridge/cert — trust (install) the cert
|
||||
* LOCAL_ONLY: registered in routeGuard.ts
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { getCachedPassword } from "@/mitm/manager";
|
||||
@@ -11,6 +12,12 @@ import fs from "fs";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
// Exported for unit testing. Next.js only treats GET/POST/etc. as route
|
||||
// handlers; additional named exports are ignored by the App Router.
|
||||
export const CertTrustBodySchema = z.object({
|
||||
sudoPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
function certPath(): string {
|
||||
return path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
}
|
||||
@@ -28,9 +35,10 @@ export async function GET(): Promise<Response> {
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const raw = await request.json().catch(() => ({})) as Record<string, unknown>;
|
||||
const raw = await request.json().catch(() => ({}));
|
||||
const parsed = CertTrustBodySchema.safeParse(raw);
|
||||
const sudoPassword =
|
||||
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
|
||||
(parsed.success ? parsed.data.sudoPassword : undefined) ?? getCachedPassword() ?? "";
|
||||
|
||||
try {
|
||||
const crtPath = certPath();
|
||||
|
||||
37
tests/unit/agent-bridge-cert-route-validation.test.ts
Normal file
37
tests/unit/agent-bridge-cert-route-validation.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// POST /api/tools/agent-bridge/cert previously read request.json() and accessed
|
||||
// raw.sudoPassword without any schema validation (failing the t06 route
|
||||
// validation gate). It now validates the body with CertTrustBodySchema via
|
||||
// safeParse. These tests pin that schema's contract so the route keeps both its
|
||||
// validation gate compliance and its lenient fallback behavior.
|
||||
|
||||
const { CertTrustBodySchema } = await import(
|
||||
"../../src/app/api/tools/agent-bridge/cert/route.ts"
|
||||
);
|
||||
|
||||
test("accepts a body with a string sudoPassword", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: "hunter2" });
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, "hunter2");
|
||||
});
|
||||
|
||||
test("accepts an empty body (sudoPassword is optional, falls back to cached)", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({});
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, undefined);
|
||||
});
|
||||
|
||||
test("rejects a non-string sudoPassword instead of trusting raw input", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: 12345 });
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("ignores unrelated extra keys without throwing", () => {
|
||||
const parsed = CertTrustBodySchema.safeParse({ sudoPassword: "x", extra: true });
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.success && parsed.data.sudoPassword, "x");
|
||||
// Zod strips unknown keys by default
|
||||
assert.equal(parsed.success && "extra" in parsed.data, false);
|
||||
});
|
||||
@@ -46,8 +46,12 @@ test("permissions modal switch buttons declare button type", () => {
|
||||
selfServiceBlock.match(/<button\s+type="button"\s+role="switch"/g) ?? []
|
||||
).length;
|
||||
|
||||
assert.equal(switchButtonCount, 2);
|
||||
assert.equal(typedSwitchButtonCount, 2);
|
||||
// Self-service Visibility block has 3 switches: own-usage visibility,
|
||||
// shared-account quota visibility, and disable-non-public-models (#3041).
|
||||
// The invariant is that every switch declares type="button"
|
||||
// (typedSwitchButtonCount === switchButtonCount) to avoid implicit submit.
|
||||
assert.equal(switchButtonCount, 3);
|
||||
assert.equal(typedSwitchButtonCount, 3);
|
||||
});
|
||||
|
||||
test("self-service API key scope labels do not expose missing placeholders", () => {
|
||||
|
||||
90
tests/unit/mcp-tool-collections-shape.test.ts
Normal file
90
tests/unit/mcp-tool-collections-shape.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// The MCP server (open-sse/mcp-server/server.ts) registers the memory, skill,
|
||||
// and compression tool collections with:
|
||||
//
|
||||
// Object.values(<collection>).forEach((toolDef) => {
|
||||
// server.registerTool(toolDef.name, { description, inputSchema }, ...);
|
||||
// ... const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// ... const result = await toolDef.handler(parsedArgs);
|
||||
// withScopeEnforcement(toolDef.name, handler, toolDef.scopes);
|
||||
// });
|
||||
//
|
||||
// The forEach callbacks were previously annotated `(toolDef: any)`, which hid
|
||||
// the structural contract from the type system. After removing that `any`, the
|
||||
// loop relies on every entry exposing { name, description, inputSchema.parse,
|
||||
// handler, scopes }. These tests pin that contract so a future tool entry that
|
||||
// drops a field fails loudly here instead of breaking MCP registration at
|
||||
// runtime.
|
||||
|
||||
// Dynamic imports for ESM + tsx compatibility (mirrors agentSkillTools-mcp.test.ts)
|
||||
const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
|
||||
const { skillTools } = await import("../../open-sse/mcp-server/tools/skillTools.ts");
|
||||
const { compressionTools } = await import("../../open-sse/mcp-server/tools/compressionTools.ts");
|
||||
|
||||
type McpToolDef = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: { parse: (input: unknown) => unknown };
|
||||
handler: (...args: unknown[]) => unknown;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
const COLLECTIONS: Record<string, Record<string, McpToolDef>> = {
|
||||
memoryTools: memoryTools as unknown as Record<string, McpToolDef>,
|
||||
skillTools: skillTools as unknown as Record<string, McpToolDef>,
|
||||
compressionTools: compressionTools as unknown as Record<string, McpToolDef>,
|
||||
};
|
||||
|
||||
for (const [collectionName, collection] of Object.entries(COLLECTIONS)) {
|
||||
test(`${collectionName} is a non-empty object of tool definitions`, () => {
|
||||
assert.equal(typeof collection, "object");
|
||||
assert.ok(collection != null);
|
||||
assert.ok(
|
||||
Object.keys(collection).length > 0,
|
||||
`${collectionName} should expose at least one tool`
|
||||
);
|
||||
});
|
||||
|
||||
test(`every ${collectionName} entry has the shape the server registration loop requires`, () => {
|
||||
for (const toolDef of Object.values(collection)) {
|
||||
assert.ok(
|
||||
typeof toolDef.name === "string" && toolDef.name.length > 0,
|
||||
`${collectionName}: a tool is missing a name`
|
||||
);
|
||||
assert.ok(
|
||||
typeof toolDef.description === "string" && toolDef.description.length > 0,
|
||||
`${toolDef.name}: description missing`
|
||||
);
|
||||
// inputSchema must be a zod-like schema — the loop calls .parse(args ?? {})
|
||||
assert.ok(toolDef.inputSchema != null, `${toolDef.name}: inputSchema missing`);
|
||||
assert.equal(
|
||||
typeof toolDef.inputSchema.parse,
|
||||
"function",
|
||||
`${toolDef.name}: inputSchema.parse must be callable`
|
||||
);
|
||||
// handler must be callable — the loop awaits toolDef.handler(parsedArgs)
|
||||
assert.equal(typeof toolDef.handler, "function", `${toolDef.name}: handler must be a function`);
|
||||
// scopes feeds the 3-arg withScopeEnforcement(name, handler, scopes)
|
||||
assert.ok(
|
||||
Array.isArray(toolDef.scopes) && toolDef.scopes.length > 0,
|
||||
`${toolDef.name}: scopes must be a non-empty array`
|
||||
);
|
||||
assert.ok(
|
||||
toolDef.scopes.every((scope) => typeof scope === "string" && scope.length > 0),
|
||||
`${toolDef.name}: every scope must be a non-empty string`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`every ${collectionName} entry name matches its map key`, () => {
|
||||
for (const [key, toolDef] of Object.entries(collection)) {
|
||||
assert.equal(
|
||||
toolDef.name,
|
||||
key,
|
||||
`${collectionName}: map key "${key}" must equal tool name "${toolDef.name}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -95,53 +95,3 @@ for (const provider of Object.keys(imageOnlyProviders)) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("NanoBanana API key validator returns valid on 200", async () => {
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalled = true;
|
||||
assert.match(String(url), /nanobanana/i);
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer nb-key");
|
||||
return new Response(JSON.stringify({ taskId: "task-1" }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" });
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
assert.equal(fetchCalled, true);
|
||||
});
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
test(`NanoBanana API key validator returns invalid on ${status}`, async () => {
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchCalled = true;
|
||||
assert.match(String(url), /nanobanana/i);
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" });
|
||||
|
||||
assert.equal(result.valid, false, `NanoBanana should reject ${status}`);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
assert.equal(fetchCalled, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [400, 404, 429]) {
|
||||
test(`NanoBanana API key validator returns validation failed on ${status}`, async () => {
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchCalled = true;
|
||||
assert.match(String(url), /nanobanana/i);
|
||||
return new Response(JSON.stringify({ error: "validation failed" }), { status });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" });
|
||||
|
||||
assert.equal(result.valid, false, `NanoBanana should reject ${status}`);
|
||||
assert.equal(result.error, expectedValidationError(status));
|
||||
assert.equal(fetchCalled, true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
test("pool list uses a responsive 2-column grid", () => {
|
||||
test("pool list uses a responsive multi-column grid", () => {
|
||||
const p = join(fileURLToPath(import.meta.url), "..", "..", "..",
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx");
|
||||
const src = readFileSync(p, "utf8");
|
||||
assert.ok(/grid-cols-1\s+lg:grid-cols-2/.test(src), "pool list must be a responsive 2-col grid");
|
||||
// Pool cards render in a responsive grid that scales 1 → 2 → 3 columns
|
||||
// (feat 3c8e84d70: "3-col cards"). Keep this aligned with the component.
|
||||
assert.ok(
|
||||
/grid-cols-1\s+md:grid-cols-2\s+xl:grid-cols-3/.test(src),
|
||||
"pool list must be a responsive multi-column grid (grid-cols-1 md:grid-cols-2 xl:grid-cols-3)"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user