From 1dd77db5a633be153ea3a928bce20139e9513e43 Mon Sep 17 00:00:00 2001 From: Kristian Mide Date: Thu, 27 Aug 2026 21:55:26 +0000 Subject: [PATCH] Add --listen-addr option for server binding --- README.md | 4 ++++ src/start.ts | 26 +++++++++++++++++++++++++- tests/start.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/start.test.ts diff --git a/README.md b/README.md index 0d36c13c9..d96b877f2 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ The following command line options are available for the `start` command: | Option | Description | Default | Alias | | -------------- | ----------------------------------------------------------------------------- | ---------- | ----- | | --port | Port to listen on | 4141 | -p | +| --listen-addr | Address to bind the server to (for example 127.0.0.1, 0.0.0.0, or ::1) | all interfaces | none | | --verbose | Enable verbose logging | false | -v | | --account-type | Account type to use (individual, business, enterprise) | individual | -a | | --manual | Enable manual request approval | false | none | @@ -220,6 +221,9 @@ npx copilot-api@latest start # Run on custom port with verbose logging npx copilot-api@latest start --port 8080 --verbose +# Bind to localhost only instead of all interfaces +npx copilot-api@latest start --port 8080 --listen-addr 127.0.0.1 + # Use with a business plan GitHub account npx copilot-api@latest start --account-type business diff --git a/src/start.ts b/src/start.ts index 14abbbdff..abac6faaa 100644 --- a/src/start.ts +++ b/src/start.ts @@ -16,6 +16,7 @@ import { server } from "./server" interface RunServerOptions { port: number + listenAddr?: string verbose: boolean accountType: string manual: boolean @@ -27,6 +28,22 @@ interface RunServerOptions { proxyEnv: boolean } +function isAllInterfacesListenAddr(listenAddr: string): boolean { + return ["::", "[::]", "0.0.0.0"].includes(listenAddr) +} + +function formatHostForUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host +} + +export function getServerUrl(port: number, listenAddr?: string): string { + if (!listenAddr || isAllInterfacesListenAddr(listenAddr)) { + return `http://localhost:${port}` + } + + return `http://${formatHostForUrl(listenAddr)}:${port}` +} + export async function runServer(options: RunServerOptions): Promise { if (options.proxyEnv) { initProxyFromEnv() @@ -64,7 +81,7 @@ export async function runServer(options: RunServerOptions): Promise { `Available models: \n${state.models?.data.map((model) => `- ${model.id}`).join("\n")}`, ) - const serverUrl = `http://localhost:${options.port}` + const serverUrl = getServerUrl(options.port, options.listenAddr) if (options.claudeCode) { invariant(state.models, "Models should be loaded by now") @@ -117,6 +134,7 @@ export async function runServer(options: RunServerOptions): Promise { serve({ fetch: server.fetch as ServerHandler, port: options.port, + hostname: options.listenAddr, }) } @@ -132,6 +150,11 @@ export const start = defineCommand({ default: "4141", description: "Port to listen on", }, + "listen-addr": { + type: "string", + description: + "Address to bind the server to (for example 127.0.0.1, 0.0.0.0, or ::1)", + }, verbose: { alias: "v", type: "boolean", @@ -193,6 +216,7 @@ export const start = defineCommand({ return runServer({ port: Number.parseInt(args.port, 10), + listenAddr: args["listen-addr"], verbose: args.verbose, accountType: args["account-type"], manual: args.manual, diff --git a/tests/start.test.ts b/tests/start.test.ts new file mode 100644 index 000000000..9e935157f --- /dev/null +++ b/tests/start.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" + +import { getServerUrl } from "../src/start" + +describe("getServerUrl", () => { + test("uses localhost when listen address is omitted", () => { + expect(getServerUrl(4141)).toBe("http://localhost:4141") + }) + + test("uses localhost when listening on all IPv4 interfaces", () => { + expect(getServerUrl(4141, "0.0.0.0")).toBe("http://localhost:4141") + }) + + test("uses localhost when listening on all IPv6 interfaces", () => { + expect(getServerUrl(4141, "::")).toBe("http://localhost:4141") + expect(getServerUrl(4141, "[::]")).toBe("http://localhost:4141") + }) + + test("uses the configured address for loopback IPv4", () => { + expect(getServerUrl(4141, "127.0.0.1")).toBe("http://127.0.0.1:4141") + }) + + test("formats IPv6 addresses correctly", () => { + expect(getServerUrl(4141, "::1")).toBe("http://[::1]:4141") + }) +})