diff --git a/.changeset/fn-8202-mdns-hostname-fix.md b/.changeset/fn-8202-mdns-hostname-fix.md new file mode 100644 index 0000000000..a79d8d4701 --- /dev/null +++ b/.changeset/fn-8202-mdns-hostname-fix.md @@ -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-) 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`. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ac5269c512..690ef106fb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | | `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. | +| `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. | | `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. | diff --git a/docs/shared-mesh-protocol.md b/docs/shared-mesh-protocol.md index f4db8179cd..2aff27daa9 100644 --- a/docs/shared-mesh-protocol.md +++ b/docs/shared-mesh-protocol.md @@ -82,7 +82,7 @@ Still useful under shared Postgres: | `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) | | 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-` host rather than the OS hostname; set global `localNetworkDiscoveryEnabled: false` to disable dashboard/serve auto-start. | | Docker mesh config generator | Provision managed peers | Removed / disabled: diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 568e870a3b..b22f1405fe 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -3002,6 +3002,30 @@ describe("runDashboard — mesh lifecycle ownership", () => { 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).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 () => { const { CentralCore } = await import("@fusion/core"); const { PeerExchangeService } = await import("@fusion/engine"); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 30f7ecb097..575b527ec5 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -1786,6 +1786,18 @@ describe("runServe — Peer exchange and discovery", () => { 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 () => { await runServe(0, {}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index e06b5b4443..f51d16c816 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2738,13 +2738,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // if (centralCoreForMesh) { try { - await centralCoreForMesh.startDiscovery({ - broadcast: true, - listen: true, - serviceType: "_fusion._tcp", - port: actualPort, - staleTimeoutMs: 300_000, - }); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + /* + * FNXC:NodeDiscovery 2026-07-17-12:00: + * FN-8202 requires dashboard boot to respect the global LAN discovery + * opt-out. The explicit discovery API remains an operator override. + */ + 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) { const message = err instanceof Error ? err.message : String(err); logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard"); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0bef5662e0..11d9ffc6e2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1075,13 +1075,23 @@ export async function runServe( // if (sharedCentralCore) { try { - await sharedCentralCore.startDiscovery({ - broadcast: true, - listen: true, - serviceType: "_fusion._tcp", - port: actualPort, - staleTimeoutMs: 300_000, - }); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + /* + * FNXC:NodeDiscovery 2026-07-17-12:00: + * FN-8202 makes automatic LAN discovery opt-outable at daemon boot; + * explicit POST /api/discovery/start requests intentionally still work. + */ + 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) { const message = err instanceof Error ? err.message : String(err); console.warn(`[serve] Failed to start mDNS discovery: ${message}`); diff --git a/packages/core/src/__tests__/node-discovery.test.ts b/packages/core/src/__tests__/node-discovery.test.ts index 1b50f5fb45..9ff0b4f6c7 100644 --- a/packages/core/src/__tests__/node-discovery.test.ts +++ b/packages/core/src/__tests__/node-discovery.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import os from "node:os"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { DiscoveryConfig, DiscoveredNode } from "../types.js"; @@ -97,6 +98,7 @@ describe("NodeDiscovery", () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it("starts and stops broadcast mode", () => { @@ -128,11 +130,39 @@ describe("NodeDiscovery", () => { expect(publishMock).toHaveBeenCalledWith( 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", () => { const discovery = new NodeDiscovery(defaultConfig({ listen: true })); const discoveredHandler = vi.fn(); diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 4fc0cfe1c6..04dfc78ef6 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -28,6 +28,14 @@ describe("settings defaults invariants", () => { 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", () => { expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", diff --git a/packages/core/src/node-discovery.ts b/packages/core/src/node-discovery.ts index 27237dc3c5..2c0fba58b0 100644 --- a/packages/core/src/node-discovery.ts +++ b/packages/core/src/node-discovery.ts @@ -18,6 +18,28 @@ const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = { const STALE_CLEANUP_INTERVAL_MS = 60_000; 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 { "node:discovered": [node: DiscoveredNode]; "node:updated": [node: DiscoveredNode]; @@ -111,8 +133,16 @@ export class NodeDiscovery extends EventEmitter { const bonjour = this.getBonjour(); const serviceType = this.parseServiceType(this.config.serviceType); + const host = deriveFusionMdnsHost(nodeId); 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({ /* * FNXC:NodeDiscovery 2026-07-15-18:05: @@ -122,6 +152,7 @@ export class NodeDiscovery extends EventEmitter { * optional mDNS collision from disrupting the dashboard process. */ name: `${nodeName.trim() || os.hostname()}-${nodeId.slice(-8)}`, + host, type: serviceType.type, protocol: serviceType.protocol, port: this.config.port, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 49cf57b5e2..8a3cdee763 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -94,6 +94,11 @@ export const DEFAULT_GLOBAL_SETTINGS = { autoBackupDir: ".fusion/backups", 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: 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. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a7fe5df6eb..5109ca5405 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2454,6 +2454,8 @@ export interface GlobalSettings { autoBackupDir?: string; /** Durable candidates requiring an operator choice after project-to-global backup migration. */ 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: * 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.