mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-09 16:00:52 +00:00
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:
parent
7812a0950f
commit
b2855f4312
4 changed files with 110 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
33
packages/server/src/server/listener-base-url.ts
Normal file
33
packages/server/src/server/listener-base-url.ts
Normal 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.")
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue