Skip to content
Closed
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
1 change: 1 addition & 0 deletions deploy/fly/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions deploy/fly/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/viewer/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const origin = req.headers.origin || "";
const allowed = ALLOWED_ORIGINS.includes(origin)
Expand Down Expand Up @@ -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", () => {
Expand Down
80 changes: 80 additions & 0 deletions test/viewer-host.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});

afterEach(async () => {
if (server) {
await new Promise<void>((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<void> {
if (s.listening) return;
await new Promise<void>((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");
});
});