Skip to content
Merged
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
27 changes: 21 additions & 6 deletions src/cli/commands/broker/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import type { Server } from "node:http";
import { createBrokerHttpServer, TunnelBroker } from "../../../tunnel/index.js";
import { parseArgs, flagString } from "../../args.js";
import type { CliContext } from "../../context.js";
import { listenErrorMessage, listenOrError, type ListenOutcome } from "../listen.js";

export async function runBrokerCommand(argv: string[], context: CliContext): Promise<number> {
export interface BrokerCommandHooks {
// Injectable so tests can deterministically exercise the bind-error path without
// depending on OS hostname resolution.
listen?: (server: Server, port: number, host: string) => Promise<ListenOutcome>;
}

export async function runBrokerCommand(
argv: string[],
context: CliContext,
hooks: BrokerCommandHooks = {}
): Promise<number> {
const [subcommand, ...rest] = argv;
if (subcommand === "serve") return brokerServe(rest, context);
if (subcommand === "serve") return brokerServe(rest, context, hooks);
context.stderr.write(`Unknown broker command: ${subcommand ?? ""}\n`);
return 1;
}

async function brokerServe(argv: string[], context: CliContext): Promise<number> {
async function brokerServe(argv: string[], context: CliContext, hooks: BrokerCommandHooks): Promise<number> {
const args = parseArgs(argv);

const host = flagString(args, "host") ?? "127.0.0.1";
Expand All @@ -22,9 +34,12 @@ async function brokerServe(argv: string[], context: CliContext): Promise<number>
logSink: (record) => context.stdout.write(`${JSON.stringify(record)}\n`)
});
const server = createBrokerHttpServer(broker);
await new Promise<void>((resolve) => {
server.listen(port, host, resolve);
});
const listen = hooks.listen ?? listenOrError;
const outcome = await listen(server, port, host);
if (!outcome.ok) {
context.stderr.write(`${listenErrorMessage(host, port, outcome.error)}\n`);
return 1;
}

context.stdout.write(`Agent Gather broker serving on ${host}:${port}\n`);
if (publicUrl !== undefined) context.stdout.write(`Public URL: ${publicUrl}\n`);
Expand Down
16 changes: 5 additions & 11 deletions src/cli/commands/launch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { Server } from "node:http";
import { platform as osPlatform } from "node:os";
import { parseArgs, type ParsedArgs } from "../../args.js";
import type { CliContext } from "../../context.js";
import { listenOrError } from "../listen.js";
import { DEFAULT_PLATFORM_PORT, defaultWaitForShutdown } from "../platform/index.js";
import { buildHelpText, VERSION } from "../../help.js";
import { createPlatformHttpServer } from "../../../platform/index.js";
Expand Down Expand Up @@ -179,18 +180,11 @@ function isConnectionRefused(error: unknown): boolean {
return code === "ECONNREFUSED";
}

// #232 launcher bind: localhost-only, and a bind failure resolves false so the
// caller refuses with a token-free message (never a kill). Delegates to the shared
// listen helper without changing that behavior.
function listen(server: Server, port: number): Promise<boolean> {
return new Promise((resolve) => {
const onError = (): void => {
server.removeListener("error", onError);
resolve(false);
};
server.once("error", onError);
server.listen(port, "127.0.0.1", () => {
server.removeListener("error", onError);
resolve(true);
});
});
return listenOrError(server, port, "127.0.0.1").then((outcome) => outcome.ok);
}

// Open a URL in the default browser without a shell: the platform opener is spawned
Expand Down
43 changes: 43 additions & 0 deletions src/cli/commands/listen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Server } from "node:http";

export interface ListenOutcome {
ok: boolean;
error?: NodeJS.ErrnoException;
}

// Bind a foreground server, guarding the one-shot 'error' event so an occupied or
// invalid bind resolves as a failure outcome instead of throwing an uncaught
// Server error or hanging the listen promise forever. This only observes its own
// listen attempt — it never inspects, kills, or mutates any other process's
// listener. On success the error guard is removed so post-bind behavior is
// unchanged from a bare `server.listen`.
export function listenOrError(server: Server, port: number, host: string): Promise<ListenOutcome> {
return new Promise((resolve) => {
const onError = (error: NodeJS.ErrnoException): void => {
server.removeListener("error", onError);
resolve({ ok: false, error });
};
server.once("error", onError);
server.listen(port, host, () => {
server.removeListener("error", onError);
resolve({ ok: true });
});
});
}

// A controlled, token-free message for a bind failure. It uses only the bind
// coordinates (host/port) and the OS error code — never request, token, or
// message data — so it is always safe to write to stderr.
export function listenErrorMessage(host: string, port: number, error: NodeJS.ErrnoException | undefined): string {
const target = `${host}:${port}`;
switch (error?.code) {
case "EADDRINUSE":
return `Cannot bind ${target}: address already in use.`;
case "EACCES":
return `Cannot bind ${target}: permission denied.`;
case "EADDRNOTAVAIL":
return `Cannot bind ${target}: address not available on this host.`;
default:
return `Cannot bind ${target}${error?.code ? `: ${error.code}` : ""}.`;
}
}
11 changes: 10 additions & 1 deletion src/cli/commands/platform/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Server } from "node:http";
import { flagBoolean, flagString, parseArgs } from "../../args.js";
import type { CliContext } from "../../context.js";
import { listenErrorMessage, listenOrError, type ListenOutcome } from "../listen.js";
import { createPlatformHttpServer } from "../../../platform/index.js";

