diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index bb3fb5193..c67c0a381 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -10,6 +10,7 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; import { existsSync } from "node:fs"; +import { isLinux, isWindows } from "@aoagents/ao-core"; import { formatCommandError } from "./cli-errors.js"; const __filename = fileURLToPath(import.meta.url); @@ -32,9 +33,18 @@ export function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const s = new Socket(); s.setTimeout(300); - s.once("connect", () => { s.destroy(); resolve(false); }); // something listening → in use - s.once("error", () => { s.destroy(); resolve(true); }); // ECONNREFUSED → free - s.once("timeout", () => { s.destroy(); resolve(true); }); // no response → free + s.once("connect", () => { + s.destroy(); + resolve(false); + }); // something listening → in use + s.once("error", () => { + s.destroy(); + resolve(true); + }); // ECONNREFUSED → free + s.once("timeout", () => { + s.destroy(); + resolve(true); + }); // no response → free s.connect(port, "127.0.0.1"); }); } @@ -57,10 +67,9 @@ export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Prom * Open a URL in the user's browser without throwing back into the caller. */ export function openUrl(url: string): void { - const [cmd, args]: [string, string[]] = - process.platform === "win32" - ? ["cmd.exe", ["/c", "start", "", url]] - : [process.platform === "linux" ? "xdg-open" : "open", [url]]; + const [cmd, args]: [string, string[]] = isWindows() + ? ["cmd.exe", ["/c", "start", "", url]] + : [isLinux() ? "xdg-open" : "open", [url]]; const browser = spawn(cmd, args, { stdio: "ignore" }); browser.on("error", (err) => { console.warn( @@ -68,12 +77,11 @@ export function openUrl(url: string): void { cmd, args, action: `open ${url} in a browser`, - installHints: - process.platform === "linux" - ? ["Install xdg-utils so `xdg-open` is available on PATH."] - : process.platform === "win32" - ? [] - : [], + installHints: isLinux() + ? ["Install xdg-utils so `xdg-open` is available on PATH."] + : isWindows() + ? [] + : [], }).message, ); }); @@ -145,8 +153,11 @@ export async function buildDashboardEnv( // 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); + 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; @@ -202,8 +213,8 @@ export function findWebDir(): string { } throw new Error( "Could not find @aoagents/ao-web package.\n" + - " If installed via npm: npm install -g @aoagents/ao\n" + - " If cloned from source: pnpm install && pnpm build", + " If installed via npm: npm install -g @aoagents/ao\n" + + " If cloned from source: pnpm install && pnpm build", ); } } diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index e9a04dd21..d8ebb63b0 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -17,14 +17,17 @@ import { export interface DirectTerminalServer { server: Server; - shutdown: () => void; + shutdown: (onClosed?: () => void) => void; } function isRemoteAuthAllowed(url: URL, initialConfiguredAuth: RemoteAuthCredentials): boolean { const { username, password: expectedPassword } = activeRemoteAuth(initialConfiguredAuth); if (!expectedPassword) return true; - return verifyRemoteWsToken(url.searchParams.get("auth_token"), { username, password: expectedPassword }); + return verifyRemoteWsToken(url.searchParams.get("auth_token"), { + username, + password: expectedPassword, + }); } /** @@ -89,7 +92,12 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm } }); - function shutdown() { + let shuttingDown = false; + + function shutdown(onClosed?: () => void) { + if (shuttingDown) return; + shuttingDown = true; + // Terminate all connected mux clients — this triggers their 'close' events // which unsubscribe terminal callbacks and kill PTY processes. if (muxWss) { @@ -98,7 +106,12 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm } muxWss.close(); } - server.close(); + server.close((err?: Error & { code?: string }) => { + if (err && err.code !== "ERR_SERVER_NOT_RUNNING") { + console.error("[DirectTerminal] Error during shutdown:", err); + } + onClosed?.(); + }); } return { server, shutdown }; @@ -125,19 +138,27 @@ if (isMainModule) { } const { server, shutdown } = createDirectTerminalServer(TMUX); + let shuttingDown = false; server.listen(PORT, () => { console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`); }); function handleShutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[DirectTerminal] Received ${signal}, shutting down...`); - shutdown(); const forceExitTimer = setTimeout(() => { console.error("[DirectTerminal] Forced shutdown after timeout"); process.exit(1); }, 5000); forceExitTimer.unref(); + + shutdown(() => { + clearTimeout(forceExitTimer); + process.exit(0); + }); } process.on("SIGINT", () => handleShutdown("SIGINT")); diff --git a/packages/web/src/middleware.test.ts b/packages/web/src/middleware.test.ts index ec05c9aac..b6bc5b4f0 100644 --- a/packages/web/src/middleware.test.ts +++ b/packages/web/src/middleware.test.ts @@ -84,6 +84,36 @@ describe("remote auth middleware", () => { expect(response.status).toBe(200); }); + it("requires credentials for proxied requests even when the socket is loopback", () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + vi.stubEnv("AO_TRUST_REMOTE_ADDRESS_HEADER", "1"); + + const response = middleware( + request("/api/projects", undefined, { + "x-ao-remote-address": "127.0.0.1", + "x-forwarded-for": "203.0.113.10", + }), + ); + + expect(response.status).toBe(401); + }); + + it("requires credentials for Cloudflare-proxied requests from loopback", () => { + vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); + vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); + vi.stubEnv("AO_TRUST_REMOTE_ADDRESS_HEADER", "1"); + + const response = middleware( + request("/api/projects", undefined, { + "x-ao-remote-address": "::1", + "cf-connecting-ip": "203.0.113.10", + }), + ); + + expect(response.status).toBe(401); + }); + it("ignores the loopback socket header unless the AO server marked it trusted", () => { vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 669e59fc7..b91c87bd7 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -60,6 +60,14 @@ function isLoopbackAddress(address: string | undefined): boolean { ); } +function hasProxyHeaders(request: NextRequest): boolean { + return ( + request.headers.has("x-forwarded-for") || + request.headers.has("x-real-ip") || + request.headers.has("cf-connecting-ip") + ); +} + export function middleware(request: NextRequest) { const password = remoteAuthPassword(); const requestIp = @@ -67,7 +75,8 @@ export function middleware(request: NextRequest) { (process.env["AO_TRUST_REMOTE_ADDRESS_HEADER"] === "1" ? (request.headers.get("x-ao-remote-address") ?? undefined) : undefined); - if (!password || isPublicAsset(request.nextUrl.pathname) || isLoopbackAddress(requestIp)) { + const allowLoopbackBypass = isLoopbackAddress(requestIp) && !hasProxyHeaders(request); + if (!password || isPublicAsset(request.nextUrl.pathname) || allowLoopbackBypass) { return NextResponse.next(); }