import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { buildAllowedHosts, isLoopbackHost, requireInboundBearer, resolveViewerHost, startViewerServer, ViewerConfigError, } from "../src/viewer/server.js"; describe("resolveViewerHost", () => { const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; afterEach(() => { if (originalEnv === undefined) { delete process.env.AGENTMEMORY_VIEWER_HOST; } else { process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; } }); it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is unset", () => { delete process.env.AGENTMEMORY_VIEWER_HOST; expect(resolveViewerHost()).toBe("127.0.0.1"); }); it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is empty", () => { process.env.AGENTMEMORY_VIEWER_HOST = ""; expect(resolveViewerHost()).toBe("127.0.0.1"); }); it("returns the configured value when AGENTMEMORY_VIEWER_HOST is set", () => { process.env.AGENTMEMORY_VIEWER_HOST = "::"; expect(resolveViewerHost()).toBe("::"); }); it("trims surrounding whitespace", () => { process.env.AGENTMEMORY_VIEWER_HOST = " ::1 "; expect(resolveViewerHost()).toBe("::1"); }); }); describe("isLoopbackHost", () => { it.each([ ["127.0.0.1", true], ["::1", true], ["localhost", true], [" 127.0.0.1 ", true], ["LOCALHOST", true], ["::", false], ["0.0.0.0", false], ["10.0.0.1", false], ["fly-local-6pn", false], ])("classifies %s as loopback=%s", (host, expected) => { expect(isLoopbackHost(host)).toBe(expected); }); }); describe("buildAllowedHosts", () => { const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; afterEach(() => { if (originalOverride === undefined) { delete process.env.VIEWER_ALLOWED_HOSTS; } else { process.env.VIEWER_ALLOWED_HOSTS = originalOverride; } }); it("seeds loopback defaults and CORS origins when bind is loopback", () => { delete process.env.VIEWER_ALLOWED_HOSTS; const allowed = buildAllowedHosts( ["http://localhost:3111", "http://127.0.0.1:3111"], 3113, "127.0.0.1", ); expect(allowed.has("localhost:3113")).toBe(true); expect(allowed.has("127.0.0.1:3113")).toBe(true); expect(allowed.has("[::1]:3113")).toBe(true); expect(allowed.has("localhost:3111")).toBe(true); expect(allowed.has("127.0.0.1:3111")).toBe(true); }); it("drops loopback defaults when bind is non-loopback, leaving only VIEWER_ALLOWED_HOSTS", () => { process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com,localhost:3113"; const allowed = buildAllowedHosts( ["http://localhost:3111", "http://127.0.0.1:3111"], 3113, "::", ); expect(allowed.has("viewer.example.com")).toBe(true); expect(allowed.has("localhost:3113")).toBe(true); // came in via the override // Origin-derived loopback hostnames must not silently land in the // allowlist when bind is non-loopback — otherwise Host header // spoofing reopens the very gap the override is meant to close. expect(allowed.has("localhost:3111")).toBe(false); expect(allowed.has("127.0.0.1:3111")).toBe(false); expect(allowed.has("127.0.0.1:3113")).toBe(false); expect(allowed.has("[::1]:3113")).toBe(false); }); it("returns an empty set when bind is non-loopback and the override is empty", () => { delete process.env.VIEWER_ALLOWED_HOSTS; const allowed = buildAllowedHosts( ["http://localhost:3111"], 3113, "0.0.0.0", ); expect(allowed.size).toBe(0); }); }); describe("requireInboundBearer", () => { const secret = "s3cr3t-bearer-value"; it("accepts a matching Bearer token", () => { expect(requireInboundBearer(`Bearer ${secret}`, secret)).toBe(true); }); it("accepts case-insensitive scheme", () => { expect(requireInboundBearer(`bearer ${secret}`, secret)).toBe(true); }); it("rejects a mismatching Bearer token", () => { expect(requireInboundBearer(`Bearer wrong-token`, secret)).toBe(false); }); it("rejects a missing header", () => { expect(requireInboundBearer(undefined, secret)).toBe(false); }); it("rejects a non-Bearer scheme", () => { expect(requireInboundBearer(`Basic ${secret}`, secret)).toBe(false); }); it("rejects an array Authorization header", () => { expect(requireInboundBearer([`Bearer ${secret}`], secret)).toBe(false); }); }); describe("startViewerServer host binding", () => { const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; let server: Server | undefined; let logSpy: ReturnType; let warnSpy: ReturnType; beforeEach(() => { logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(async () => { if (server) { await new Promise((resolve) => server!.close(() => resolve())); server = undefined; } logSpy.mockRestore(); warnSpy.mockRestore(); if (originalEnv === undefined) { delete process.env.AGENTMEMORY_VIEWER_HOST; } else { process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; } if (originalOverride === undefined) { delete process.env.VIEWER_ALLOWED_HOSTS; } else { process.env.VIEWER_ALLOWED_HOSTS = originalOverride; } }); async function waitForListening(s: Server): Promise { if (s.listening) return; await new Promise((resolve) => s.once("listening", () => resolve())); } it("binds to 127.0.0.1 by default — preserves loopback-only security", async () => { delete process.env.AGENTMEMORY_VIEWER_HOST; server = startViewerServer(0, null, null); await waitForListening(server); const addr = server.address() as AddressInfo; expect(addr.address).toBe("127.0.0.1"); }); it("binds to AGENTMEMORY_VIEWER_HOST when set — covers the deploy/fly fix for #434", async () => { process.env.AGENTMEMORY_VIEWER_HOST = "::1"; server = startViewerServer(0, null, null); await waitForListening(server); const addr = server.address() as AddressInfo; expect(addr.address).toBe("::1"); }); it("refuses to start when bind is non-loopback and no AGENTMEMORY_SECRET is configured", () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com"; expect(() => startViewerServer(0, null, null)).toThrow(ViewerConfigError); expect(() => startViewerServer(0, null, null)).toThrow( /unset AGENTMEMORY_VIEWER_HOST/, ); expect(() => startViewerServer(0, null, null)).toThrow( /set AGENTMEMORY_SECRET/, ); }); it("refuses to start when bind is non-loopback and VIEWER_ALLOWED_HOSTS is empty", () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; delete process.env.VIEWER_ALLOWED_HOSTS; expect(() => startViewerServer(0, null, null, "test-secret"), ).toThrow(ViewerConfigError); expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( /set VIEWER_ALLOWED_HOSTS/, ); expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( /unset AGENTMEMORY_VIEWER_HOST/, ); }); it("returns 401 for non-Bearer API calls when bind is non-loopback", async () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; // Pre-seed an entry so refuse-start passes. The request-time // buildAllowedHosts call will re-read the env, so we widen it after // the port is known. process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; const secret = "test-secret-xyz"; server = startViewerServer(0, null, null, secret); await waitForListening(server); const addr = server.address() as AddressInfo; process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; const unauthed = await fetch( `http://127.0.0.1:${addr.port}/agentmemory/livez`, ); expect(unauthed.status).toBe(401); const wrongBearer = await fetch( `http://127.0.0.1:${addr.port}/agentmemory/livez`, { headers: { Authorization: "Bearer wrong-token" } }, ); expect(wrongBearer.status).toBe(401); // Correct bearer reaches the proxy. Upstream is not running in // this test, so we expect a non-401 status (the proxy will fail // with 502/504) — the key invariant is that the auth gate let it // through. const goodBearer = await fetch( `http://127.0.0.1:${addr.port}/agentmemory/livez`, { headers: { Authorization: `Bearer ${secret}` } }, ); expect(goodBearer.status).not.toBe(401); }); it("does not require inbound auth on the loopback default bind", async () => { delete process.env.AGENTMEMORY_VIEWER_HOST; delete process.env.VIEWER_ALLOWED_HOSTS; server = startViewerServer(0, null, null, "test-secret-xyz"); await waitForListening(server); const addr = server.address() as AddressInfo; const res = await fetch( `http://127.0.0.1:${addr.port}/agentmemory/livez`, ); // No 401: the loopback bind keeps the legacy behaviour where any // local process is implicitly trusted. Upstream is not running, so // we expect a proxy-error status, just not the auth gate. expect(res.status).not.toBe(401); }); it("serves HTML at / on non-loopback bind without requiring a Bearer", async () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; server = startViewerServer(0, null, null, "test-secret-xyz"); await waitForListening(server); const addr = server.address() as AddressInfo; process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; const res = await fetch(`http://127.0.0.1:${addr.port}/`); // The HTML shell stays unauthenticated so a browser can fetch it; // the embedded JS still needs the bearer for the data calls. expect(res.status).not.toBe(401); }); it("logs non-loopback bind mode and inbound auth requirements", async () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113,[::1]:3113"; server = startViewerServer(0, null, null, "test-secret-xyz"); await waitForListening(server); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("bound to 0.0.0.0; inbound Bearer required"), ); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("allowed Host headers: localhost:3113, [::1]:3113"), ); }); it("does not retry EADDRINUSE when bind is non-loopback", async () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113"; const blocker = createServer((_req, res) => res.end("busy")); await new Promise((resolve) => blocker.listen(0, "0.0.0.0", resolve)); const blockedPort = (blocker.address() as AddressInfo).port; try { const viewer = startViewerServer( blockedPort, null, null, "test-secret-xyz", ); const err = await new Promise((resolve) => viewer.once("error", resolve), ); expect(err.code).toBe("EADDRINUSE"); await new Promise((resolve) => setTimeout(resolve, 20)); expect(viewer.listening).toBe(false); expect(logSpy).not.toHaveBeenCalledWith( expect.stringContaining(`fallback from ${blockedPort}`), ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("not retrying because non-loopback viewer binds"), ); } finally { await new Promise((resolve) => blocker.close(() => resolve())); } }); });