diff --git a/deploy/fly/Dockerfile b/deploy/fly/Dockerfile index 89257f93..22cf0aa2 100644 --- a/deploy/fly/Dockerfile +++ b/deploy/fly/Dockerfile @@ -26,6 +26,7 @@ RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overr && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ + AGENTMEMORY_VIEWER_HOST=:: \ TINI_SUBREAPER=1 COPY --chmod=0755 entrypoint.sh /usr/local/bin/agentmemory-entrypoint.sh diff --git a/deploy/fly/README.md b/deploy/fly/README.md index 020d9751..9f6194d0 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -75,6 +75,11 @@ fly proxy 3113:3113 --app "$APP" viewer's bearer token still has to ride a loopback connection on your laptop — the v0.9.12 plaintext-bearer guard stays satisfied. +The deploy image sets `AGENTMEMORY_VIEWER_HOST=::` so the viewer listens +on the machine's `fly-local-6pn` WireGuard interface as well as loopback. +Without that override the viewer would bind to `127.0.0.1` only and +`fly proxy` would reset the connection. + ## Rotate the HMAC secret ```bash diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 533d4bb4..9674d143 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -13,6 +13,10 @@ const ALLOWED_ORIGINS = ( .split(",") .map((o) => o.trim()); +export function resolveViewerHost(): string { + return process.env.AGENTMEMORY_VIEWER_HOST?.trim() || "127.0.0.1"; +} + function corsHeaders(req: IncomingMessage): Record { const origin = req.headers.origin || ""; const allowed = ALLOWED_ORIGINS.includes(origin) @@ -117,9 +121,10 @@ export function startViewerServer( let attempt = 0; let currentPort = requestedPort; + const host = resolveViewerHost(); const tryListen = (): void => { - server.listen(currentPort, "127.0.0.1"); + server.listen(currentPort, host); }; server.on("listening", () => { diff --git a/test/viewer-host.test.ts b/test/viewer-host.test.ts new file mode 100644 index 00000000..0a1fad38 --- /dev/null +++ b/test/viewer-host.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { resolveViewerHost, startViewerServer } 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("startViewerServer host binding", () => { + const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; + let server: Server | undefined; + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + logSpy.mockRestore(); + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_VIEWER_HOST; + } else { + process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; + } + }); + + 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"); + }); +});