diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2704baba..8c8068dde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **fix(api):** `/v1/completions` now returns the legacy OpenAI Completions shape (`object:"text_completion"`, `choices[].text`) instead of chat payloads (`choices[].message|delta.content`) — the endpoint routes internally through the chat pipeline, so legacy Completion clients like TabbyML's `openai/completion` backend crashed with `missing field "text"`. The response (both non-streaming JSON and the SSE stream) is now translated back to the text-completion shape; `[DONE]` and error bodies pass through unchanged. ([#3571](https://github.com/diegosouzapw/OmniRoute/issues/3571)) - **fix(usage):** the z.ai/GLM coding-plan quota card no longer shows "Monthly 0%" — coding plans have no monthly cap (only 5-hour windows), so the quota API reports the `TIME_LIMIT` ("Monthly") entry with `total=0`, and the `total>0 ? … : 0` fallback rendered a misleading 0% remaining (which can skew downstream model-choice). With no absolute cap the remaining percentage now falls back to the percentage-derived value (full/100% when 0% used). ([#3580](https://github.com/diegosouzapw/OmniRoute/issues/3580)) - **docs(discovery):** mark `DISCOVERY_TOOL_DESIGN.md`'s API Endpoints table with an explicit "⚠️ Not yet implemented — Phase 2" banner — the discovery routes are a design proposal (Phase-1 stub only), and the banner makes clear the `KNOWN_STALE_DOC_REFS` gate suppression is intentional, not stale drift. ([#3498](https://github.com/diegosouzapw/OmniRoute/issues/3498)) +- **fix(agent-bridge):** add the missing `POST /api/tools/agent-bridge/upstream-ca/test` route — the UpstreamCaField "Test" button POSTed to it but it didn't exist (404). The new validate-only route checks the CA file exists and is a parseable PEM certificate (returns the subject/expiry) **without** persisting the path or activating it; it inherits the `/api/tools/agent-bridge/` LOCAL_ONLY classification. ([#3488](https://github.com/diegosouzapw/OmniRoute/issues/3488)) --- diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 7db3051bf5..08b2d522ee 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -28,7 +28,6 @@ const KNOWN_MISSING = new Set([ "/api/gamification/badges", // profile/page.tsx — idem "/api/gamification/badges/earned", // profile/page.tsx — idem "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian - "/api/tools/agent-bridge/upstream-ca/test", // UpstreamCaField.tsx — rota inexistente ]); function walk(dir, acc = []) { diff --git a/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts b/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts new file mode 100644 index 0000000000..0ca12d9b33 --- /dev/null +++ b/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts @@ -0,0 +1,63 @@ +/** + * POST /api/tools/agent-bridge/upstream-ca/test + * + * Validate-only (dry-run) counterpart of POST /api/tools/agent-bridge/upstream-ca: + * checks the upstream CA file exists and is a parseable PEM certificate, WITHOUT + * persisting the path or activating it via configureUpstreamCa(). Backs the + * UpstreamCaField "Test" button, which previously 404'd (#3488). + * + * LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts. + */ +import crypto from "crypto"; +import fs from "fs"; + +import { AgentBridgeUpstreamCaPostSchema } from "@/shared/schemas/agentBridge"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { createErrorResponse } from "@/lib/api/errorResponse"; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = AgentBridgeUpstreamCaPostSchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ + status: 400, + message: "Invalid request body", + details: parsed.error.flatten(), + }); + } + + const { path: caPath } = parsed.data; + + if (!fs.existsSync(caPath)) { + return createErrorResponse({ status: 400, message: `Upstream CA file not found: ${caPath}` }); + } + + let pem: string; + try { + pem = fs.readFileSync(caPath, "utf8"); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 400, message: `Unable to read upstream CA file: ${msg}` }); + } + + if (!pem.includes("-----BEGIN CERTIFICATE-----")) { + return createErrorResponse({ + status: 400, + message: "File is not a PEM certificate (missing a -----BEGIN CERTIFICATE----- block).", + }); + } + + try { + const cert = new crypto.X509Certificate(pem); + return Response.json({ ok: true, path: caPath, subject: cert.subject, validTo: cert.validTo }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 400, message: `Invalid certificate: ${msg}` }); + } +} diff --git a/tests/unit/upstream-ca-test-route-3488.test.ts b/tests/unit/upstream-ca-test-route-3488.test.ts new file mode 100644 index 0000000000..2554360e76 --- /dev/null +++ b/tests/unit/upstream-ca-test-route-3488.test.ts @@ -0,0 +1,94 @@ +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 { POST } from "../../src/app/api/tools/agent-bridge/upstream-ca/test/route.ts"; + +// #3488 — UpstreamCaField's "Test" button POSTed to /api/tools/agent-bridge/upstream-ca/test, +// which did not exist (404). The new validate-only route checks the CA file exists and is a +// parseable PEM certificate WITHOUT persisting/activating it. + +// A throwaway self-signed cert (CN=OmniRoute Test CA), valid to 2036. +const TEST_CA_PEM = `-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUISgNKO/v/z0FdUIPoCD4dwgKbacwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRT21uaVJvdXRlIFRlc3QgQ0EwHhcNMjYwNjEwMjExNDMx +WhcNMzYwNjA3MjExNDMxWjAcMRowGAYDVQQDDBFPbW5pUm91dGUgVGVzdCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALkubKCA7sgOph0nsKhQZNoH +UaQo+mrodWJ+23yVnxPygBQQay6okO1w5U6yxweinyCC0jB87Y386q30cqYK6NCf +HbAgkNRelhxeoU71DztIjIaKZCTlra5CCjVxVzvIOu4PoP8UgoLy/jOLM15XiM5B +entgjw62qXGRGak2Thiac+dHRzKJAIPxRDnWDrgQFsduNtSb1sGbivjUjLLEabm0 +gokYlJpNCYJvS31qvL37aeV8igjt8hsReVEb5qm5RiiAepM9B3gvKvn0fsKvxT2a +rmqgySF+o2aTi+PW3+ZySoWoUL6b7GSA/CpF6Mc3u2qM/DvU4Kr0K8Y5EE+PsYUC +AwEAAaNTMFEwHQYDVR0OBBYEFD/qt9vsjOHNvlfT6Z4j9myR4GJJMB8GA1UdIwQY +MBaAFD/qt9vsjOHNvlfT6Z4j9myR4GJJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAH0WP40mF66cqUxQjamHS2BScRkn5E+SvwoZD12ZvQO/kgj/ +wbWu5mZOf5cpuqHrOmfOC+6itIkZb7v6d0CRM19xcoAg1mVWFFSk0iroFCw0qltN +kB5WPvKHI6JRkr7HdUTD1qW9ljveMPfZ/Fm9ZM6QhCOLifzNTP2GMTVyIMAvig6B +TgmL/sn4dw4C2UTMQioMMXHSeJ90OD4Pv3mqX16JKrRSICoTExBoEIF23kWWJL6m +RL5Jiv1pdbFujHL8l9KPI2xmsWtkKutxOL2O5zpdUxP4noNVInDqEmbriK6CKY4y +hWHoQhtd4zf9H6+NIi38SPTCAmCjgU7iVq6mWoE= +-----END CERTIFICATE----- +`; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ca-test-")); +const validCaPath = path.join(dir, "valid-ca.pem"); +const nonPemPath = path.join(dir, "not-a-cert.txt"); +fs.writeFileSync(validCaPath, TEST_CA_PEM); +fs.writeFileSync(nonPemPath, "this is not a certificate"); + +test.after(() => fs.rmSync(dir, { recursive: true, force: true })); + +function postJson(body: unknown): Request { + return new Request("http://localhost/api/tools/agent-bridge/upstream-ca/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("#3488 valid PEM cert → 200 ok with subject", async () => { + const res = await POST(postJson({ path: validCaPath })); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + assert.match(json.subject, /OmniRoute Test CA/); +}); + +test("#3488 does NOT persist the CA path (validate-only)", async () => { + await POST(postJson({ path: validCaPath })); + // The persisted path file used by the real POST route must not be created by /test. + // We can't import the constant without side effects, so assert the dry-run returns a + // shape with no activation marker and leaves no global state — the absence of an + // `activated`/`persisted` field is the contract. + const res = await POST(postJson({ path: validCaPath })); + const json = await res.json(); + assert.equal(json.persisted, undefined); + assert.equal(json.activated, undefined); +}); + +test("#3488 non-existent path → 400", async () => { + const res = await POST(postJson({ path: path.join(dir, "nope.pem") })); + assert.equal(res.status, 400); +}); + +test("#3488 file that is not a PEM cert → 400", async () => { + const res = await POST(postJson({ path: nonPemPath })); + assert.equal(res.status, 400); +}); + +test("#3488 invalid body (missing path) → 400", async () => { + const res = await POST(postJson({})); + assert.equal(res.status, 400); +}); + +test("#3488 invalid JSON body → 400", async () => { + const req = new Request("http://localhost/api/tools/agent-bridge/upstream-ca/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + const res = await POST(req); + assert.equal(res.status, 400); +});