diff --git a/deploy/fly/README.md b/deploy/fly/README.md
index 020d9751..d7d87513 100644
--- a/deploy/fly/README.md
+++ b/deploy/fly/README.md
@@ -51,7 +51,15 @@ fly logs --app "$APP" | grep -A1 AGENTMEMORY_SECRET=
You will see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`.
Copy it into your client environment (`~/.bashrc`, Claude Desktop config,
-etc.). The secret is never printed again on subsequent boots.
+the viewer unlock prompt, etc.). The secret is never printed again on
+subsequent boots.
+
+If the first-boot log line is no longer available, read the persisted
+secret from the mounted volume:
+
+```bash
+fly ssh console --app "$APP" -C "sh -lc 'cat /data/.hmac'"
+```
## Verify the deployment
@@ -75,6 +83,32 @@ 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 entrypoint sets `AGENTMEMORY_VIEWER_HOST=::` **only when it detects
+Fly's runtime variables** (`FLY_APP_NAME` / `FLY_ALLOC_ID`). That makes
+the viewer listen on the machine's `fly-local-6pn` WireGuard interface
+as well as loopback so `fly proxy` can reach it. The same branch
+preseeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`,
+which are the Host headers `fly proxy 3113:3113` actually emits on
+your laptop.
+
+When `AGENTMEMORY_VIEWER_HOST` is non-loopback the viewer enforces two
+extra guards: it refuses to start unless `VIEWER_ALLOWED_HOSTS` is
+explicitly set, and every request to `/agentmemory/*` must present
+`Authorization: Bearer $AGENTMEMORY_SECRET`. Static HTML and the
+favicon are still served unauthenticated. If a proxied viewer request
+gets a 401, the browser UI prompts for `AGENTMEMORY_SECRET` and stores
+it in session storage so subsequent viewer API calls include the bearer.
+Use the value printed in the first-boot logs or read `/data/.hmac`
+inside the machine.
+
+> **Security warning.** Setting `AGENTMEMORY_VIEWER_HOST=0.0.0.0` or
+> `::` turns the viewer into a network-reachable proxy that signs every
+> upstream call with `AGENTMEMORY_SECRET`. Never enable that outside a
+> network you trust (Fly's WireGuard mesh in this template), and never
+> set it in a plain `docker run -p 3113:3113 …` on a shared host — the
+> entrypoint deliberately skips the override when Fly env vars are
+> absent so a plain Docker pull stays loopback-only.
+
## Rotate the HMAC secret
```bash
diff --git a/deploy/fly/entrypoint.sh b/deploy/fly/entrypoint.sh
index ffdd6333..5fd4cc26 100755
--- a/deploy/fly/entrypoint.sh
+++ b/deploy/fly/entrypoint.sh
@@ -95,4 +95,18 @@ fi
AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")"
export AGENTMEMORY_SECRET
+# The viewer's default 127.0.0.1 bind is unreachable through fly proxy,
+# which enters the machine via fly-local-6pn (IPv6). Opt into a
+# non-loopback bind ONLY when we're actually inside Fly (detected via
+# Fly's runtime variables). A plain `docker run` of this image will not
+# see these variables and will keep the safe-by-default loopback bind,
+# so it can't silently expose the viewer's bearer-authorized proxy to
+# the LAN. VIEWER_ALLOWED_HOSTS is preseeded to the Host headers that
+# `fly proxy 3113:3113` actually produces on the operator's laptop.
+if [ -n "${FLY_APP_NAME:-}" ] || [ -n "${FLY_ALLOC_ID:-}" ]; then
+ : "${AGENTMEMORY_VIEWER_HOST:=::}"
+ : "${VIEWER_ALLOWED_HOSTS:=localhost:3113,127.0.0.1:3113,[::1]:3113}"
+ export AGENTMEMORY_VIEWER_HOST VIEWER_ALLOWED_HOSTS
+fi
+
exec gosu "$RUN_AS" agentmemory "$@"
diff --git a/src/viewer/index.html b/src/viewer/index.html
index c2c200b8..d5d3824d 100644
--- a/src/viewer/index.html
+++ b/src/viewer/index.html
@@ -711,6 +711,46 @@
.flag-close:hover,
.flag-close:focus-visible { color: var(--ink); outline: 2px solid var(--border); outline-offset: 1px; }
+ .viewer-auth {
+ display: none;
+ padding: 0 24px 10px 24px;
+ background: var(--bg);
+ flex: 0 0 auto;
+ position: relative;
+ z-index: 1;
+ }
+ .viewer-auth.open { display: block; }
+ .viewer-auth-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto;
+ gap: 10px;
+ align-items: center;
+ width: min(960px, 100%);
+ max-width: 960px;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
+ background: var(--bg-subtle);
+ font-family: var(--font-ui);
+ font-size: 12px;
+ }
+ .viewer-auth-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; }
+ .viewer-auth-desc { color: var(--ink-muted); line-height: 1.4; }
+ .viewer-auth-desc code { font-family: var(--font-mono); font-size: 10px; color: var(--ink); }
+ .viewer-auth input {
+ width: 100%;
+ min-width: 0;
+ padding: 7px 9px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ }
+ @media (max-width: 900px) {
+ .viewer-auth-panel { grid-template-columns: 1fr; }
+ }
+
/* Viewer footer */
.viewer-footer {
margin-top: 48px; padding: 16px 0 24px;
@@ -961,6 +1001,7 @@
agentmemory
+
@@ -1063,6 +1104,7 @@ agentmemory
};
var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay'];
+ var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token';
var state = {
activeTab: 'dashboard',
@@ -1159,17 +1201,54 @@ agentmemory
try { el.setSelectionRange(focus.start, focus.end); } catch (e) {}
}
}
+ function getViewerToken() {
+ try { return sessionStorage.getItem(VIEWER_TOKEN_STORAGE_KEY) || ''; } catch (_) { return ''; }
+ }
+ function setViewerToken(token) {
+ try {
+ if (token) sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, token);
+ else sessionStorage.removeItem(VIEWER_TOKEN_STORAGE_KEY);
+ } catch (_) {}
+ }
+ function showViewerAuthPrompt() {
+ var host = document.getElementById('viewer-auth');
+ if (!host) return;
+ host.classList.add('open');
+ host.innerHTML =
+ '' +
+ '
' +
+ '
Viewer authorization required
' +
+ '
Enter AGENTMEMORY_SECRET to unlock viewer API access.
' +
+ '
' +
+ '
' +
+ '
Unlock ' +
+ '
';
+ var input = document.getElementById('viewer-auth-token');
+ if (input && typeof input.focus === 'function') input.focus();
+ }
+ function hideViewerAuthPrompt() {
+ var host = document.getElementById('viewer-auth');
+ if (!host) return;
+ host.classList.remove('open');
+ host.innerHTML = '';
+ }
async function api(path, opts) {
try {
var url = REST + '/agentmemory/' + path;
var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {});
+ var viewerToken = getViewerToken();
+ if (viewerToken && !headers.Authorization && !headers.authorization) {
+ headers.Authorization = 'Bearer ' + viewerToken;
+ }
var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
var res = await fetch(url, fetchOpts);
if (!res.ok) {
+ if (res.status === 401) showViewerAuthPrompt();
console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
return null;
}
+ hideViewerAuthPrompt();
return await res.json();
} catch (err) {
console.warn('[viewer] API error on ' + path + ':', err);
@@ -3710,6 +3789,19 @@ agentmemory
if (memoryId) confirmDeleteMemory(memoryId);
return;
}
+ if (action === 'save-viewer-token') {
+ var tokenInput = document.getElementById('viewer-auth-token');
+ var token = tokenInput ? tokenInput.value.trim() : '';
+ if (token) {
+ setViewerToken(token);
+ hideViewerAuthPrompt();
+ if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
+ state[state.activeTab].loaded = false;
+ }
+ loadTab(state.activeTab);
+ }
+ return;
+ }
if (action === 'timeline-filter') {
setTlTypeFilter(target.getAttribute('data-type-filter') || '');
return;
diff --git a/src/viewer/server.ts b/src/viewer/server.ts
index 71598690..3e73068d 100644
--- a/src/viewer/server.ts
+++ b/src/viewer/server.ts
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { renderViewerDocument } from "./document.js";
+import { timingSafeCompare } from "../auth.js";
// Self-host the viewer favicon at /favicon.svg instead of an inline
// data: URI so the viewer CSP can stay tight at `img-src 'self'`.
@@ -47,30 +48,52 @@ const ALLOWED_ORIGINS = (
//
// Explicit override via VIEWER_ALLOWED_HOSTS for the rare case of a
// reverse-proxy in front of the viewer; defaults are computed from the
-// listen port at server-create time.
-const ALLOWED_HOSTS_OVERRIDE = (process.env.VIEWER_ALLOWED_HOSTS || "")
- .split(",")
- .map((h) => h.trim().toLowerCase())
- .filter(Boolean);
+// listen port at server-create time. Read at call time so tests and
+// runtime overrides can rotate the value without a module reload.
+function readAllowedHostsOverride(): string[] {
+ return (process.env.VIEWER_ALLOWED_HOSTS || "")
+ .split(",")
+ .map((h) => h.trim().toLowerCase())
+ .filter(Boolean);
+}
+
+export function resolveViewerHost(): string {
+ return process.env.AGENTMEMORY_VIEWER_HOST?.trim() || "127.0.0.1";
+}
+
+export function isLoopbackHost(host: string): boolean {
+ const h = host.trim().toLowerCase();
+ return h === "127.0.0.1" || h === "::1" || h === "localhost";
+}
export function buildAllowedHosts(
origins: string[],
listenPort: number,
+ bindHost: string = "127.0.0.1",
): Set {
const hosts = new Set();
- for (const o of origins) {
- try {
- const parsed = new URL(o);
- if (parsed.host) hosts.add(parsed.host.toLowerCase());
- } catch {
- // Skip invalid origin entries — the existing CORS path already
- // tolerates them by simply not matching; mirror that here.
+ // When bind is loopback the listening socket is unreachable from the
+ // network, so it's safe to seed the allowlist from the CORS origins
+ // (which by default are localhost-based) plus the standard loopback
+ // hostnames on the actual listen port. When bind is non-loopback the
+ // listening socket is reachable from anywhere TCP can reach the
+ // process, and any of those loopback names becomes a spoofable Host
+ // header — so only explicit VIEWER_ALLOWED_HOSTS entries are trusted.
+ if (isLoopbackHost(bindHost)) {
+ for (const o of origins) {
+ try {
+ const parsed = new URL(o);
+ if (parsed.host) hosts.add(parsed.host.toLowerCase());
+ } catch {
+ // Skip invalid origin entries — the existing CORS path already
+ // tolerates them by simply not matching; mirror that here.
+ }
}
+ hosts.add(`localhost:${listenPort}`);
+ hosts.add(`127.0.0.1:${listenPort}`);
+ hosts.add(`[::1]:${listenPort}`);
}
- hosts.add(`localhost:${listenPort}`);
- hosts.add(`127.0.0.1:${listenPort}`);
- hosts.add(`[::1]:${listenPort}`);
- for (const h of ALLOWED_HOSTS_OVERRIDE) hosts.add(h);
+ for (const h of readAllowedHostsOverride()) hosts.add(h);
return hosts;
}
@@ -84,6 +107,21 @@ export function isHostAllowed(
return allowed.has(lower);
}
+// When bind is non-loopback the viewer is a bearer-authorized proxy
+// reachable from the network, so every request that would forward
+// upstream must also present the same bearer. Static routes (HTML,
+// favicon) stay open so a browser can fetch the shell — the JS inside
+// then has to provide a bearer for the API calls.
+export function requireInboundBearer(
+ authHeader: string | string[] | undefined,
+ secret: string,
+): boolean {
+ if (typeof authHeader !== "string") return false;
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(authHeader);
+ if (!match) return false;
+ return timingSafeCompare(match[1], secret);
+}
+
function corsHeaders(req: IncomingMessage): Record {
const origin = req.headers.origin || "";
const allowed = ALLOWED_ORIGINS.includes(origin)
@@ -141,6 +179,13 @@ export function getViewerSkipped(): boolean {
return viewerSkipped;
}
+export class ViewerConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ViewerConfigError";
+ }
+}
+
export function startViewerServer(
port: number,
_kv: unknown,
@@ -154,6 +199,28 @@ export function startViewerServer(
const resolvedRestPort = restPort ?? port - 2;
const requestedPort = port;
+ const host = resolveViewerHost();
+ let inboundSecret: string | null = null;
+
+ // Non-loopback bind turns the viewer into a network-reachable
+ // bearer-authorized proxy. Refuse to start unless the operator has
+ // both an inbound secret to authenticate callers against and an
+ // explicit Host header allowlist; otherwise the listening socket
+ // becomes an open relay to the local REST API.
+ if (!isLoopbackHost(host)) {
+ if (!secret) {
+ throw new ViewerConfigError(
+ `AGENTMEMORY_VIEWER_HOST=${host} requires AGENTMEMORY_SECRET to be set so the viewer can validate inbound bearer tokens. To fix: unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind, or set AGENTMEMORY_SECRET. For Fly images, it is printed on first boot; see deploy/fly/README.md.`,
+ );
+ }
+ if (readAllowedHostsOverride().length === 0) {
+ throw new ViewerConfigError(
+ `AGENTMEMORY_VIEWER_HOST=${host} requires VIEWER_ALLOWED_HOSTS because non-loopback viewer binds only trust explicit Host headers. To fix: set VIEWER_ALLOWED_HOSTS to a comma-separated list of trusted Host header values (e.g. "localhost:3113" for fly proxy), or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`,
+ );
+ }
+ inboundSecret = secret;
+ }
+
// Computed lazily on first request — `port` may be 0 here (OS-assigned)
// or the EADDRINUSE retry loop below may bump us to a different port,
// so we read the actual bound port from server.address() on first hit.
@@ -166,7 +233,7 @@ export function startViewerServer(
addr && typeof addr === "object" && "port" in addr
? (addr.port as number)
: port;
- allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort);
+ allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort, host);
}
if (!isHostAllowed(req.headers.host, allowedHosts)) {
res.writeHead(403, { "Content-Type": "text/plain" });
@@ -225,6 +292,18 @@ export function startViewerServer(
return;
}
+ if (
+ inboundSecret !== null &&
+ !requireInboundBearer(req.headers.authorization, inboundSecret)
+ ) {
+ res.writeHead(401, {
+ "Content-Type": "text/plain",
+ "WWW-Authenticate": 'Bearer realm="agentmemory-viewer"',
+ });
+ res.end("unauthorized");
+ return;
+ }
+
try {
await proxyToRestApi(resolvedRestPort, pathname, qs, method, req, res, secret);
} catch (err) {
@@ -237,7 +316,7 @@ export function startViewerServer(
let currentPort = requestedPort;
const tryListen = (): void => {
- server.listen(currentPort, "127.0.0.1");
+ server.listen(currentPort, host);
};
server.on("listening", () => {
@@ -247,6 +326,13 @@ export function startViewerServer(
? addr.port
: currentPort;
viewerSkipped = false;
+ if (inboundSecret !== null) {
+ const allowedHosts = readAllowedHostsOverride().join(", ");
+ console.log(
+ `[agentmemory] Viewer: http://localhost:${currentPort} (bound to ${host}; inbound Bearer required; allowed Host headers: ${allowedHosts})`,
+ );
+ return;
+ }
if (currentPort === requestedPort) {
console.log(`[agentmemory] Viewer: http://localhost:${currentPort}`);
} else {
@@ -257,7 +343,11 @@ export function startViewerServer(
});
server.on("error", (err: NodeJS.ErrnoException) => {
- if (err.code === "EADDRINUSE" && attempt < MAX_VIEWER_PORT_RETRIES) {
+ if (
+ err.code === "EADDRINUSE" &&
+ inboundSecret === null &&
+ attempt < MAX_VIEWER_PORT_RETRIES
+ ) {
attempt++;
currentPort = requestedPort + attempt;
setImmediate(tryListen);
@@ -266,9 +356,15 @@ export function startViewerServer(
if (err.code === "EADDRINUSE") {
boundViewerPort = null;
viewerSkipped = true;
- console.warn(
- `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`,
- );
+ if (inboundSecret !== null) {
+ console.warn(
+ `[agentmemory] Viewer port ${requestedPort} is in use while bound to ${host}; not retrying because non-loopback viewer binds require VIEWER_ALLOWED_HOSTS to match the exact port. Free the port, choose another viewer port, or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`,
+ );
+ } else {
+ console.warn(
+ `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`,
+ );
+ }
} else {
boundViewerPort = null;
viewerSkipped = true;
diff --git a/test/viewer-host.test.ts b/test/viewer-host.test.ts
new file mode 100644
index 00000000..b68ecec4
--- /dev/null
+++ b/test/viewer-host.test.ts
@@ -0,0 +1,330 @@
+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()));
+ }
+ });
+});
diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts
index 9938a753..a671df88 100644
--- a/test/viewer-session-id.test.ts
+++ b/test/viewer-session-id.test.ts
@@ -135,6 +135,14 @@ function loadViewerSandbox() {
search: "",
},
localStorage: { getItem: () => null, setItem: () => {} },
+ sessionStorage: (() => {
+ const values = new Map();
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ };
+ })(),
fetch: async () => ({ ok: true, json: async () => ({}) }),
WebSocket: function WebSocket() {},
navigator: { userAgent: "vitest" },
@@ -169,6 +177,35 @@ function loadViewerSandbox() {
}
describe("viewer session rendering", () => {
+ it("attaches the saved viewer bearer to API calls", async () => {
+ const { sandbox } = loadViewerSandbox();
+ const requests: Array<{ url: string; opts: { headers?: Record } }> = [];
+ sandbox.sessionStorage.setItem("agentmemory-viewer-token", "viewer-secret");
+ sandbox.fetch = async (url: string, opts: { headers?: Record }) => {
+ requests.push({ url, opts });
+ return { ok: true, json: async () => ({ ok: true }) };
+ };
+
+ await sandbox.apiGet("health");
+
+ expect(requests).toHaveLength(1);
+ expect(requests[0].opts.headers?.Authorization).toBe("Bearer viewer-secret");
+ });
+
+ it("shows where to find AGENTMEMORY_SECRET after a viewer auth failure", async () => {
+ const { sandbox, getElement } = loadViewerSandbox();
+ sandbox.fetch = async () => ({ ok: false, status: 401, json: async () => ({}) });
+
+ await sandbox.apiGet("health");
+
+ const prompt = getElement("viewer-auth");
+ expect(prompt.classList.contains("open")).toBe(true);
+ expect(prompt.innerHTML).toContain("AGENTMEMORY_SECRET");
+ expect(prompt.innerHTML).toContain("unlock viewer API access");
+ expect(prompt.innerHTML).not.toContain("fly logs");
+ expect(prompt.innerHTML).not.toContain("/data/.hmac");
+ });
+
it("does not throw when dashboard sessions are missing ids", () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.state.dashboard = {