feat(agent-bridge): portable JSON import/export of config (Gap 4) (#4094)

Integrated into release/v3.8.28
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 16:00:30 -03:00
committed by GitHub
parent 42cf06e4af
commit 7f53f6bb32
3 changed files with 233 additions and 0 deletions

View 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 });
}
}

View 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,
};
}

View 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"
);
});