FN-8202: prevent mDNS hostname conflicts

Prevent self-conflicting mDNS advertisements and allow automatic LAN discovery to be disabled.

- Advertise a Fusion-owned DNS-SD hostname derived from the node ID.
- Add a global automatic LAN discovery opt-out for dashboard and serve.
- Cover hostname isolation and discovery startup behavior with tests and documentation.

Files changed:
 .changeset/fn-8202-mdns-hostname-fix.md            |  7 +++++
 docs/settings-reference.md                         |  1 +
 docs/shared-mesh-protocol.md                       |  2 +-
 .../cli/src/commands/__tests__/dashboard.test.ts   | 24 ++++++++++++++++
 packages/cli/src/commands/__tests__/serve.test.ts  | 12 ++++++++
 packages/cli/src/commands/dashboard.ts             | 24 +++++++++++-----
 packages/cli/src/commands/serve.ts                 | 24 +++++++++++-----
 packages/core/src/__tests__/node-discovery.test.ts | 32 +++++++++++++++++++++-
 .../core/src/__tests__/settings-defaults.test.ts   |  8 ++++++
 packages/core/src/node-discovery.ts                | 31 +++++++++++++++++++++
 packages/core/src/settings-schema.ts               |  5 ++++
 packages/core/src/types.ts                         |  2 ++
 12 files changed, 156 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-8202

Fusion-Task-Lineage: c1d6e36b-bda7-4968-b40c-c14e16ebda28

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 09:54:44 -07:00
parent 7fc4b439ee
commit 67fac31f5c
12 changed files with 156 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stop fn dashboard from making macOS rename its own local hostname over mDNS.
category: fix
dev: node-discovery now advertises a Fusion-owned mDNS host (fusion-<nodeId8>) instead of os.hostname(), avoiding the self-conflict rename; adds global setting `localNetworkDiscoveryEnabled` (default true) to disable LAN auto-discovery in `fn dashboard`/`fn serve`.

View File

@@ -107,6 +107,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. | | `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. |
| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. | | `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. | | `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `localNetworkDiscoveryEnabled` | `boolean` | `true` | Enables automatic `_fusion._tcp` LAN broadcast and listening when `fn dashboard` or `fn serve` starts. Set `false` to opt out of automatic mDNS/DNS-SD discovery; an explicit operator `POST /api/discovery/start` request remains available. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. | | `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. |
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. | | `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |

View File

@@ -82,7 +82,7 @@ Still useful under shared Postgres:
| `POST /api/mesh/sync` | Peer gossip: `knownPeers` (+ optional `authMaterial` only) | | `POST /api/mesh/sync` | Peer gossip: `knownPeers` (+ optional `authMaterial` only) |
| `POST/GET /api/mesh/task-ids/*` | Local allocator against shared ID tables (no remote coordinator hop) | | `POST/GET /api/mesh/task-ids/*` | Local allocator against shared ID tables (no remote coordinator hop) |
| Auth sync routes | Optional credential fan-out for file-local auth | | Auth sync routes | Optional credential fan-out for file-local auth |
| mDNS discovery | Join convenience, not task SoT | | mDNS discovery | Join convenience, not task SoT. `_fusion._tcp` advertises a Fusion-owned `fusion-<nodeId8>` host rather than the OS hostname; set global `localNetworkDiscoveryEnabled: false` to disable dashboard/serve auto-start. |
| Docker mesh config generator | Provision managed peers | | Docker mesh config generator | Provision managed peers |
Removed / disabled: Removed / disabled:

View File

@@ -3002,6 +3002,30 @@ describe("runDashboard — mesh lifecycle ownership", () => {
dispose(); dispose();
}); });
it("skips automatic discovery when local network discovery is disabled", async () => {
const { CentralCore } = await import("@fusion/core");
const startDiscovery = vi.fn().mockResolvedValue(undefined);
mockGlobalSettingsGetSettings.mockResolvedValue({ localNetworkDiscoveryEnabled: false });
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]),
updateNode: vi.fn().mockResolvedValue(undefined),
startDiscovery,
stopDiscovery: vi.fn(),
}));
const { dispose } = await runDashboard(0, { open: false });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(startDiscovery).not.toHaveBeenCalled();
dispose();
});
it("stops peer exchange and discovery during shutdown", async () => { it("stops peer exchange and discovery during shutdown", async () => {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
const { PeerExchangeService } = await import("@fusion/engine"); const { PeerExchangeService } = await import("@fusion/engine");

View File

@@ -1786,6 +1786,18 @@ describe("runServe — Peer exchange and discovery", () => {
await triggerSignal("SIGINT"); await triggerSignal("SIGINT");
}); });
it("skips automatic discovery when local network discovery is disabled", async () => {
mocks.globalSettingsGetSettings.mockResolvedValue({ localNetworkDiscoveryEnabled: false });
await runServe(4040, {});
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
expect(nodeCentral).toBeDefined();
expect(nodeCentral.startDiscovery).not.toHaveBeenCalled();
await triggerSignal("SIGINT");
});
it("starts discovery with port 5050 when port 0 is requested", async () => { it("starts discovery with port 5050 when port 0 is requested", async () => {
await runServe(0, {}); await runServe(0, {});

View File

@@ -2738,13 +2738,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// //
if (centralCoreForMesh) { if (centralCoreForMesh) {
try { try {
await centralCoreForMesh.startDiscovery({ const globalSettings = await store.getGlobalSettingsStore().getSettings();
broadcast: true, /*
listen: true, * FNXC:NodeDiscovery 2026-07-17-12:00:
serviceType: "_fusion._tcp", * FN-8202 requires dashboard boot to respect the global LAN discovery
port: actualPort, * opt-out. The explicit discovery API remains an operator override.
staleTimeoutMs: 300_000, */
}); if (globalSettings.localNetworkDiscoveryEnabled === false) {
logSink.warn("LAN discovery disabled by localNetworkDiscoveryEnabled setting", "dashboard");
} else {
await centralCoreForMesh.startDiscovery({
broadcast: true,
listen: true,
serviceType: "_fusion._tcp",
port: actualPort,
staleTimeoutMs: 300_000,
});
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard"); logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard");

