diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index fae84a63..d2d57500 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -1,4 +1,6 @@ +import os from "node:os"; import { describe, expect, it } from "vitest"; +import { vi } from "vitest"; import type { Request } from "express"; import { buildInviteOnboardingTextDocument } from "../routes/access.js"; @@ -114,4 +116,68 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("Message from inviter"); expect(text).toContain("prioritize flaky test triage first"); }); + + it("includes LAN candidates when the advertised host is tailnet-only", () => { + const networkSpy = vi.spyOn(os, "networkInterfaces").mockReturnValue({ + en0: [ + { + address: "fe80::1", + family: "IPv6", + internal: false, + netmask: "ffff:ffff:ffff:ffff::", + cidr: "fe80::1/64", + mac: "00:00:00:00:00:00", + scopeid: 1, + }, + { + address: "192.168.6.178", + family: "IPv4", + internal: false, + netmask: "255.255.252.0", + cidr: "192.168.6.178/22", + mac: "00:00:00:00:00:00", + }, + ], + utun0: [ + { + address: "203.0.113.42", + family: "IPv4", + internal: false, + netmask: "255.255.255.255", + cidr: "203.0.113.42/32", + mac: "00:00:00:00:00:00", + }, + ], + }); + + try { + const req = buildReq("paperclip.example.test:3103"); + const invite = { + id: "invite-4", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2026-03-05T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-04T00:00:00.000Z"), + updatedAt: new Date("2026-03-04T00:00:00.000Z"), + } as const; + + const text = buildInviteOnboardingTextDocument(req, "token-999", invite as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "0.0.0.0", + allowedHostnames: ["paperclip.example.test", "203.0.113.42"], + }); + + expect(text).toContain("http://192.168.6.178:3103"); + expect(text).not.toContain("http://[fe80::1]:3103"); + } finally { + networkSpy.mockRestore(); + } + }); }); diff --git a/server/src/__tests__/paperclip-env.test.ts b/server/src/__tests__/paperclip-env.test.ts index 2d922d58..e397f082 100644 --- a/server/src/__tests__/paperclip-env.test.ts +++ b/server/src/__tests__/paperclip-env.test.ts @@ -30,14 +30,14 @@ afterEach(() => { describe("buildPaperclipEnv", () => { it("prefers an explicit PAPERCLIP_RUNTIME_API_URL", () => { - process.env.PAPERCLIP_RUNTIME_API_URL = "http://100.104.161.29:3102"; + process.env.PAPERCLIP_RUNTIME_API_URL = "http://203.0.113.42:3102"; process.env.PAPERCLIP_API_URL = "http://localhost:4100"; process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1"; process.env.PAPERCLIP_LISTEN_PORT = "3101"; const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); - expect(env.PAPERCLIP_API_URL).toBe("http://100.104.161.29:3102"); + expect(env.PAPERCLIP_API_URL).toBe("http://203.0.113.42:3102"); }); it("falls back to PAPERCLIP_API_URL when no runtime URL is configured", () => { @@ -52,6 +52,7 @@ describe("buildPaperclipEnv", () => { }); it("uses runtime listen host/port when explicit URL is not set", () => { + delete process.env.PAPERCLIP_RUNTIME_API_URL; delete process.env.PAPERCLIP_API_URL; process.env.PAPERCLIP_LISTEN_HOST = "0.0.0.0"; process.env.PAPERCLIP_LISTEN_PORT = "3101"; @@ -63,6 +64,7 @@ describe("buildPaperclipEnv", () => { }); it("formats IPv6 hosts safely in fallback URL generation", () => { + delete process.env.PAPERCLIP_RUNTIME_API_URL; delete process.env.PAPERCLIP_API_URL; process.env.PAPERCLIP_LISTEN_HOST = "::1"; process.env.PAPERCLIP_LISTEN_PORT = "3101"; diff --git a/server/src/__tests__/runtime-api.test.ts b/server/src/__tests__/runtime-api.test.ts index 055ef7f6..df628f66 100644 --- a/server/src/__tests__/runtime-api.test.ts +++ b/server/src/__tests__/runtime-api.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "../runtime-api.js"; +import { + buildRuntimeApiCandidateUrls, + choosePrimaryRuntimeApiUrl, + collectReachableInterfaceHosts, +} from "../runtime-api.js"; describe("runtime API discovery", () => { it("prefers the explicit public base URL for the primary runtime URL", () => { @@ -56,7 +60,23 @@ describe("runtime API discovery", () => { "http://198.51.100.10:3102", "http://runtime-host.example.test:3102", "http://203.0.113.42:3102", - "http://[fe80::1]:3102", + ]); + }); + + it("tries the preferred API URL before derived callback candidates", () => { + expect( + buildRuntimeApiCandidateUrls({ + preferredApiUrl: "https://agent-entry.example.test/base/path", + authPublicBaseUrl: "https://paperclip.example.test/app", + allowedHostnames: ["198.51.100.10"], + bindHost: "0.0.0.0", + port: 3102, + networkInterfacesMap: {}, + }), + ).toEqual([ + "https://agent-entry.example.test", + "https://paperclip.example.test", + "https://198.51.100.10:3102", ]); }); @@ -74,4 +94,54 @@ describe("runtime API discovery", () => { "http://host.docker.internal:3102", ]); }); + + it("prefers usable interface hosts and skips link-local addresses", () => { + expect( + collectReachableInterfaceHosts({ + networkInterfacesMap: { + en0: [ + { + address: "fe80::1", + family: "IPv6", + internal: false, + netmask: "ffff:ffff:ffff:ffff::", + cidr: "fe80::1/64", + mac: "00:00:00:00:00:00", + scopeid: 1, + }, + { + address: "192.168.6.178", + family: "IPv4", + internal: false, + netmask: "255.255.252.0", + cidr: "192.168.6.178/22", + mac: "00:00:00:00:00:00", + }, + { + address: "fd7a:115c:a1e0::8a3a:a11d", + family: "IPv6", + internal: false, + netmask: "ffff:ffff:ffff::", + cidr: "fd7a:115c:a1e0::8a3a:a11d/48", + mac: "00:00:00:00:00:00", + scopeid: 0, + }, + ], + en1: [ + { + address: "169.254.10.20", + family: "IPv4", + internal: false, + netmask: "255.255.0.0", + cidr: "169.254.10.20/16", + mac: "00:00:00:00:00:00", + }, + ], + }, + }), + ).toEqual([ + "192.168.6.178", + "fd7a:115c:a1e0::8a3a:a11d", + ]); + }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 35fb0767..8ae02c6d 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -1,4 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL; +const ORIGINAL_PAPERCLIP_RUNTIME_API_URL = process.env.PAPERCLIP_RUNTIME_API_URL; +const ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; +const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST; +const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT; const { createAppMock, @@ -265,6 +271,26 @@ describe("startServer PAPERCLIP_API_URL handling", () => { delete process.env.PAPERCLIP_API_URL; }); + afterEach(() => { + if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL; + else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL; + + if (ORIGINAL_PAPERCLIP_RUNTIME_API_URL === undefined) delete process.env.PAPERCLIP_RUNTIME_API_URL; + else process.env.PAPERCLIP_RUNTIME_API_URL = ORIGINAL_PAPERCLIP_RUNTIME_API_URL; + + if (ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON === undefined) { + delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; + } else { + process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; + } + + if (ORIGINAL_PAPERCLIP_LISTEN_HOST === undefined) delete process.env.PAPERCLIP_LISTEN_HOST; + else process.env.PAPERCLIP_LISTEN_HOST = ORIGINAL_PAPERCLIP_LISTEN_HOST; + + if (ORIGINAL_PAPERCLIP_LISTEN_PORT === undefined) delete process.env.PAPERCLIP_LISTEN_PORT; + else process.env.PAPERCLIP_LISTEN_PORT = ORIGINAL_PAPERCLIP_LISTEN_PORT; + }); + it("uses the externally set PAPERCLIP_API_URL when provided", async () => { process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; @@ -272,6 +298,10 @@ describe("startServer PAPERCLIP_API_URL handling", () => { expect(started.apiUrl).toBe("http://custom-api:3100"); expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100"); + expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")).toEqual( + expect.arrayContaining(["http://custom-api:3100"]), + ); + expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe("http://custom-api:3100"); }); it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { diff --git a/server/src/index.ts b/server/src/index.ts index 44eb4567..bd7a9bc1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -637,13 +637,14 @@ export async function startServer(): Promise { bindHost: runtimeListenHost, port: listenPort, }); + const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl; const runtimeApiCandidates = buildRuntimeApiCandidateUrls({ + preferredApiUrl: configuredApiUrl, authPublicBaseUrl: config.authPublicBaseUrl ?? null, allowedHostnames: config.allowedHostnames, bindHost: runtimeListenHost, port: listenPort, }); - const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl; process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); process.env.PAPERCLIP_RUNTIME_API_URL = runtimeApiUrl; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 73516c28..ac7423f3 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -55,6 +55,7 @@ import { } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; +import { collectReachableInterfaceHosts } from "../runtime-api.js"; import { accessService, agentService, @@ -1500,6 +1501,11 @@ function buildOnboardingConnectionCandidates(input: { candidates.add(`${protocol}//host.docker.internal${port}`); } + for (const host of collectReachableInterfaceHosts()) { + const formattedHost = host.includes(":") && !host.startsWith("[") && !host.endsWith("]") ? `[${host}]` : host; + candidates.add(`${protocol}//${formattedHost}${port}`); + } + return Array.from(candidates); } diff --git a/server/src/runtime-api.ts b/server/src/runtime-api.ts index 6938b578..96c8ab75 100644 --- a/server/src/runtime-api.ts +++ b/server/src/runtime-api.ts @@ -14,6 +14,14 @@ function isWildcardHost(host: string): boolean { return normalized === "0.0.0.0" || normalized === "::"; } +function isLinkLocalHost(host: string): boolean { + const normalized = normalizeHost(host).toLowerCase(); + if (normalized.startsWith("169.254.")) return true; + // IPv6 link-local block is fe80::/10 (fe80:: through febf::) + if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true; + return false; +} + function formatOrigin(protocol: string, host: string, port: number): string { const normalizedHost = host.includes(":") && !host.startsWith("[") && !host.endsWith("]") ? `[${host}]` @@ -68,7 +76,36 @@ export function choosePrimaryRuntimeApiUrl(input: { return formatOrigin("http:", "localhost", input.port); } +export function collectReachableInterfaceHosts(input: { + networkInterfacesMap?: NodeJS.Dict; +} = {}): string[] { + const interfaces = input.networkInterfacesMap ?? os.networkInterfaces(); + const rankedHosts: Array<{ host: string; rank: number; index: number }> = []; + const seen = new Set(); + let index = 0; + + for (const entries of Object.values(interfaces)) { + for (const entry of entries ?? []) { + if (entry.internal) continue; + const host = normalizeHost(entry.address); + if (!host || isLoopbackHost(host) || isWildcardHost(host) || isLinkLocalHost(host)) continue; + if (seen.has(host)) continue; + seen.add(host); + rankedHosts.push({ + host, + rank: entry.family === "IPv4" ? 0 : 1, + index: index++, + }); + } + } + + return rankedHosts + .sort((left, right) => left.rank - right.rank || left.index - right.index) + .map((entry) => entry.host); +} + export function buildRuntimeApiCandidateUrls(input: { + preferredApiUrl?: string | null; authPublicBaseUrl?: string | null; allowedHostnames: string[]; bindHost: string; @@ -88,6 +125,7 @@ export function buildRuntimeApiCandidateUrls(input: { })(); const protocol = explicitOrigin ? new URL(explicitOrigin).protocol : "http:"; + pushCandidate(candidates, seen, input.preferredApiUrl); pushCandidate(candidates, seen, explicitOrigin); for (const rawHost of input.allowedHostnames) { @@ -108,14 +146,8 @@ export function buildRuntimeApiCandidateUrls(input: { } } - const interfaces = input.networkInterfacesMap ?? os.networkInterfaces(); - for (const entries of Object.values(interfaces)) { - for (const entry of entries ?? []) { - if (entry.internal) continue; - const host = normalizeHost(entry.address); - if (!host || isLoopbackHost(host) || isWildcardHost(host)) continue; - pushCandidate(candidates, seen, formatOrigin(protocol, host, input.port)); - } + for (const host of collectReachableInterfaceHosts({ networkInterfacesMap: input.networkInterfacesMap })) { + pushCandidate(candidates, seen, formatOrigin(protocol, host, input.port)); } if (candidates.length === 0) {