diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f4dc70fe..935cc040 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, D import { resolveHttpsOptions } from "./server/tls" import { RemoteProxySessionManager } from "./server/remote-proxy" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" +import { resolvePluginBaseUrl } from "./server/listener-base-url" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" import { SideCarManager } from "./sidecars/manager" @@ -490,14 +491,8 @@ async function main() { } const remoteStart = httpsStart ?? httpStart - const localProtocol: "http" | "https" = httpStart ? "http" : "https" const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http" - // Use an explicit IPv4 loopback address for the "local" URL. - // On macOS, `localhost` often resolves to ::1 first, and it is possible to have - // another instance bound on IPv6 while this instance binds IPv4 (or vice versa), - // which can lead clients to talk to the wrong process. - const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` let remoteUrl: string | undefined let remoteAddresses = [] as ReturnType if (remoteStart) { @@ -517,6 +512,15 @@ async function main() { } } + // Prefer an explicit IPv4 loopback address only when one of the bound listeners + // accepts loopback. Concrete LAN bindings do not, so plugins need the reachable + // bound/listener URL instead of an unreachable 127.0.0.1 URL. + const localUrl = resolvePluginBaseUrl({ + httpStart: httpStart ? { protocol: "http", bindHost: httpBindHost, port: httpStart.port } : null, + httpsStart: httpsStart ? { protocol: "https", bindHost: httpsBindHost, port: httpsStart.port } : null, + remoteUrl, + }) + serverMeta.localUrl = localUrl serverMeta.localPort = localStart.port serverMeta.remoteUrl = remoteUrl diff --git a/packages/server/src/server/__tests__/listener-base-url.test.ts b/packages/server/src/server/__tests__/listener-base-url.test.ts new file mode 100644 index 00000000..f742e68e --- /dev/null +++ b/packages/server/src/server/__tests__/listener-base-url.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { resolvePluginBaseUrl } from "../listener-base-url" + +describe("resolvePluginBaseUrl", () => { + it("keeps loopback URLs for default local listeners", () => { + assert.equal( + resolvePluginBaseUrl({ + httpsStart: { protocol: "https", bindHost: "127.0.0.1", port: 9898 }, + remoteUrl: "https://localhost:9898", + }), + "https://127.0.0.1:9898", + ) + }) + + it("uses the concrete LAN listener when no loopback listener exists", () => { + assert.equal( + resolvePluginBaseUrl({ + httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", + }), + "https://192.168.1.25:9898", + ) + }) + + it("prefers loopback for wildcard listeners because 0.0.0.0 accepts loopback", () => { + assert.equal( + resolvePluginBaseUrl({ + httpsStart: { protocol: "https", bindHost: "0.0.0.0", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", + }), + "https://127.0.0.1:9898", + ) + }) + + it("keeps loopback HTTP when remote HTTPS also exists", () => { + assert.equal( + resolvePluginBaseUrl({ + httpStart: { protocol: "http", bindHost: "127.0.0.1", port: 9899 }, + httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", + }), + "http://127.0.0.1:9899", + ) + }) +}) diff --git a/packages/server/src/server/listener-base-url.ts b/packages/server/src/server/listener-base-url.ts new file mode 100644 index 00000000..6e68b0a0 --- /dev/null +++ b/packages/server/src/server/listener-base-url.ts @@ -0,0 +1,33 @@ +export interface StartedListenerBaseUrlInput { + protocol: "http" | "https" + bindHost: string + port: number +} + +export interface ResolvePluginBaseUrlInput { + httpStart?: StartedListenerBaseUrlInput | null + httpsStart?: StartedListenerBaseUrlInput | null + remoteUrl?: string +} + +export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { + const loopbackListener = [input.httpStart, input.httpsStart].find((listener) => listener && acceptsLoopback(listener.bindHost)) + if (loopbackListener) { + return `${loopbackListener.protocol}://127.0.0.1:${loopbackListener.port}` + } + + if (input.remoteUrl) { + return input.remoteUrl + } + + const fallbackListener = input.httpStart ?? input.httpsStart + if (!fallbackListener) { + throw new Error("No listeners started") + } + + return `${fallbackListener.protocol}://${fallbackListener.bindHost}:${fallbackListener.port}` +} + +function acceptsLoopback(bindHost: string): boolean { + return bindHost === "0.0.0.0" || bindHost === "::" || bindHost === "localhost" || bindHost === "::1" || bindHost.startsWith("127.") +} diff --git a/packages/server/src/workspaces/__tests__/git-clone.test.ts b/packages/server/src/workspaces/__tests__/git-clone.test.ts index 8798d48a..7de89462 100644 --- a/packages/server/src/workspaces/__tests__/git-clone.test.ts +++ b/packages/server/src/workspaces/__tests__/git-clone.test.ts @@ -160,30 +160,27 @@ describe("cloneGitRepository", () => { } }) - it( - "supports destinations directly under a Windows drive root", - { skip: process.platform !== "win32" }, - async () => { - const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) - const sourceRepo = path.join(temp, "source.git") - const root = path.parse(process.cwd()).root - const destinationPath = path.join(root, `codenomad-git-clone-root-${Date.now()}-${Math.random().toString(36).slice(2)}`) + it("supports destinations directly under a safe parent", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + const root = path.parse(process.cwd()).root + const parentPath = process.platform === "win32" ? root : temp + const destinationPath = path.join(parentPath, `codenomad-git-clone-root-${Date.now()}-${Math.random().toString(36).slice(2)}`) - try { - createBareRepository(sourceRepo) - rmSync(destinationPath, { recursive: true, force: true }) + try { + createBareRepository(sourceRepo) + rmSync(destinationPath, { recursive: true, force: true }) - const result = await cloneGitRepository({ - repositoryUrl: sourceRepo, - destinationPath, - }) + const result = await cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath, + }) - assert.equal(result.path, destinationPath) - assert.equal(existsSync(destinationPath), true) - } finally { - rmSync(destinationPath, { recursive: true, force: true }) - rmSync(temp, { recursive: true, force: true }) - } - }, - ) + assert.equal(result.path, destinationPath) + assert.equal(existsSync(destinationPath), true) + } finally { + rmSync(destinationPath, { recursive: true, force: true }) + rmSync(temp, { recursive: true, force: true }) + } + }) })