From f353efa70a03ce0dae7ef6786aec485aef9d9609 Mon Sep 17 00:00:00 2001 From: otherview Date: Mon, 4 May 2026 15:55:16 +0100 Subject: [PATCH 1/2] feat(tower): Add TOWER_HOST env var to bind to different network interfaces --- codev/resources/arch.md | 10 +- codev/resources/commands/agent-farm.md | 3 + .../agent-farm/__tests__/tower-host.test.ts | 146 ++++++++++++++++++ .../src/agent-farm/servers/tower-server.ts | 15 +- .../src/agent-farm/utils/server-utils.ts | 46 ++++++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 packages/codev/src/agent-farm/__tests__/tower-host.test.ts diff --git a/codev/resources/arch.md b/codev/resources/arch.md index af3701bc4..c27b9f527 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -758,10 +758,16 @@ Agent Farm is designed for local development use only. Understanding the securit #### Network Binding -All services bind to `localhost` only: +All services bind to `localhost` by default: - Tower server + Dashboard + WebSocket terminals: `127.0.0.1:4100` - No external network exposure +The Tower bind address can be overridden via `TOWER_HOST` environment variable +(e.g., `TOWER_HOST=0.0.0.0` for all network interfaces). Accepted values are +`127.0.0.1` (default), `0.0.0.0`, `localhost`, valid IPv4 literals, and +bracketed IPv6 literals (e.g., `[::1]`). Hostname resolution is not supported; +only IP literals are accepted. + #### Authentication **Current approach: None (localhost assumption)** @@ -769,7 +775,7 @@ All services bind to `localhost` only: - Terminal WebSocket endpoints have no authentication - All processes share the user's permissions -**Justification**: Since all services bind to localhost, only processes running as the same user can connect. External network access is blocked at the binding level. +**Justification**: Since all services bind to localhost by default, only processes running as the same user can connect. External network access is blocked at the binding level. If `TOWER_HOST` is set to `0.0.0.0`, ensure your firewall restricts access accordingly. #### Request Validation diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index 6a565c9c7..b91c1c25d 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -460,6 +460,9 @@ afx tower start [options] **Options:** - `-p, --port ` - Port to run on (default: 4100) +**Environment Variables:** +- `TOWER_HOST` - Bind address (default: `127.0.0.1`). Set to `0.0.0.0` for all network interfaces. Accepts IP literals only (no hostnames). + #### afx tower stop Stop the tower dashboard. diff --git a/packages/codev/src/agent-farm/__tests__/tower-host.test.ts b/packages/codev/src/agent-farm/__tests__/tower-host.test.ts new file mode 100644 index 000000000..2640364fb --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/tower-host.test.ts @@ -0,0 +1,146 @@ +/** + * Integration tests for TOWER_HOST env var + * + * Verifies that the tower server respects the TOWER_HOST environment + * variable for configuring the bind address. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { startTower, cleanupTestDb } from "./helpers/tower-test-utils.js"; + +// Use a unique port range for this test suite +const PORT_DEFAULT = 14800; +const PORT_ALL_INTERFACES = 14801; +const PORT_INVALID = 14802; + +let towerDefault: Awaited> | null = null; +let towerAllInterfaces: Awaited> | null = null; +let invalidProcess: ChildProcess | null = null; + +/** + * Check if a specific host:port pair is responding + */ +async function isHostResponding(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + socket.on("error", () => { + resolve(false); + }); + socket.connect(port, host); + }); +} + +/** + * Check if a port responds on localhost + */ +async function isRespondingOnLocalhost(port: number): Promise { + return isHostResponding("127.0.0.1", port); +} + +describe("TOWER_HOST env var", () => { + beforeAll(async () => { + // Start 2 tower instances with different TOWER_HOST settings. + // Note: TOWER_HOST=localhost is NOT tested here because on macOS + // 'localhost' resolves to ::1 (IPv6) first, and the health check in + // tower-test-utils connects to 127.0.0.1 (IPv4). The unit tests + // already verify that 'localhost' passes validateHost(). + towerDefault = await startTower(PORT_DEFAULT, {}); + towerAllInterfaces = await startTower(PORT_ALL_INTERFACES, { + TOWER_HOST: "0.0.0.0", + }); + + // Try starting with an invalid host — should fail to start + const { resolve } = await import("node:path"); + const towerServerPath = resolve( + import.meta.dirname, + "../../../../dist/agent-farm/servers/tower-server.js", + ); + + const socketDir = mkdtempSync("/tmp/codev-sock-invalid-"); + invalidProcess = spawn("node", [towerServerPath, String(PORT_INVALID)], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + env: { + ...process.env, + NODE_ENV: "test", + AF_TEST_DB: `test-${PORT_INVALID}.db`, + SHELLPER_SOCKET_DIR: socketDir, + TOWER_HOST: "not-a-valid-host", + }, + }); + + // Wait for it to exit (should fail fast with validation error) + await new Promise((resolve) => { + invalidProcess!.on("exit", () => resolve()); + // Safety: kill after 5s if it somehow didn't exit + setTimeout(() => { + invalidProcess?.kill("SIGKILL"); + resolve(); + }, 5000); + }); + + try { + rmSync(socketDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }, 30000); + + afterAll(async () => { + if (towerDefault) await towerDefault.stop(); + if (towerAllInterfaces) await towerAllInterfaces.stop(); + + // Clean up test DBs + cleanupTestDb(PORT_DEFAULT); + cleanupTestDb(PORT_ALL_INTERFACES); + cleanupTestDb(PORT_INVALID); + }); + + describe("default behavior (no TOWER_HOST)", () => { + it("binds to localhost only", async () => { + // Default tower should respond on 127.0.0.1 + const responding = await isRespondingOnLocalhost(PORT_DEFAULT); + expect(responding).toBe(true); + }); + + it("responds to /api/status on localhost", async () => { + const res = await fetch(`http://127.0.0.1:${PORT_DEFAULT}/api/status`); + expect(res.ok).toBe(true); + }); + }); + + describe("TOWER_HOST=0.0.0.0", () => { + it("binds to all interfaces (responds on localhost)", async () => { + // When bound to 0.0.0.0, it should still respond on 127.0.0.1 + const responding = await isRespondingOnLocalhost(PORT_ALL_INTERFACES); + expect(responding).toBe(true); + }); + + it("responds to /api/status", async () => { + const res = await fetch( + `http://127.0.0.1:${PORT_ALL_INTERFACES}/api/status`, + ); + expect(res.ok).toBe(true); + }); + }); + + describe("invalid TOWER_HOST", () => { + it("causes tower to exit with non-zero code", () => { + const code = invalidProcess?.exitCode; + expect(code).not.toBe(0); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 55fbf0dee..7a0518b0b 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -50,6 +50,7 @@ import { import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js'; import type { RouteContext } from './tower-routes.js'; import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js'; +import { validateHost } from '../utils/server-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -76,6 +77,11 @@ const portArg = opts.port || args[0] || String(DEFAULT_TOWER_PORT); const port = parseInt(portArg, 10); const logFilePath = opts.logFile; +// Resolve bind host: TOWER_HOST env var (for containerized setups) or default to localhost. +// The spawned tower-server process inherits process.env from the afx CLI, so no CLI +// flag plumbing is needed — set TOWER_HOST=0.0.0.0 in your environment or docker-compose. +const bindHost = validateHost(process.env.TOWER_HOST || '127.0.0.1'); + // Logging utility function log(level: 'INFO' | 'ERROR' | 'WARN', message: string): void { const timestamp = new Date().toISOString(); @@ -317,9 +323,12 @@ const server = http.createServer(async (req, res) => { await handleRequest(req, res, routeCtx); }); -// SECURITY: Bind to localhost only to prevent network exposure -server.listen(port, '127.0.0.1', async () => { - log('INFO', `Tower server listening at http://localhost:${port}`); +// SECURITY: Bind to configured host (default 127.0.0.1 for localhost-only). +// Set TOWER_HOST env var to override (e.g., 0.0.0.0 for all network interfaces). +server.listen(port, bindHost, async () => { + // Display localhost in URLs for local UX even when bound to 0.0.0.0. + const displayHost = bindHost === '0.0.0.0' ? 'localhost' : bindHost; + log('INFO', `Tower server listening at http://${displayHost}:${port}`); // Initialize shellper session manager for persistent terminals const socketDir = process.env.SHELLPER_SOCKET_DIR || path.join(homedir(), '.codev', 'run'); diff --git a/packages/codev/src/agent-farm/utils/server-utils.ts b/packages/codev/src/agent-farm/utils/server-utils.ts index b7d58962e..3c7fb079b 100644 --- a/packages/codev/src/agent-farm/utils/server-utils.ts +++ b/packages/codev/src/agent-farm/utils/server-utils.ts @@ -80,3 +80,49 @@ export function parseJsonBody(req: http.IncomingMessage, maxSize = 1024 * 1024): export function isRequestAllowed(_req: http.IncomingMessage): boolean { return true; } +/** + * Validate a bind host value for server.listen(). + * + * Accepts 127.0.0.1, 0.0.0.0, localhost, valid IPv4, and bracketed IPv6. + * Returns the validated host string, or throws on invalid input. + * + * Used by tower-server.ts to resolve TOWER_HOST. + * + * @param host - The bind host string (e.g., from TOWER_HOST env var) + * @returns The validated/trimmed host string + * @throws Error with a clear message if the host is invalid + */ +export function validateHost(host: string): string { + if (!host || host.trim().length === 0) { + throw new Error( + 'Invalid bind host "". ' + + 'Accepted values: 127.0.0.1 (default), 0.0.0.0, localhost, ' + + 'or a valid IPv4/IPv6 literal.', + ); + } + const h = host.trim(); + + // Allow common literals + if (h === '127.0.0.1' || h === '0.0.0.0' || h === 'localhost') { + return h; + } + + // IPv4: four octets 0-255 + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(h)) { + const parts = h.split('.').map(Number); + if (parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255)) { + return h; + } + } + + // Bracketed IPv6 (e.g., [::1], [::]) + if (/^\[.+\]$/.test(h)) { + return h; + } + + throw new Error( + `Invalid bind host "${h}". ` + + 'Accepted values: 127.0.0.1 (default), 0.0.0.0, localhost, ' + + 'or a valid IPv4/IPv6 literal.', + ); +} From 8a6176d8d96b0ecf17979213f94a676814df05b3 Mon Sep 17 00:00:00 2001 From: otherview Date: Tue, 5 May 2026 22:44:47 +0100 Subject: [PATCH 2/2] Adding BRIDGE_MODE + BRIDGE_TOWER_HOST --- codev/resources/arch.md | 22 ++- codev/resources/commands/agent-farm.md | 3 +- .../agent-farm/__tests__/bridge-mode.test.ts | 136 ++++++++++++++++ .../agent-farm/__tests__/server-utils.test.ts | 76 +++++++++ .../agent-farm/__tests__/tower-host.test.ts | 146 ------------------ .../src/agent-farm/servers/tower-server.ts | 19 ++- .../src/agent-farm/utils/server-utils.ts | 6 +- packages/core/src/tower-client.ts | 4 +- 8 files changed, 248 insertions(+), 164 deletions(-) create mode 100644 packages/codev/src/agent-farm/__tests__/bridge-mode.test.ts delete mode 100644 packages/codev/src/agent-farm/__tests__/tower-host.test.ts diff --git a/codev/resources/arch.md b/codev/resources/arch.md index c27b9f527..503742a0a 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -762,11 +762,21 @@ All services bind to `localhost` by default: - Tower server + Dashboard + WebSocket terminals: `127.0.0.1:4100` - No external network exposure -The Tower bind address can be overridden via `TOWER_HOST` environment variable -(e.g., `TOWER_HOST=0.0.0.0` for all network interfaces). Accepted values are -`127.0.0.1` (default), `0.0.0.0`, `localhost`, valid IPv4 literals, and -bracketed IPv6 literals (e.g., `[::1]`). Hostname resolution is not supported; -only IP literals are accepted. +##### Bridge Mode + +Bridge mode enables Tower to bind to non-localhost addresses for container access. +It requires an explicit opt-in via two environment variables: + +- `BRIDGE_MODE=1` — Required to enable non-localhost binding. Without this flag, Tower + only binds to `127.0.0.1` regardless of other settings. +- `BRIDGE_TOWER_HOST` — The bind address used when `BRIDGE_MODE=1` is set. Default: + `127.0.0.1`. Accepted values: `0.0.0.0` (all interfaces), `127.0.0.1`, `localhost`, + valid IPv4 literals, and bracketed IPv6 literals (e.g., `[::1]`). + +When bridge mode is enabled, Tower logs a warning on startup: +`Bridge mode is ENABLED — Tower is listening on 0.0.0.0 network interfaces.` + +**Note:** `BRIDGE_TOWER_HOST` has no effect unless `BRIDGE_MODE=1` is also set. #### Authentication @@ -775,7 +785,7 @@ only IP literals are accepted. - Terminal WebSocket endpoints have no authentication - All processes share the user's permissions -**Justification**: Since all services bind to localhost by default, only processes running as the same user can connect. External network access is blocked at the binding level. If `TOWER_HOST` is set to `0.0.0.0`, ensure your firewall restricts access accordingly. +**Justification**: Since all services bind to localhost by default, only processes running as the same user can connect. External network access is blocked at the binding level. If bridge mode is enabled with `BRIDGE_MODE=1`, ensure your firewall restricts access accordingly. #### Request Validation diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index b91c1c25d..e3e03277e 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -461,7 +461,8 @@ afx tower start [options] - `-p, --port ` - Port to run on (default: 4100) **Environment Variables:** -- `TOWER_HOST` - Bind address (default: `127.0.0.1`). Set to `0.0.0.0` for all network interfaces. Accepts IP literals only (no hostnames). +- `BRIDGE_MODE=1` — Enable non-localhost binding (required). Without this flag, Tower only binds to `127.0.0.1`. +- `BRIDGE_TOWER_HOST` — Bind address when bridge mode is enabled (default: `127.0.0.1`). Only consulted when `BRIDGE_MODE=1`. Set to `0.0.0.0` for all network interfaces. Accepts IP literals only (no hostnames). Note: `BRIDGE_TOWER_HOST` has no effect unless `BRIDGE_MODE=1`. #### afx tower stop diff --git a/packages/codev/src/agent-farm/__tests__/bridge-mode.test.ts b/packages/codev/src/agent-farm/__tests__/bridge-mode.test.ts new file mode 100644 index 000000000..88d7e33b7 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/bridge-mode.test.ts @@ -0,0 +1,136 @@ +/** + * Integration tests for Bridge Mode env vars. + * + * Verifies that the bridge mode system (BRIDGE_MODE + BRIDGE_TOWER_HOST) + * correctly controls the Tower server bind address. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { startTower, cleanupTestDb } from "./helpers/tower-test-utils.js"; + +const PORT_DEFAULT = 14900; +const PORT_BRIDGE_ALL = 14901; +const PORT_BRIDGE_NO_HOST = 14902; +const PORT_INVALID = 14903; + +let towerDefault: Awaited> | null = null; +let towerBridgeAll: Awaited> | null = null; +let towerBridgeNoHost: Awaited> | null = null; +let invalidProcess: ChildProcess | null = null; + +async function isHostResponding(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on("connect", () => { socket.destroy(); resolve(true); }); + socket.on("timeout", () => { socket.destroy(); resolve(false); }); + socket.on("error", () => { resolve(false); }); + socket.connect(port, host); + }); +} + +function isRespondingOnLocalhost(port: number): Promise { + return isHostResponding("127.0.0.1", port); +} + +describe("Bridge Mode", () => { + beforeAll(async () => { + towerDefault = await startTower(PORT_DEFAULT, {}); + + towerBridgeAll = await startTower(PORT_BRIDGE_ALL, { + BRIDGE_MODE: "1", + BRIDGE_TOWER_HOST: "0.0.0.0", + }); + + // Bridge mode enabled but no BRIDGE_TOWER_HOST — should fall back to 127.0.0.1 + towerBridgeNoHost = await startTower(PORT_BRIDGE_NO_HOST, { + BRIDGE_MODE: "1", + }); + + // Invalid bridge host + await import("node:path"); + // @ts-expect-error dynamic import resolved + const { resolve } = await import("node:path"); + const towerServerPath = resolve( + import.meta.dirname, + "../../../../dist/agent-farm/servers/tower-server.js", + ); + + const socketDir = mkdtempSync("/tmp/codev-sock-invalid-"); + invalidProcess = spawn("node", [towerServerPath, String(PORT_INVALID)], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + env: { + ...process.env, + NODE_ENV: "test", + AF_TEST_DB: `test-${PORT_INVALID}.db`, + SHELLPER_SOCKET_DIR: socketDir, + BRIDGE_MODE: "1", + BRIDGE_TOWER_HOST: "not-a-valid-host", + }, + }); + + await new Promise((resolve) => { + invalidProcess!.on("exit", () => resolve()); + setTimeout(() => { + invalidProcess?.kill("SIGKILL"); + resolve(); + }, 5000); + }); + + try { rmSync(socketDir, { recursive: true, force: true }); } catch { /* ignore */ } + }, 30000); + + afterAll(async () => { + if (towerDefault) await towerDefault.stop(); + if (towerBridgeAll) await towerBridgeAll.stop(); + if (towerBridgeNoHost) await towerBridgeNoHost.stop(); + cleanupTestDb(PORT_DEFAULT); + cleanupTestDb(PORT_BRIDGE_ALL); + cleanupTestDb(PORT_BRIDGE_NO_HOST); + cleanupTestDb(PORT_INVALID); + }); + + describe("default behavior (no bridge mode)", () => { + it("binds to localhost only", async () => { + expect(await isRespondingOnLocalhost(PORT_DEFAULT)).toBe(true); + }); + + it("responds to /api/status on localhost", async () => { + const res = await fetch(`http://127.0.0.1:${PORT_DEFAULT}/api/status`); + expect(res.ok).toBe(true); + }); + }); + + describe("BRIDGE_MODE=1 with BRIDGE_TOWER_HOST=0.0.0.0", () => { + it("binds to all interfaces (responds on localhost)", async () => { + expect(await isRespondingOnLocalhost(PORT_BRIDGE_ALL)).toBe(true); + }); + + it("responds to /api/status", async () => { + const res = await fetch(`http://127.0.0.1:${PORT_BRIDGE_ALL}/api/status`); + expect(res.ok).toBe(true); + }); + }); + + describe("BRIDGE_MODE=1 without BRIDGE_TOWER_HOST", () => { + it("falls back to 127.0.0.1 as default", async () => { + expect(await isRespondingOnLocalhost(PORT_BRIDGE_NO_HOST)).toBe(true); + }); + + it("responds to /api/status", async () => { + const res = await fetch(`http://127.0.0.1:${PORT_BRIDGE_NO_HOST}/api/status`); + expect(res.ok).toBe(true); + }); + }); + + describe("BRIDGE_MODE=1 with invalid BRIDGE_TOWER_HOST", () => { + it("causes tower to exit with non-zero code", () => { + expect(invalidProcess?.exitCode).not.toBe(0); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/server-utils.test.ts b/packages/codev/src/agent-farm/__tests__/server-utils.test.ts index c6c0f3e25..f6d283719 100644 --- a/packages/codev/src/agent-farm/__tests__/server-utils.test.ts +++ b/packages/codev/src/agent-farm/__tests__/server-utils.test.ts @@ -10,6 +10,7 @@ import { Readable } from 'node:stream'; import { escapeHtml, parseJsonBody, + validateHost, } from '../utils/server-utils.js'; describe('Server Utilities', () => { @@ -72,4 +73,79 @@ describe('Server Utilities', () => { }); }); + describe('validateHost', () => { + it('should accept 127.0.0.1', () => { + expect(validateHost('127.0.0.1')).toBe('127.0.0.1'); + }); + + it('should accept 0.0.0.0', () => { + expect(validateHost('0.0.0.0')).toBe('0.0.0.0'); + }); + + it('should accept localhost', () => { + expect(validateHost('localhost')).toBe('localhost'); + }); + + it('should accept valid IPv4 addresses', () => { + expect(validateHost('192.168.1.1')).toBe('192.168.1.1'); + expect(validateHost('10.0.0.1')).toBe('10.0.0.1'); + expect(validateHost('255.255.255.255')).toBe('255.255.255.255'); + expect(validateHost('0.0.0.0')).toBe('0.0.0.0'); + }); + + it('should accept whitespace and return trimmed value', () => { + expect(validateHost(' 127.0.0.1 ')).toBe('127.0.0.1'); + }); + + it('should reject empty string', () => { + expect(() => validateHost('')).toThrow('Invalid bind host ""'); + }); + + it('should reject null/undefined via empty check', () => { + expect(() => validateHost(null as unknown as string)).toThrow(); + expect(() => validateHost('')).toThrow(); + }); + + it('should reject octets outside 0-255 range', () => { + expect(() => validateHost('256.1.1.1')).toThrow(); + expect(() => validateHost('1.1.1.999')).toThrow(); + expect(() => validateHost('-1.1.1.1')).toThrow(); + }); + + it('should reject non-localhost hostnames', () => { + expect(() => validateHost('example.com')).toThrow(); + expect(() => validateHost('myhost.local')).toThrow(); + }); + + it('should reject non-localhost with trailing/leading slash', () => { + expect(() => validateHost('/127.0.0.1')).toThrow(); + }); + + // Bracketed IPv6 validation - strict hex+colon only + it('should accept valid bracketed IPv6 addresses', () => { + expect(validateHost('[::1]')).toBe('[::1]'); + expect(validateHost('[::]')).toBe('[::]'); + expect(validateHost('[fe80::1]')).toBe('[fe80::1]'); + expect(validateHost('[2001:db8::1]')).toBe('[2001:db8::1]'); + expect(validateHost('[2001:0db8:0000:0000:0000:0000:0000:0001]')).toBe('[2001:0db8:0000:0000:0000:0000:0000:0001]'); + }); + + it('should reject invalid bracketed IPv6 addresses', () => { + expect(() => validateHost('[not-an-ip]')).toThrow(); + expect(() => validateHost('[anything]')).toThrow(); + expect(() => validateHost('[foo]')).toThrow(); + expect(() => validateHost('[::g]')).toThrow(); // 'g' is not valid hex + expect(() => validateHost('[hello]')).toThrow(); + }); + + it('should reject unbracketed IPv6', () => { + expect(() => validateHost('::1')).toThrow(); + expect(() => validateHost('fe80::1')).toThrow(); + }); + + it('should reject bracketed but missing IPv6', () => { + expect(() => validateHost('[]')).toThrow(); + }); + }); + }); diff --git a/packages/codev/src/agent-farm/__tests__/tower-host.test.ts b/packages/codev/src/agent-farm/__tests__/tower-host.test.ts deleted file mode 100644 index 2640364fb..000000000 --- a/packages/codev/src/agent-farm/__tests__/tower-host.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Integration tests for TOWER_HOST env var - * - * Verifies that the tower server respects the TOWER_HOST environment - * variable for configuring the bind address. - */ - -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { spawn, type ChildProcess } from "node:child_process"; -import net from "node:net"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { startTower, cleanupTestDb } from "./helpers/tower-test-utils.js"; - -// Use a unique port range for this test suite -const PORT_DEFAULT = 14800; -const PORT_ALL_INTERFACES = 14801; -const PORT_INVALID = 14802; - -let towerDefault: Awaited> | null = null; -let towerAllInterfaces: Awaited> | null = null; -let invalidProcess: ChildProcess | null = null; - -/** - * Check if a specific host:port pair is responding - */ -async function isHostResponding(host: string, port: number): Promise { - return new Promise((resolve) => { - const socket = new net.Socket(); - socket.setTimeout(1000); - socket.on("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.on("timeout", () => { - socket.destroy(); - resolve(false); - }); - socket.on("error", () => { - resolve(false); - }); - socket.connect(port, host); - }); -} - -/** - * Check if a port responds on localhost - */ -async function isRespondingOnLocalhost(port: number): Promise { - return isHostResponding("127.0.0.1", port); -} - -describe("TOWER_HOST env var", () => { - beforeAll(async () => { - // Start 2 tower instances with different TOWER_HOST settings. - // Note: TOWER_HOST=localhost is NOT tested here because on macOS - // 'localhost' resolves to ::1 (IPv6) first, and the health check in - // tower-test-utils connects to 127.0.0.1 (IPv4). The unit tests - // already verify that 'localhost' passes validateHost(). - towerDefault = await startTower(PORT_DEFAULT, {}); - towerAllInterfaces = await startTower(PORT_ALL_INTERFACES, { - TOWER_HOST: "0.0.0.0", - }); - - // Try starting with an invalid host — should fail to start - const { resolve } = await import("node:path"); - const towerServerPath = resolve( - import.meta.dirname, - "../../../../dist/agent-farm/servers/tower-server.js", - ); - - const socketDir = mkdtempSync("/tmp/codev-sock-invalid-"); - invalidProcess = spawn("node", [towerServerPath, String(PORT_INVALID)], { - stdio: ["ignore", "pipe", "pipe"], - detached: false, - env: { - ...process.env, - NODE_ENV: "test", - AF_TEST_DB: `test-${PORT_INVALID}.db`, - SHELLPER_SOCKET_DIR: socketDir, - TOWER_HOST: "not-a-valid-host", - }, - }); - - // Wait for it to exit (should fail fast with validation error) - await new Promise((resolve) => { - invalidProcess!.on("exit", () => resolve()); - // Safety: kill after 5s if it somehow didn't exit - setTimeout(() => { - invalidProcess?.kill("SIGKILL"); - resolve(); - }, 5000); - }); - - try { - rmSync(socketDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - }, 30000); - - afterAll(async () => { - if (towerDefault) await towerDefault.stop(); - if (towerAllInterfaces) await towerAllInterfaces.stop(); - - // Clean up test DBs - cleanupTestDb(PORT_DEFAULT); - cleanupTestDb(PORT_ALL_INTERFACES); - cleanupTestDb(PORT_INVALID); - }); - - describe("default behavior (no TOWER_HOST)", () => { - it("binds to localhost only", async () => { - // Default tower should respond on 127.0.0.1 - const responding = await isRespondingOnLocalhost(PORT_DEFAULT); - expect(responding).toBe(true); - }); - - it("responds to /api/status on localhost", async () => { - const res = await fetch(`http://127.0.0.1:${PORT_DEFAULT}/api/status`); - expect(res.ok).toBe(true); - }); - }); - - describe("TOWER_HOST=0.0.0.0", () => { - it("binds to all interfaces (responds on localhost)", async () => { - // When bound to 0.0.0.0, it should still respond on 127.0.0.1 - const responding = await isRespondingOnLocalhost(PORT_ALL_INTERFACES); - expect(responding).toBe(true); - }); - - it("responds to /api/status", async () => { - const res = await fetch( - `http://127.0.0.1:${PORT_ALL_INTERFACES}/api/status`, - ); - expect(res.ok).toBe(true); - }); - }); - - describe("invalid TOWER_HOST", () => { - it("causes tower to exit with non-zero code", () => { - const code = invalidProcess?.exitCode; - expect(code).not.toBe(0); - }); - }); -}); diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 7a0518b0b..24166e08f 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -77,10 +77,14 @@ const portArg = opts.port || args[0] || String(DEFAULT_TOWER_PORT); const port = parseInt(portArg, 10); const logFilePath = opts.logFile; -// Resolve bind host: TOWER_HOST env var (for containerized setups) or default to localhost. -// The spawned tower-server process inherits process.env from the afx CLI, so no CLI -// flag plumbing is needed — set TOWER_HOST=0.0.0.0 in your environment or docker-compose. -const bindHost = validateHost(process.env.TOWER_HOST || '127.0.0.1'); +// Bridge mode: Tower binds to non-localhost when explicitly enabled. +// BRIDGE_MODE=1 is the opt-in flag; without it, no non-localhost bind is possible. +// BRIDGE_TOWER_HOST specifies the bind address when bridge mode is enabled +// (default: 127.0.0.1 — the spawned tower-server inherits process.env from the afx CLI). +const bridgeMode = process.env.BRIDGE_MODE === '1'; +const bindHost = bridgeMode + ? validateHost(process.env.BRIDGE_TOWER_HOST || '127.0.0.1') + : '127.0.0.1'; // Logging utility function log(level: 'INFO' | 'ERROR' | 'WARN', message: string): void { @@ -324,9 +328,12 @@ const server = http.createServer(async (req, res) => { }); // SECURITY: Bind to configured host (default 127.0.0.1 for localhost-only). -// Set TOWER_HOST env var to override (e.g., 0.0.0.0 for all network interfaces). +// Bridge mode enables non-localhost binding when BRIDGE_MODE=1 is set. server.listen(port, bindHost, async () => { - // Display localhost in URLs for local UX even when bound to 0.0.0.0. + if (bridgeMode) { + log('WARN', `Bridge mode is ENABLED — Tower is listening on ${bindHost} network interfaces.`); + } + // Display localhost in URLs for local UX even when bound to all interfaces. const displayHost = bindHost === '0.0.0.0' ? 'localhost' : bindHost; log('INFO', `Tower server listening at http://${displayHost}:${port}`); diff --git a/packages/codev/src/agent-farm/utils/server-utils.ts b/packages/codev/src/agent-farm/utils/server-utils.ts index 3c7fb079b..1a1b45ec5 100644 --- a/packages/codev/src/agent-farm/utils/server-utils.ts +++ b/packages/codev/src/agent-farm/utils/server-utils.ts @@ -86,9 +86,9 @@ export function isRequestAllowed(_req: http.IncomingMessage): boolean { * Accepts 127.0.0.1, 0.0.0.0, localhost, valid IPv4, and bracketed IPv6. * Returns the validated host string, or throws on invalid input. * - * Used by tower-server.ts to resolve TOWER_HOST. + * Used by tower-server.ts to resolve BRIDGE_TOWER_HOST when BRIDGE_MODE=1. * - * @param host - The bind host string (e.g., from TOWER_HOST env var) + * @param host - The bind host string (e.g., from BRIDGE_TOWER_HOST env var) * @returns The validated/trimmed host string * @throws Error with a clear message if the host is invalid */ @@ -116,7 +116,7 @@ export function validateHost(host: string): string { } // Bracketed IPv6 (e.g., [::1], [::]) - if (/^\[.+\]$/.test(h)) { + if (/^\[[0-9a-fA-F:]+\]$/.test(h)) { return h; } diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index 6da084e85..d4a6a2215 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -88,10 +88,10 @@ export class TowerClient { private readonly getAuthKey: () => string | null; constructor(portOrOptions?: number | TowerClientOptions) { - const options = typeof portOrOptions === 'number' + const options: TowerClientOptions = typeof portOrOptions === 'number' ? { port: portOrOptions } : portOrOptions ?? {}; - const host = options.host ?? 'localhost'; + const host = options.host ?? process.env.BRIDGE_TOWER_HOST ?? 'localhost'; const port = options.port ?? DEFAULT_TOWER_PORT; this.baseUrl = `http://${host}:${port}`; this.getAuthKey = options.getAuthKey ?? ensureLocalKey;