diff --git a/codev/resources/arch.md b/codev/resources/arch.md index af3701bc..503742a0 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -758,10 +758,26 @@ 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 +##### 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 **Current approach: None (localhost assumption)** @@ -769,7 +785,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 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 6a565c9c..e3e03277 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -460,6 +460,10 @@ afx tower start [options] **Options:** - `-p, --port ` - Port to run on (default: 4100) +**Environment Variables:** +- `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 Stop the tower dashboard. 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 00000000..88d7e33b --- /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 c6c0f3e2..f6d28371 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/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 55fbf0de..24166e08 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,15 @@ const portArg = opts.port || args[0] || String(DEFAULT_TOWER_PORT); const port = parseInt(portArg, 10); const logFilePath = opts.logFile; +// 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 { const timestamp = new Date().toISOString(); @@ -317,9 +327,15 @@ 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). +// Bridge mode enables non-localhost binding when BRIDGE_MODE=1 is set. +server.listen(port, bindHost, async () => { + 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}`); // 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 b7d58962..1a1b45ec 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 BRIDGE_TOWER_HOST when BRIDGE_MODE=1. + * + * @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 + */ +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 (/^\[[0-9a-fA-F:]+\]$/.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.', + ); +} diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index 6da084e8..d4a6a221 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;