From 520010d5a20df7648ccee0559eb16408e9c213a0 Mon Sep 17 00:00:00 2001 From: prateek Date: Thu, 19 Feb 2026 04:00:19 +0530 Subject: [PATCH] feat: configurable terminal server ports for multi-dashboard support (#113) * feat: make terminal server ports configurable to fix multi-dashboard EADDRINUSE When multiple ao dashboards run simultaneously (e.g., ao on port 3000, integrator on port 3002), both try to start terminal WebSocket servers on hardcoded ports 3001/3003, causing EADDRINUSE. Add terminalPort and directTerminalPort to config schema so each instance can use unique ports. Co-Authored-By: Claude Opus 4.6 * fix: use optional() instead of default() for terminal port schema Zod .default() always fills in the value, making config.terminalPort never undefined and the env var fallback in buildDashboardEnv dead code. Switch to .optional() so the priority chain works correctly: config value > TERMINAL_PORT env var > hardcoded default. Co-Authored-By: Claude Opus 4.6 * fix: move terminal server defaults from 3001/3003 to 14800/14801 The 3000-3009 range is the most contested in dev tooling (Next.js auto-increments, BrowserSync, Grafana, Rails, Express all default to 3000+). Port 14800-14899 has zero IANA registrations, zero known dev tool conflicts, and is safely below OS ephemeral ranges. Updated all hardcoded fallbacks, .env.local.example, docker-compose port mappings, and documentation. Co-Authored-By: Claude Opus 4.6 * feat: auto-detect available terminal ports for zero-config multi-dashboard When no terminal ports are configured (no config, no env vars), buildDashboardEnv now probes for an available port pair starting at 14800. The second `ao start` automatically gets 14802/14803 (or the next free pair), eliminating EADDRINUSE without any user configuration. Port detection scans in steps of 2 to keep the pair consecutive. Explicit config/env values bypass auto-detection entirely. Co-Authored-By: Claude Opus 4.6 * fix: update onboarding test to use new default terminal port (14801) The onboarding integration test had port 3003 hardcoded for the WebSocket health check. Updated to read from DIRECT_TERMINAL_PORT env var with 14801 as the default, matching the new port defaults. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- agent-orchestrator.yaml.example | 5 ++ docs/DEVELOPMENT.md | 8 +- packages/cli/src/commands/dashboard.ts | 7 +- packages/cli/src/commands/start.ts | 18 +++- packages/cli/src/lib/web-dir.ts | 84 ++++++++++++++++++- packages/core/src/config.ts | 2 + packages/core/src/types.ts | 6 ++ packages/web/.env.local.example | 10 +-- packages/web/server/direct-terminal-ws.ts | 2 +- packages/web/server/terminal-websocket.ts | 2 +- .../web/src/components/DirectTerminal.tsx | 2 +- packages/web/src/components/Terminal.tsx | 2 +- tests/integration/docker-compose.yml | 8 +- tests/integration/onboarding-test.sh | 6 +- 14 files changed, 135 insertions(+), 27 deletions(-) diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 3e55d1aaa..99daf79e6 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -10,6 +10,11 @@ worktreeDir: ~/.worktrees # Web dashboard port port: 3000 +# Terminal server ports (defaults: 14800/14801 — chosen to avoid conflicts with dev tools) +# Override when running multiple dashboards to avoid EADDRINUSE +# terminalPort: 14800 +# directTerminalPort: 14801 + # Default plugins (these are the defaults — you can omit this section) defaults: runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2f8667de9..9c0150310 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -328,12 +328,12 @@ curl http://localhost:3000/api/sessions/my-app-3 ```bash # Terminal server ports (for web dashboard) -TERMINAL_PORT=3001 -DIRECT_TERMINAL_PORT=3003 +TERMINAL_PORT=14800 +DIRECT_TERMINAL_PORT=14801 # Next.js -NEXT_PUBLIC_TERMINAL_PORT=3001 -NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003 +NEXT_PUBLIC_TERMINAL_PORT=14800 +NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801 ``` ### User Secrets diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index dc603abf5..b0eb2d669 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -62,7 +62,12 @@ export function registerDashboard(program: Command): void { console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); - const env = buildDashboardEnv(port, config.configPath); + const env = await buildDashboardEnv( + port, + config.configPath, + config.terminalPort, + config.directTerminalPort, + ); const child = spawn("npx", ["next", "dev", "-p", String(port)], { cwd: webDir, diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 9d4d7ef6a..3e7edc1c9 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -65,8 +65,14 @@ function resolveProject( * Start dashboard server in the background. * Returns the child process handle for cleanup. */ -function startDashboard(port: number, webDir: string, configPath: string | null): ChildProcess { - const env = buildDashboardEnv(port, configPath); +async function startDashboard( + port: number, + webDir: string, + configPath: string | null, + terminalPort?: number, + directTerminalPort?: number, +): Promise { + const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); const child = spawn("pnpm", ["run", "dev"], { cwd: webDir, @@ -150,7 +156,13 @@ export function registerStart(program: Command): void { } spinner.start("Starting dashboard"); - dashboardProcess = startDashboard(port, webDir, config.configPath); + dashboardProcess = await startDashboard( + port, + webDir, + config.configPath, + config.terminalPort, + config.directTerminalPort, + ); spinner.succeed(`Dashboard starting on http://localhost:${port}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index d378593e6..7ee48c311 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -3,6 +3,7 @@ * Shared utility to avoid duplication between dashboard.ts and start.ts. */ +import { createServer } from "node:net"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; @@ -12,11 +13,59 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); +/** Default terminal server base port (14800 range: zero IANA registrations, no dev tool conflicts) */ +const DEFAULT_TERMINAL_PORT = 14800; + +/** + * Check if a TCP port is available by attempting to bind to it. + * Returns true if the port is free, false if in use. + */ +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => { + resolve(false); + }); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +/** + * Find a pair of consecutive available ports starting from `base`. + * Scans upward in steps of 2 (keeping ports paired) until both are free. + * Returns [terminalPort, directTerminalPort]. + */ +async function findAvailablePortPair(base: number): Promise<[number, number]> { + const MAX_ATTEMPTS = 50; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const p1 = base + i * 2; + const p2 = p1 + 1; + const [free1, free2] = await Promise.all([isPortAvailable(p1), isPortAvailable(p2)]); + if (free1 && free2) { + return [p1, p2]; + } + } + // If all 50 pairs exhausted, fall back to the base (will fail at bind time with clear error) + return [base, base + 1]; +} + /** * Build environment variables for spawning the dashboard process. * Shared between `ao start` and `ao dashboard` to avoid duplication. + * + * Terminal server ports default to 14800/14801 but can be overridden via config. + * When no explicit port is set, auto-detects available ports to allow multiple + * dashboard instances to run simultaneously without EADDRINUSE conflicts. */ -export function buildDashboardEnv(port: number, configPath: string | null): Record { +export async function buildDashboardEnv( + port: number, + configPath: string | null, + terminalPort?: number, + directTerminalPort?: number, +): Promise> { const env: Record = { ...process.env } as Record; // Pass config path so dashboard uses the same config as the CLI @@ -24,10 +73,37 @@ export function buildDashboardEnv(port: number, configPath: string | null): Reco env["AO_CONFIG_PATH"] = configPath; } - // Set ports for client-side access (Next.js requires NEXT_PUBLIC_ prefix) env["PORT"] = String(port); - env["NEXT_PUBLIC_TERMINAL_PORT"] = env["TERMINAL_PORT"] ?? "3001"; - env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = env["DIRECT_TERMINAL_PORT"] ?? "3003"; + + // If explicit ports provided (config or env var), use them directly. + // Otherwise, auto-detect an available pair starting from the default. + const explicitTerminal = terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined); + const explicitDirect = directTerminalPort ?? (env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined); + + let resolvedTerminal: number; + let resolvedDirect: number; + + if (explicitTerminal !== undefined && explicitDirect !== undefined) { + // Both explicitly set — use as-is + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitDirect; + } else if (explicitTerminal !== undefined) { + // Terminal port set, derive direct from it + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitTerminal + 1; + } else if (explicitDirect !== undefined) { + // Direct port set, derive terminal from it + resolvedTerminal = explicitDirect - 1; + resolvedDirect = explicitDirect; + } else { + // Neither set — auto-detect available pair + [resolvedTerminal, resolvedDirect] = await findAvailablePortPair(DEFAULT_TERMINAL_PORT); + } + + env["TERMINAL_PORT"] = String(resolvedTerminal); + env["DIRECT_TERMINAL_PORT"] = String(resolvedDirect); + env["NEXT_PUBLIC_TERMINAL_PORT"] = String(resolvedTerminal); + env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = String(resolvedDirect); return env; } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 713b29b33..4a4c34070 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -90,6 +90,8 @@ const DefaultPluginsSchema = z.object({ const OrchestratorConfigSchema = z.object({ port: z.number().default(3000), + terminalPort: z.number().optional(), + directTerminalPort: z.number().optional(), readyThresholdMs: z.number().nonnegative().default(300_000), defaults: DefaultPluginsSchema.default({}), projects: z.record(ProjectConfigSchema), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 25bcbe221..ae0346506 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -795,6 +795,12 @@ export interface OrchestratorConfig { /** Web dashboard port (defaults to 3000) */ port?: number; + /** Terminal WebSocket server port (defaults to 3001) */ + terminalPort?: number; + + /** Direct terminal WebSocket server port (defaults to 3003) */ + directTerminalPort?: number; + /** Milliseconds before a "ready" session becomes "idle" (default: 300000 = 5 min) */ readyThresholdMs: number; diff --git a/packages/web/.env.local.example b/packages/web/.env.local.example index d0260f17a..79d39233a 100644 --- a/packages/web/.env.local.example +++ b/packages/web/.env.local.example @@ -1,7 +1,7 @@ -# Terminal server ports -TERMINAL_PORT=3001 # ttyd (iframe) terminal server -DIRECT_TERMINAL_PORT=3003 # Direct WebSocket terminal server with XDA support +# Terminal server ports (14800 range chosen to avoid conflicts with dev tools) +TERMINAL_PORT=14800 # ttyd (iframe) terminal server +DIRECT_TERMINAL_PORT=14801 # Direct WebSocket terminal server with XDA support # Public environment variables (exposed to browser) -NEXT_PUBLIC_TERMINAL_PORT=3001 -NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003 +NEXT_PUBLIC_TERMINAL_PORT=14800 +NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801 diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 05badae16..74ad78998 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -210,7 +210,7 @@ if (isMainModule) { console.log(`[DirectTerminal] Using tmux: ${TMUX}`); const { server, shutdown } = createDirectTerminalServer(TMUX); - const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "3003", 10); + const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); server.listen(PORT, () => { console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`); diff --git a/packages/web/server/terminal-websocket.ts b/packages/web/server/terminal-websocket.ts index b8840b7a9..8116b5221 100644 --- a/packages/web/server/terminal-websocket.ts +++ b/packages/web/server/terminal-websocket.ts @@ -307,7 +307,7 @@ const server = createServer(async (req, res) => { res.end("Not found"); }); -const PORT = parseInt(process.env.TERMINAL_PORT ?? "3001", 10); +const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10); server.listen(PORT, () => { console.log(`[Terminal] Server listening on port ${PORT}`); diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index bda6803b9..7fda2273b 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -124,7 +124,7 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer // Connect WebSocket const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const hostname = window.location.hostname; - const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "3003"; + const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801"; const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`; console.log("[DirectTerminal] Connecting to:", wsUrl); diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index ce9ac8a18..4d169d8ab 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -18,7 +18,7 @@ export function Terminal({ sessionId }: TerminalProps) { const [error, setError] = useState(null); useEffect(() => { - const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001"; + const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "14800"; // Use current hostname instead of hardcoded localhost const protocol = window.location.protocol; const hostname = window.location.hostname; diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index bc12cb2db..a9a075428 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -15,10 +15,10 @@ services: - ../..:/workspace/agent-orchestrator # Expose ports for debugging (optional) # Use 9000/9001/9003 on host to avoid conflicts with orchestrator - # Container still uses 9000/3001/3003 internally + # Container still uses 9000/14800/14801 internally ports: - - "9000:9000" # Dashboard (host:container) - - "9001:3001" # Terminal WebSocket (host:container) - - "9003:3003" # DirectTerminal WebSocket (host:container) + - "9000:9000" # Dashboard (host:container) + - "9001:14800" # Terminal WebSocket (host:container) + - "9003:14801" # DirectTerminal WebSocket (host:container) # Increase shared memory for browser-based tests (future) shm_size: 2gb diff --git a/tests/integration/onboarding-test.sh b/tests/integration/onboarding-test.sh index bb44359a7..1516e8bc7 100755 --- a/tests/integration/onboarding-test.sh +++ b/tests/integration/onboarding-test.sh @@ -131,10 +131,12 @@ end_step "Step 7: Dashboard API responding" start_step "Step 8: Verify WebSocket servers" # Check if direct terminal WebSocket server is running (required for terminal feature) -echo " Checking WebSocket server on port 3003..." +# Default port is 14801 (14800 range chosen to avoid conflicts with dev tools) +DIRECT_TERMINAL_PORT="${DIRECT_TERMINAL_PORT:-14801}" +echo " Checking WebSocket server on port $DIRECT_TERMINAL_PORT..." max_retries=10 for i in $(seq 1 $max_retries); do - if curl -sf http://localhost:3003/health > /dev/null 2>&1; then + if curl -sf "http://localhost:$DIRECT_TERMINAL_PORT/health" > /dev/null 2>&1; then echo " ✓ WebSocket server responding" break fi