Repository navigation
feat(agent-bridge): portable JSON import/export of config (Gap 4) #4094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; | ||
| } | ||
|
Comment on lines
+70
to
+86
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Additionally, we should validate that the keys of export function importConfig(config: AgentBridgeConfig): ImportResult {
const db = getDbInstance();
const validAgentIds = new Set(ALL_TARGETS.map((t) => t.id));
db.transaction(() => {
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)) {
if (!validAgentIds.has(agentId)) {
throw new Error(`Invalid agent ID: ${agentId}`);
}
setMappings(agentId, mappings);
}
})();
return {
bypassPatterns: config.bypassPatterns.length,
customHosts: config.customHosts.length,
agents: Object.keys(config.agentMappings).length,
};
} |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to Repository Style Guide Rule 8, all input validation Zod schemas must be defined in
src/shared/validation/schemas.ts. Please move the definition ofAgentBridgeConfigSchematosrc/shared/validation/schemas.tsand re-export it here to maintain compliance and prevent potential client-side bundling issues.References