Skip to content
Open
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
20 changes: 18 additions & 2 deletions codev/resources/arch.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,18 +758,34 @@ 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)**
- Dashboard has no login/password
- 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

Expand Down
4 changes: 4 additions & 0 deletions codev/resources/commands/agent-farm.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ afx tower start [options]
**Options:**
- `-p, --port <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.
Expand Down
136 changes: 136 additions & 0 deletions packages/codev/src/agent-farm/__tests__/bridge-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof startTower>> | null = null;
let towerBridgeAll: Awaited<ReturnType<typeof startTower>> | null = null;
let towerBridgeNoHost: Awaited<ReturnType<typeof startTower>> | null = null;
let invalidProcess: ChildProcess | null = null;

async function isHostResponding(host: string, port: number): Promise<boolean> {
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<boolean> {
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<void>((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);
});
});
});
76 changes: 76 additions & 0 deletions packages/codev/src/agent-farm/__tests__/server-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Readable } from 'node:stream';
import {
escapeHtml,
parseJsonBody,
validateHost,
} from '../utils/server-utils.js';

describe('Server Utilities', () => {
Expand Down Expand Up @@ -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();
});
});

});
22 changes: 19 additions & 3 deletions packages/codev/src/agent-farm/servers/tower-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
46 changes: 46 additions & 0 deletions packages/codev/src/agent-farm/utils/server-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}
4 changes: 2 additions & 2 deletions packages/core/src/tower-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down