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
9 changes: 4 additions & 5 deletions src/app/api/tools/agent-bridge/cert/regenerate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import { createErrorResponse } from "@/lib/api/errorResponse";

export async function POST(): Promise<Response> {
try {
// generateCert checks for existing files — force-regenerate by deleting first
// is not in scope; the function is idempotent (returns existing paths). If a
// caller needs a fresh cert they must delete the old one manually. We expose
// whatever generateCert decides.
const result = await generateCert();
// #10467: generateCert() returns the existing paths untouched when a cert is already
// on disk, which made this endpoint a no-op — the download still served the old file.
// This route is the one caller that must always mint a fresh cert.
const result = await generateCert({ force: true });
return Response.json({ ok: true, certPath: result.cert, keyPath: result.key });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
Expand Down
9 changes: 7 additions & 2 deletions src/mitm/cert/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ const TARGET_HOST = TARGET_HOSTS[0];
/**
* Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed)
*/
export async function generateCert(): Promise<{ key: string; cert: string }> {
export async function generateCert(options?: {
force?: boolean;
}): Promise<{ key: string; cert: string }> {
const certDir = path.join(resolveMitmDataDir(), "mitm");
const keyPath = path.join(certDir, "server.key");
const certPath = path.join(certDir, "server.crt");

if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
// #10467: callers that only need a cert to exist keep the existing one, but the
// regenerate endpoint has to actually mint a new one — otherwise a cert missing the
// SANs added in #6494 can never be replaced from the UI.
if (!options?.force && fs.existsSync(keyPath) && fs.existsSync(certPath)) {
console.log("✅ SSL certificate already exists");
return { key: keyPath, cert: certPath };
}
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/agent-bridge-cert-regenerate-force-10467.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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";
import { X509Certificate } from "node:crypto";

// #10467: POST /api/tools/agent-bridge/cert/regenerate called generateCert() with no
// arguments. generateCert() short-circuits when server.key and server.crt already exist,
// so the endpoint was a no-op on every machine that had ever started the bridge — the
// download route kept serving the old file, with the same md5 and, for anyone whose cert
// predates #6494, still missing the extra SANs. generateCert now takes { force } and the
// regenerate route is the one caller that passes it.

const certModule = "../../src/mitm/cert/generate.ts";

async function withTempDataDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cert-10467-"));
const previous = process.env.DATA_DIR;
process.env.DATA_DIR = dir;
try {
return await fn(dir);
} finally {
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
fs.rmSync(dir, { recursive: true, force: true });
}
}

const read = (p: string) => fs.readFileSync(p, "utf8");

test("generateCert() keeps the existing certificate when it is already on disk", async () => {
await withTempDataDir(async () => {
const { generateCert } = await import(certModule);

const first = await generateCert();
const certBefore = read(first.cert);
const keyBefore = read(first.key);

const second = await generateCert();

assert.equal(second.cert, first.cert, "path should be stable");
assert.equal(read(second.cert), certBefore, "certificate should not be replaced");
assert.equal(read(second.key), keyBefore, "key should not be replaced");
});
});

test("generateCert({ force: true }) mints a new certificate over the existing one", async () => {
await withTempDataDir(async () => {
const { generateCert } = await import(certModule);

const first = await generateCert();
const certBefore = read(first.cert);
const keyBefore = read(first.key);

const forced = await generateCert({ force: true });

assert.equal(forced.cert, first.cert, "path should be stable");
assert.notEqual(read(forced.cert), certBefore, "certificate should be replaced");
assert.notEqual(read(forced.key), keyBefore, "key should be replaced");
});
});

test("the forced certificate is still valid and carries every antigravity SAN", async () => {
await withTempDataDir(async () => {
const { generateCert } = await import(certModule);
const { ANTIGRAVITY_TARGET } = await import("../../src/mitm/targets/antigravity.ts");

await generateCert();
const forced = await generateCert({ force: true });

const x509 = new X509Certificate(fs.readFileSync(forced.cert));
const sans = (x509.subjectAltName ?? "")
.split(",")
.map((entry) => entry.trim().replace(/^DNS:/, ""))
.filter(Boolean);

for (const host of ANTIGRAVITY_TARGET.hosts) {
assert.ok(sans.includes(host), `forced cert is missing SAN for ${host}`);
}
});
});

test("generateCert({ force: true }) creates the certificate when none exists yet", async () => {
await withTempDataDir(async () => {
const { generateCert } = await import(certModule);

const result = await generateCert({ force: true });

assert.ok(fs.existsSync(result.cert), "certificate should exist");
assert.ok(fs.existsSync(result.key), "key should exist");
});
});