// Default control-plane port: room serve uses 8787, the broker 8799; the owner
Expand All @@ -12,6 +13,9 @@ export interface PlatformCommandHooks {
// Injectable so tests can drive the running server and shut it down instead of
// blocking on process signals forever.
waitForShutdown?: (server: Server) => Promise<void>;
// Injectable so tests can deterministically exercise the bind-error path without
// depending on OS hostname resolution.
listen?: (server: Server, port: number, host: string) => Promise<ListenOutcome>;
}

export async function runPlatformCommand(
Expand Down Expand Up @@ -43,7 +47,12 @@ async function platformServe(argv: string[], context: CliContext, hooks: Platfor
}

const server = createPlatformHttpServer({ root: context.home, allowInsecureRemote: allowRemote });
await new Promise<void>((resolve) => server.listen(port, host, resolve));
const listen = hooks.listen ?? listenOrError;
const outcome = await listen(server, port, host);
if (!outcome.ok) {
context.stderr.write(`${listenErrorMessage(host, port, outcome.error)}\n`);
return 1;
}
const address = server.address();
const boundPort = typeof address === "object" && address !== null ? address.port : port;
const url = `http://${host}:${boundPort}`;
Expand Down
27 changes: 21 additions & 6 deletions src/cli/commands/room/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import type { Server } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
Expand Down Expand Up @@ -55,8 +56,15 @@ import {
import { readPublicBaseUrl } from "../../../tunnel/index.js";
import { parseArgs, flagBoolean, flagString, type ParsedArgs } from "../../args.js";
import type { CliContext } from "../../context.js";
import { listenErrorMessage, listenOrError, type ListenOutcome } from "../listen.js";
import { readCurrent, readToken, recordJoinedRoom, writeCurrent, writeToken } from "../../state.js";

export interface RoomCommandHooks {
// Injectable so tests can deterministically exercise the bind-error path without
// depending on OS hostname resolution.
listen?: (server: Server, port: number, host: string) => Promise<ListenOutcome>;
}

// Printed to stderr whenever `room serve --allow-remote` starts (#180). Assumes
// HTTPS is terminated at the edge (tunnel/reverse proxy); Agent Gather adds no
// TLS of its own. See docs/self-tunnel.md for the full BYO-tunnel trust boundary.
Expand All @@ -68,7 +76,11 @@ export const ALLOW_REMOTE_WARNING = [
" and let the tunnel own public TLS. See docs/self-tunnel.md."
].join("\n");

export async function runRoomCommand(argv: string[], context: CliContext): Promise<number> {
export async function runRoomCommand(
argv: string[],
context: CliContext,
hooks: RoomCommandHooks = {}
): Promise<number> {
const [subcommand, ...rest] = argv;
if (subcommand === "start") return roomStart(rest, context);
if (subcommand === "create-boardroom") return roomCreateBoardroom(rest, context);
Expand All @@ -84,7 +96,7 @@ export async function runRoomCommand(argv: string[], context: CliContext): Promi
if (subcommand === "brief") return roomBrief(rest, context);
if (subcommand === "attendance") return roomAttendance(rest, context);
if (subcommand === "session") return roomSession(rest, context);
if (subcommand === "serve") return roomServe(rest, context);
if (subcommand === "serve") return roomServe(rest, context, hooks);
if (subcommand === "launch") return roomLaunch(rest, context);
if (subcommand === "runtime-status") return roomRuntimeStatus(rest, context);
if (subcommand === "invite") return roomInvite(rest, context);
Expand Down Expand Up @@ -896,7 +908,7 @@ async function probeRuntime(publicUrl: string): Promise<boolean> {
}
}

async function roomServe(argv: string[], context: CliContext): Promise<number> {
async function roomServe(argv: string[], context: CliContext, hooks: RoomCommandHooks = {}): Promise<number> {
const args = parseArgs(argv);
const current = await readCurrent(context.home);
const currentUrl = new URL(current.baseUrl);
Expand All @@ -920,9 +932,12 @@ async function roomServe(argv: string[], context: CliContext): Promise<number> {
// wait commands; otherwise keep advertising the local serve URL.
publicBaseUrl: () => readPublicBaseUrl(context.home, current.roomId) ?? localBaseUrl
});
await new Promise<void>((resolve) => {
server.listen(port, host, resolve);
});
const listen = hooks.listen ?? listenOrError;
const outcome = await listen(server, port, host);
if (!outcome.ok) {
context.stderr.write(`${listenErrorMessage(host, port, outcome.error)}\n`);
return 1;
}
await writeCurrent(context.home, { ...current, baseUrl: localBaseUrl });
// Sharp guardrail (#180): --allow-remote assumes HTTPS is terminated at the
// edge (tunnel/reverse proxy). Warn loudly so nobody exposes plain HTTP or
Expand Down
Loading
Loading