View File

@@ -1075,13 +1075,23 @@ export async function runServe(
// //
if (sharedCentralCore) { if (sharedCentralCore) {
try { try {
await sharedCentralCore.startDiscovery({ const globalSettings = await store.getGlobalSettingsStore().getSettings();
broadcast: true, /*
listen: true, * FNXC:NodeDiscovery 2026-07-17-12:00:
serviceType: "_fusion._tcp", * FN-8202 makes automatic LAN discovery opt-outable at daemon boot;
port: actualPort, * explicit POST /api/discovery/start requests intentionally still work.
staleTimeoutMs: 300_000, */
}); if (globalSettings.localNetworkDiscoveryEnabled === false) {
console.warn("[serve] LAN discovery disabled by localNetworkDiscoveryEnabled setting");
} else {
await sharedCentralCore.startDiscovery({
broadcast: true,
listen: true,
serviceType: "_fusion._tcp",
port: actualPort,
staleTimeoutMs: 300_000,
});
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[serve] Failed to start mDNS discovery: ${message}`); console.warn(`[serve] Failed to start mDNS discovery: ${message}`);

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import os from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { DiscoveryConfig, DiscoveredNode } from "../types.js"; import type { DiscoveryConfig, DiscoveredNode } from "../types.js";
@@ -97,6 +98,7 @@ describe("NodeDiscovery", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks();
}); });
it("starts and stops broadcast mode", () => { it("starts and stops broadcast mode", () => {
@@ -128,11 +130,39 @@ describe("NodeDiscovery", () => {
expect(publishMock).toHaveBeenCalledWith( expect(publishMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: expect.stringMatching(/-_local_1$/), name: expect.stringMatching(/-_local_1$/),
}), }),
); );
}); });
it.each([
[" ", "node_local_1", "fusion-delocal1"],
["A very long local node name that is not used as the mDNS host", "node_abcdefghijk", "fusion-defghijk"],
["Local", "id", "fusion-id"],
])("publishes a Fusion-owned host for nodeName %j and nodeId %j", (nodeName, nodeId, expectedHost) => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
discovery.start(nodeId, nodeName);
const config = publishMock.mock.calls[0]?.[0] as { host?: string };
expect(config.host).toBe(expectedHost);
expect(config.host).toMatch(/^fusion-[a-z0-9-]+$/);
expect(config.host?.replace(/\.local\.?$/i, "").toLowerCase()).not.toBe(
os.hostname().replace(/\.local\.?$/i, "").toLowerCase(),
);
});
it("never publishes the OS hostname when it matches the naïve Fusion host", () => {
vi.spyOn(os, "hostname").mockReturnValue("fusion-delocal1.local");
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
discovery.start("node_local_1", "Local");
const config = publishMock.mock.calls[0]?.[0] as { host?: string };
expect(config.host).toBe("fusion-delocal1-service");
expect(config.host?.replace(/\.local\.?$/i, "").toLowerCase()).not.toBe(
os.hostname().replace(/\.local\.?$/i, "").toLowerCase(),
);
});
it("starts listen mode and emits node:discovered/node:lost", () => { it("starts listen mode and emits node:discovered/node:lost", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true })); const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn(); const discoveredHandler = vi.fn();

View File

@@ -28,6 +28,14 @@ describe("settings defaults invariants", () => {
expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined();
}); });
it("defaults local network discovery on and keeps its opt-out global-only", () => {
expect(DEFAULT_GLOBAL_SETTINGS.localNetworkDiscoveryEnabled).toBe(true);
expect(GLOBAL_SETTINGS_KEYS).toContain("localNetworkDiscoveryEnabled");
expect(PROJECT_SETTINGS_KEYS).not.toContain("localNetworkDiscoveryEnabled");
expect("localNetworkDiscoveryEnabled" in DEFAULT_PROJECT_SETTINGS).toBe(false);
expect(isGlobalOnlySettingsKey("localNetworkDiscoveryEnabled")).toBe(true);
});
it("defaults dashboard keyboard shortcuts globally", () => { it("defaults dashboard keyboard shortcuts globally", () => {
expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({
quickChat: "Space", quickChat: "Space",

View File

@@ -18,6 +18,28 @@ const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = {
const STALE_CLEANUP_INTERVAL_MS = 60_000; const STALE_CLEANUP_INTERVAL_MS = 60_000;
const FUSION_VERSION = "0.1.0"; const FUSION_VERSION = "0.1.0";
function normalizeMdnsHost(host: string): string {
return host.trim().replace(/\.local\.?$/i, "").toLowerCase();
}
/**
* Returns a bounded, DNS-label-safe hostname owned by Fusion rather than the OS.
*/
function deriveFusionMdnsHost(nodeId: string): string {
const suffix = nodeId
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
.slice(-8)
.replace(/^-+|-+$/g, "") || "node";
const host = `fusion-${suffix}`;
// An unusually named host can equal the naïve Fusion label; retain ownership
// while guaranteeing that Fusion never claims the OS hostname.
return normalizeMdnsHost(host) === normalizeMdnsHost(os.hostname())
? `${host}-service`
: host;
}
interface NodeDiscoveryEvents { interface NodeDiscoveryEvents {
"node:discovered": [node: DiscoveredNode]; "node:discovered": [node: DiscoveredNode];
"node:updated": [node: DiscoveredNode]; "node:updated": [node: DiscoveredNode];
@@ -111,8 +133,16 @@ export class NodeDiscovery extends EventEmitter<NodeDiscoveryEvents> {
const bonjour = this.getBonjour(); const bonjour = this.getBonjour();
const serviceType = this.parseServiceType(this.config.serviceType); const serviceType = this.parseServiceType(this.config.serviceType);
const host = deriveFusionMdnsHost(nodeId);
try { try {
/*
* FNXC:NodeDiscovery 2026-07-17-12:00:
* bonjour-service probes the DNS-SD instance FQDN but not its advertised
* A/SRV host. FN-8202 found that re-announcing the OS hostname made macOS
* mDNSResponder self-conflict and rename the host, so publish a
* Fusion-owned target instead of os.hostname().
*/
this.broadcastService = bonjour.publish({ this.broadcastService = bonjour.publish({
/* /*
* FNXC:NodeDiscovery 2026-07-15-18:05: * FNXC:NodeDiscovery 2026-07-15-18:05:
@@ -122,6 +152,7 @@ export class NodeDiscovery extends EventEmitter<NodeDiscoveryEvents> {
* optional mDNS collision from disrupting the dashboard process. * optional mDNS collision from disrupting the dashboard process.
*/ */
name: `${nodeName.trim() || os.hostname()}-${nodeId.slice(-8)}`, name: `${nodeName.trim() || os.hostname()}-${nodeId.slice(-8)}`,
host,
type: serviceType.type, type: serviceType.type,
protocol: serviceType.protocol, protocol: serviceType.protocol,
port: this.config.port, port: this.config.port,

View File

@@ -94,6 +94,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
autoBackupDir: ".fusion/backups", autoBackupDir: ".fusion/backups",
backupSettingsMigrationConflicts: undefined, backupSettingsMigrationConflicts: undefined,
/* /*
FNXC:NodeDiscovery 2026-07-17-12:00:
LAN discovery remains automatic for existing operators, while FN-8202 provides a global opt-out from dashboard and serve mDNS/DNS-SD auto-start.
*/
localNetworkDiscoveryEnabled: true,
/*
FNXC:DashboardShortcuts 2026-07-04-00:00: FNXC:DashboardShortcuts 2026-07-04-00:00:
Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action. Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action.
*/ */

View File

@@ -2454,6 +2454,8 @@ export interface GlobalSettings {
autoBackupDir?: string; autoBackupDir?: string;
/** Durable candidates requiring an operator choice after project-to-global backup migration. */ /** Durable candidates requiring an operator choice after project-to-global backup migration. */
backupSettingsMigrationConflicts?: BackupSettingsMigrationConflict[]; backupSettingsMigrationConflicts?: BackupSettingsMigrationConflict[];
/** When false, fn dashboard and fn serve skip automatic mDNS/DNS-SD LAN discovery. Default: true (FN-8202 opt-out). */
localNetworkDiscoveryEnabled?: boolean;
/** /**
* FNXC:DashboardShortcuts 2026-07-04-00:00: * FNXC:DashboardShortcuts 2026-07-04-00:00:
* Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action. * Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action.