mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
feat(agent-bridge): portable JSON import/export of config (Gap 4) (#4094)
Integrated into release/v3.8.28
This commit is contained in:
committed by
GitHub
parent
42cf06e4af
commit
7f53f6bb32
44
src/app/api/tools/agent-bridge/config/route.ts
Normal file
44
src/app/api/tools/agent-bridge/config/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GET /api/tools/agent-bridge/config — export portable AgentBridge config
|
||||
* POST /api/tools/agent-bridge/config — import portable AgentBridge config
|
||||
*
|
||||
* Lets users replicate a setup (bypass patterns + custom hosts + per-agent
|
||||
* model mappings) across machines via a versioned JSON blob. Built-in defaults
|
||||
* are not exported, so importing never duplicates them. (Gap 4.)
|
||||
*
|
||||
* LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts.
|
||||
*/
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import {
|
||||
AgentBridgeConfigSchema,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
} from "@/lib/inspector/configPortability";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
return Response.json(exportConfig());
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const raw = await request.json().catch(() => null);
|
||||
const parsed = AgentBridgeConfigSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: parsed.error.issues[0]?.message ?? "Invalid AgentBridge config",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = importConfig(parsed.data);
|
||||
return Response.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
86
src/lib/inspector/configPortability.ts
Normal file
86
src/lib/inspector/configPortability.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Portable AgentBridge configuration (Gap 4).
|
||||
*
|
||||
* Serialises the operator-tunable AgentBridge state — user bypass patterns,
|
||||
* custom hosts, and per-agent model mappings — into a versioned JSON blob so a
|
||||
* setup can be replicated across machines (ProxyBridge ships the same
|
||||
* import/export-rules portability). Defaults (bank/gov/okta bypass, etc.) live
|
||||
* in code and are intentionally NOT exported, so importing never duplicates or
|
||||
* fights them.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { getUserBypassPatterns, replaceUserBypassPatterns } from "@/lib/db/agentBridgeBypass";
|
||||
import { listCustomHosts, addCustomHost } from "@/lib/db/inspectorCustomHosts";
|
||||
import { getMappingsForAgent, setMappings } from "@/lib/db/agentBridgeMappings";
|
||||
import { ALL_TARGETS } from "@/mitm/targets/index";
|
||||
|
||||
export const AgentBridgeConfigSchema = z.object({
|
||||
version: z.literal(1),
|
||||
bypassPatterns: z.array(z.string()),
|
||||
customHosts: z.array(
|
||||
z.object({
|
||||
host: z.string().min(1),
|
||||
kind: z.enum(["llm", "app", "custom"]).default("custom"),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
agentMappings: z.record(
|
||||
z.string(),
|
||||
z.array(z.object({ source: z.string(), target: z.string() }))
|
||||
),
|
||||
});
|
||||
|
||||
export type AgentBridgeConfig = z.infer<typeof AgentBridgeConfigSchema>;
|
||||
|
||||
/** Read the current operator-tunable AgentBridge state into a portable blob. */
|
||||
export function exportConfig(): AgentBridgeConfig {
|
||||
const customHosts = listCustomHosts().map((h) => ({
|
||||
host: h.host,
|
||||
kind: (h.kind as "llm" | "app" | "custom") ?? "custom",
|
||||
label: h.label ?? null,
|
||||
}));
|
||||
|
||||
const agentMappings: Record<string, Array<{ source: string; target: string }>> = {};
|
||||
for (const target of ALL_TARGETS) {
|
||||
const rows = getMappingsForAgent(target.id);
|
||||
if (rows.length > 0) {
|
||||
agentMappings[target.id] = rows.map((r) => ({
|
||||
source: r.source_model,
|
||||
target: r.target_model,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
bypassPatterns: getUserBypassPatterns(),
|
||||
customHosts,
|
||||
agentMappings,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
bypassPatterns: number;
|
||||
customHosts: number;
|
||||
agents: number;
|
||||
}
|
||||
|
||||
/** Apply a validated config to the DB. Bypass + mappings replace wholesale;
|
||||
* custom hosts are added idempotently (INSERT OR IGNORE). */
|
||||
export function importConfig(config: AgentBridgeConfig): ImportResult {
|
||||
replaceUserBypassPatterns(config.bypassPatterns);
|
||||
|
||||
for (const h of config.customHosts) {
|
||||
addCustomHost(h.host, h.kind, h.label ?? undefined);
|
||||
}
|
||||
|
||||
for (const [agentId, mappings] of Object.entries(config.agentMappings)) {
|
||||
setMappings(agentId, mappings);
|
||||
}
|
||||
|
||||
return {
|
||||
bypassPatterns: config.bypassPatterns.length,
|
||||
customHosts: config.customHosts.length,
|
||||
agents: Object.keys(config.agentMappings).length,
|
||||
};
|
||||
}
|
||||
103
tests/unit/agent-bridge-config-portability.test.ts
Normal file
103
tests/unit/agent-bridge-config-portability.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Gap 4: portable JSON import/export of AgentBridge config (bypass patterns +
|
||||
* custom hosts + per-agent model mappings) so users can replicate a setup
|
||||
* across machines. Schema validation is pure; export/import roundtrip uses the
|
||||
* DATA_DIR-tmp + resetDbInstance pattern (CLAUDE.md PII learning #3).
|
||||
*/
|
||||
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-agentbridge-config-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const portability = await import("../../src/lib/inspector/configPortability.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
||||
} else throw error;
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema accepts a well-formed config", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 1,
|
||||
bypassPatterns: ["*.bank.test"],
|
||||
customHosts: [{ host: "api.internal.test", kind: "custom", label: "Internal" }],
|
||||
agentMappings: { cursor: [{ source: "gpt-4o", target: "claude-sonnet-4-5" }] },
|
||||
});
|
||||
assert.equal(parsed.success, true);
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema rejects a wrong version", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 2,
|
||||
bypassPatterns: [],
|
||||
customHosts: [],
|
||||
agentMappings: {},
|
||||
});
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("AgentBridgeConfigSchema rejects a non-string bypass pattern", () => {
|
||||
const parsed = portability.AgentBridgeConfigSchema.safeParse({
|
||||
version: 1,
|
||||
bypassPatterns: [123],
|
||||
customHosts: [],
|
||||
agentMappings: {},
|
||||
});
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("import then export roundtrips bypass + custom hosts + mappings", () => {
|
||||
const config = {
|
||||
version: 1 as const,
|
||||
bypassPatterns: ["*.bank.test", "literal.example.com"],
|
||||
customHosts: [
|
||||
{ host: "api.internal.test", kind: "custom" as const, label: "Internal LLM" },
|
||||
],
|
||||
agentMappings: {
|
||||
cursor: [{ source: "gpt-4o", target: "claude-sonnet-4-5" }],
|
||||
},
|
||||
};
|
||||
portability.importConfig(config);
|
||||
const exported = portability.exportConfig();
|
||||
|
||||
assert.deepEqual(
|
||||
[...exported.bypassPatterns].sort(),
|
||||
[...config.bypassPatterns].sort(),
|
||||
"bypass patterns must roundtrip"
|
||||
);
|
||||
assert.ok(
|
||||
exported.customHosts.some((h) => h.host === "api.internal.test"),
|
||||
"custom host must roundtrip"
|
||||
);
|
||||
assert.deepEqual(
|
||||
exported.agentMappings.cursor,
|
||||
config.agentMappings.cursor,
|
||||
"agent mappings must roundtrip"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user