fix: handle plugin base URL for host binding (#512)

## Summary
- Resolve the plugin fetch/SSE base URL from actual listener bindings
instead of always publishing 127.0.0.1.
- Preserve loopback plugin loading for default local, localhost,
wildcard, and mixed HTTP/HTTPS listener setups.
- Make the git clone destination test portable so server tests run with
zero skips.

## Validation
- npm run typecheck --workspace @neuralnomads/codenomad
- node --import tsx --test
packages/server/src/server/__tests__/listener-base-url.test.ts
packages/server/src/server/__tests__/network-addresses.test.ts
packages/server/src/workspaces/__tests__/git-clone.test.ts — 15 pass / 0
fail / 0 skipped
- shopt -s globstar && node --import tsx --test
packages/server/src/**/*.test.ts — 59 pass / 0 fail / 0 skipped

## Notes
- Nomad task/evidence artifacts intentionally excluded from the commit
per maintainer request.
This commit is contained in:
Omer Cohen 2026-05-31 21:10:34 +03:00 committed by GitHub
parent 7812a0950f
commit b2855f4312
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 29 deletions

View file

@ -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<typeof resolveNetworkAddresses>
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

View file

@ -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",
)
})
})

View file

@ -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.")
}

View file

@ -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 })
}
})
})