Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/app/api/tools/agent-bridge/config/route.ts
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 });
}
}
86 changes: 86 additions & 0 deletions src/lib/inspector/configPortability.ts
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() }))
),
});
Comment on lines +17 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 of AgentBridgeConfigSchema to src/shared/validation/schemas.ts and re-export it here to maintain compliance and prevent potential client-side bundling issues.

export { AgentBridgeConfigSchema } from "@/shared/validation/schemas";
References
  1. Always validate inputs with Zod schemas from src/shared/validation/schemas.ts. (link)


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The importConfig function performs multiple database write operations across different tables. If any of these operations fail (e.g., due to database constraints or locks), the database will be left in a partially imported state. Wrapping the entire import process in a single transaction ensures atomicity.

Additionally, we should validate that the keys of agentMappings correspond to valid agent IDs in ALL_TARGETS to prevent writing invalid/garbage agent IDs to the database. (Note: Please remember to import getDbInstance from @/lib/db/core at the top of the file).

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

103 changes: 103 additions & 0 deletions tests/unit/agent-bridge-config-portability.test.ts
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"
);
});
Loading