From 88ca0b2c3f12feb46ab7ad8744126959cb16accc Mon Sep 17 00:00:00 2001 From: Luke <92253590+ImLukeF@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:12:08 +1100 Subject: [PATCH] fix(status): handle node-only hosts on current main (#56718) * Status: handle node-only hosts * Status: address follow-up review nits * Changelog: note node-only status fix * Status: lazy-load node-only helper --- CHANGELOG.md | 1 + src/commands/status-all.ts | 36 +++-- src/commands/status-all/diagnosis.test.ts | 32 ++++ src/commands/status-all/diagnosis.ts | 7 + src/commands/status-all/report-lines.test.ts | 1 + src/commands/status.command.ts | 29 +++- src/commands/status.daemon.ts | 2 + src/commands/status.node-mode.test.ts | 92 ++++++++++++ src/commands/status.node-mode.ts | 75 ++++++++++ src/commands/status.test.ts | 147 ++++++++++++++----- 10 files changed, 367 insertions(+), 55 deletions(-) create mode 100644 src/commands/status.node-mode.test.ts create mode 100644 src/commands/status.node-mode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2683ee1d5..20098144c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,7 @@ Docs: https://docs.openclaw.ai - Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963) - Status/port diagnostics: treat single-process dual-stack loopback gateway listeners as healthy in `openclaw status --all`, suppressing false “port already in use” conflict warnings. (#53398) Thanks @DanWebb1949. - Memory/builtin: keep memory-file indexing active in FTS-only mode (no embedding provider) so forced reindexes no longer swap in an empty index and wipe existing memory chunks. (#42714) Thanks @asamimei. +- CLI/status: detect node-only hosts in `openclaw status` and `openclaw status --all`, show the configured remote gateway target instead of a false local `ECONNREFUSED`, and suppress contradictory local-gateway diagnosis output. ## 2026.3.24 diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 5c18c4f87e5..b4f731e8d08 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -35,6 +35,7 @@ import { buildChannelsTable } from "./status-all/channels.js"; import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js"; import { pickGatewaySelfPresence } from "./status-all/gateway.js"; import { buildStatusAllReportLines } from "./status-all/report-lines.js"; +import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js"; import { readServiceStatusSummary } from "./status.service-summary.js"; import { formatUpdateOneLiner } from "./status.update.js"; @@ -147,6 +148,7 @@ export async function statusAllCommand( return { label: summary.label, installed: summary.installed, + externallyManaged: summary.externallyManaged, managedByOpenClaw: summary.managedByOpenClaw, loaded: summary.loaded, loadedText: summary.loadedText, @@ -158,6 +160,13 @@ export async function statusAllCommand( }; const daemon = await readServiceSummary(resolveGatewayService()); const nodeService = await readServiceSummary(resolveNodeService()); + const nodeOnlyGateway = + daemon && nodeService + ? await resolveNodeOnlyGatewayInfo({ + daemon, + node: nodeService, + }) + : null; progress.tick(); progress.setLabel("Scanning agents…"); @@ -171,6 +180,9 @@ export async function statusAllCommand( progress.tick(); const connectionDetailsForReport = (() => { + if (nodeOnlyGateway) { + return nodeOnlyGateway.connectionDetails; + } if (!remoteUrlMissing) { return connection.message; } @@ -195,14 +207,16 @@ export async function statusAllCommand( : {}; progress.setLabel("Querying gateway…"); - const health = gatewayReachable - ? await callGateway({ - config: cfg, - method: "health", - timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), - ...callOverrides, - }).catch((err) => ({ error: String(err) })) - : { error: gatewayProbe?.error ?? "gateway unreachable" }; + const health = nodeOnlyGateway + ? undefined + : gatewayReachable + ? await callGateway({ + config: cfg, + method: "health", + timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), + ...callOverrides, + }).catch((err) => ({ error: String(err) })) + : { error: gatewayProbe?.error ?? "gateway unreachable" }; const channelsStatus = gatewayReachable ? await callGateway({ @@ -261,6 +275,9 @@ export async function statusAllCommand( ? `unreachable (${gatewayProbe.error})` : "unreachable"; const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : ""; + const gatewayValue = + nodeOnlyGateway?.gatewayValue ?? + `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`; const gatewaySelfLine = gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform ? [ @@ -303,7 +320,7 @@ export async function statusAllCommand( { Item: "Update", Value: updateLine }, { Item: "Gateway", - Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`, + Value: gatewayValue, }, ...(probeAuthResolution.warning ? [{ Item: "Gateway auth warning", Value: probeAuthResolution.warning }] @@ -368,6 +385,7 @@ export async function statusAllCommand( channelIssues, gatewayReachable, health, + nodeOnlyGateway, }, }); diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts index f8dc23cae1b..9475d36ad2c 100644 --- a/src/commands/status-all/diagnosis.test.ts +++ b/src/commands/status-all/diagnosis.test.ts @@ -57,6 +57,7 @@ function createBaseParams( channelIssues: [], gatewayReachable: false, health: null, + nodeOnlyGateway: null, }; } @@ -87,4 +88,35 @@ describe("status-all diagnosis port checks", () => { expect(output).toContain("! Port 18789"); expect(output).toContain("Port 18789 is already in use."); }); + + it("avoids unreachable gateway diagnosis in node-only mode", async () => { + const params = createBaseParams([]); + params.connectionDetailsForReport = [ + "Node-only mode detected", + "Local gateway: not expected on this machine", + "Remote gateway target: gateway.example.com:19000", + ].join("\n"); + params.tailscale.backendState = "Running"; + params.health = undefined; + params.nodeOnlyGateway = { + gatewayTarget: "gateway.example.com:19000", + gatewayValue: "node → gateway.example.com:19000 · no local gateway", + connectionDetails: [ + "Node-only mode detected", + "Local gateway: not expected on this machine", + "Remote gateway target: gateway.example.com:19000", + "Inspect the remote gateway host for live channel and health details.", + ].join("\n"), + }; + + await appendStatusAllDiagnosis(params); + + const output = params.lines.join("\n"); + expect(output).toContain("Node-only mode detected"); + expect(output).toContain( + "Channel issues skipped (node-only mode; query gateway.example.com:19000)", + ); + expect(output).not.toContain("Channel issues skipped (gateway unreachable)"); + expect(output).not.toContain("Gateway health:"); + }); }); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index c12c4d86e08..59178918a51 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -14,6 +14,7 @@ import { formatPluginCompatibilityNotice, type PluginCompatibilityNotice, } from "../../plugins/status.js"; +import type { NodeOnlyGatewayInfo } from "../status.node-mode.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -72,6 +73,7 @@ export async function appendStatusAllDiagnosis(params: { channelIssues: ChannelIssueLike[]; gatewayReachable: boolean; health: unknown; + nodeOnlyGateway: NodeOnlyGatewayInfo | null; }) { const { lines, muted, ok, warn, fail } = params; @@ -248,6 +250,11 @@ export async function appendStatusAllDiagnosis(params: { if (params.channelIssues.length > 12) { lines.push(` ${muted(`… +${params.channelIssues.length - 12} more`)}`); } + } else if (params.nodeOnlyGateway) { + emitCheck( + `Channel issues skipped (node-only mode; query ${params.nodeOnlyGateway.gatewayTarget})`, + "ok", + ); } else { emitCheck( `Channel issues skipped (gateway ${params.gatewayReachable ? "query failed" : "unreachable"})`, diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts index 70b9503d63f..1e6a1844172 100644 --- a/src/commands/status-all/report-lines.test.ts +++ b/src/commands/status-all/report-lines.test.ts @@ -65,6 +65,7 @@ describe("buildStatusAllReportLines", () => { channelIssues: [], gatewayReachable: false, health: null, + nodeOnlyGateway: null, }, }); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 85c10adfa92..dd3c3050460 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -17,6 +17,7 @@ let statusAllModulePromise: Promise | undefine let statusCommandTextRuntimePromise: | Promise | undefined; +let statusNodeModeModulePromise: Promise | undefined; function loadProviderUsage() { providerUsagePromise ??= import("../infra/provider-usage.js"); @@ -53,6 +54,11 @@ function loadStatusCommandTextRuntime() { return statusCommandTextRuntimePromise; } +function loadStatusNodeModeModule() { + statusNodeModeModulePromise ??= import("./status.node-mode.js"); + return statusNodeModeModulePromise; +} + function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; @@ -302,7 +308,21 @@ export async function statusCommand( }).httpUrl : "disabled"; + const [daemon, nodeDaemon] = await Promise.all([ + getDaemonStatusSummary(), + getNodeDaemonStatusSummary(), + ]); + const nodeOnlyGateway = await loadStatusNodeModeModule().then(({ resolveNodeOnlyGatewayInfo }) => + resolveNodeOnlyGatewayInfo({ + daemon, + node: nodeDaemon, + }), + ); + const gatewayValue = (() => { + if (nodeOnlyGateway) { + return nodeOnlyGateway.gatewayValue; + } const target = remoteUrlMissing ? `fallback ${gatewayConnection.url}` : `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`; @@ -344,11 +364,6 @@ export async function statusCommand( const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; })(); - - const [daemon, nodeDaemon] = await Promise.all([ - getDaemonStatusSummary(), - getNodeDaemonStatusSummary(), - ]); const daemonValue = (() => { if (daemon.installed === false) { return `${daemon.label} not installed`; @@ -743,7 +758,9 @@ export async function statusCommand( runtime.log("Next steps:"); runtime.log(` Need to share? ${formatCliCommand("openclaw status --all")}`); runtime.log(` Need to debug live? ${formatCliCommand("openclaw logs --follow")}`); - if (gatewayReachable) { + if (nodeOnlyGateway) { + runtime.log(` Need node service? ${formatCliCommand("openclaw node status")}`); + } else if (gatewayReachable) { runtime.log(` Need to test channels? ${formatCliCommand("openclaw status --deep")}`); } else { runtime.log(` Fix reachability first: ${formatCliCommand("openclaw gateway probe")}`); diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index dcf5487e8ce..5d8fe0927ac 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -6,6 +6,7 @@ import { readServiceStatusSummary } from "./status.service-summary.js"; type DaemonStatusSummary = { label: string; installed: boolean | null; + loaded: boolean; managedByOpenClaw: boolean; externallyManaged: boolean; loadedText: string; @@ -21,6 +22,7 @@ async function buildDaemonStatusSummary( return { label: summary.label, installed: summary.installed, + loaded: summary.loaded, managedByOpenClaw: summary.managedByOpenClaw, externallyManaged: summary.externallyManaged, loadedText: summary.loadedText, diff --git a/src/commands/status.node-mode.test.ts b/src/commands/status.node-mode.test.ts new file mode 100644 index 00000000000..fa3a39ce7af --- /dev/null +++ b/src/commands/status.node-mode.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadNodeHostConfig: vi.fn(), +})); + +vi.mock("../node-host/config.js", () => ({ + loadNodeHostConfig: mocks.loadNodeHostConfig, +})); + +import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js"; + +describe("resolveNodeOnlyGatewayInfo", () => { + beforeEach(() => { + mocks.loadNodeHostConfig.mockReset(); + }); + + it("returns node-only gateway details when no local gateway is installed", async () => { + mocks.loadNodeHostConfig.mockResolvedValueOnce({ + version: 1, + nodeId: "node-1", + gateway: { host: "gateway.example.com", port: 19000 }, + }); + + await expect( + resolveNodeOnlyGatewayInfo({ + daemon: { installed: false }, + node: { + installed: true, + loaded: true, + externallyManaged: false, + runtimeShort: "running (pid 4321)", + }, + }), + ).resolves.toEqual({ + gatewayTarget: "gateway.example.com:19000", + gatewayValue: "node → gateway.example.com:19000 · no local gateway", + connectionDetails: [ + "Node-only mode detected", + "Local gateway: not expected on this machine", + "Remote gateway target: gateway.example.com:19000", + "Inspect the remote gateway host for live channel and health details.", + ].join("\n"), + }); + }); + + it("does not claim node-only mode when the node service is installed but inactive", async () => { + mocks.loadNodeHostConfig.mockResolvedValueOnce({ + version: 1, + nodeId: "node-1", + gateway: { host: "gateway.example.com", port: 19000 }, + }); + + await expect( + resolveNodeOnlyGatewayInfo({ + daemon: { installed: false }, + node: { + installed: true, + loaded: false, + externallyManaged: false, + runtime: { status: "stopped" }, + runtimeShort: "stopped", + }, + }), + ).resolves.toBeNull(); + }); + + it("falls back to an unknown gateway target when node-only config is missing", async () => { + mocks.loadNodeHostConfig.mockResolvedValueOnce(null); + + await expect( + resolveNodeOnlyGatewayInfo({ + daemon: { installed: false }, + node: { + installed: true, + loaded: true, + externallyManaged: false, + runtimeShort: "running (pid 4321)", + }, + }), + ).resolves.toEqual({ + gatewayTarget: "(gateway address unknown)", + gatewayValue: "node → (gateway address unknown) · no local gateway", + connectionDetails: [ + "Node-only mode detected", + "Local gateway: not expected on this machine", + "Remote gateway target: (gateway address unknown)", + "Inspect the remote gateway host for live channel and health details.", + ].join("\n"), + }); + }); +}); diff --git a/src/commands/status.node-mode.ts b/src/commands/status.node-mode.ts new file mode 100644 index 00000000000..4646e4bdb87 --- /dev/null +++ b/src/commands/status.node-mode.ts @@ -0,0 +1,75 @@ +import { DEFAULT_GATEWAY_PORT } from "../config/paths.js"; +import { loadNodeHostConfig } from "../node-host/config.js"; + +type NodeOnlyServiceLike = { + installed: boolean | null; + loaded?: boolean | null; + externallyManaged?: boolean; + runtime?: + | { + status?: string; + pid?: number; + } + | undefined; + runtimeShort?: string | null; +}; + +export type NodeOnlyGatewayInfo = { + gatewayTarget: string; + gatewayValue: string; + connectionDetails: string; +}; + +function resolveNodeGatewayTarget(gateway?: { host?: string; port?: number }): string { + return gateway?.host + ? `${gateway.host}:${gateway.port ?? DEFAULT_GATEWAY_PORT}` + : "(gateway address unknown)"; +} + +function hasRunningRuntime( + runtime: + | { + status?: string; + pid?: number; + } + | undefined, +): boolean { + return runtime?.status === "running" || typeof runtime?.pid === "number"; +} + +function isNodeServiceActive(node: NodeOnlyServiceLike): boolean { + if (node.installed !== true) { + return false; + } + if (node.externallyManaged === true) { + return true; + } + if (node.loaded === true) { + return true; + } + if (hasRunningRuntime(node.runtime)) { + return true; + } + return typeof node.runtimeShort === "string" && node.runtimeShort.startsWith("running"); +} + +export async function resolveNodeOnlyGatewayInfo(params: { + daemon: Pick; + node: NodeOnlyServiceLike; +}): Promise { + if (params.daemon.installed !== false || !isNodeServiceActive(params.node)) { + return null; + } + + const gatewayTarget = resolveNodeGatewayTarget((await loadNodeHostConfig())?.gateway); + return { + gatewayTarget, + gatewayValue: `node → ${gatewayTarget} · no local gateway`, + connectionDetails: [ + "Node-only mode detected", + "Local gateway: not expected on this machine", + `Remote gateway target: ${gatewayTarget}`, + "Inspect the remote gateway host for live channel and health details.", + ].join("\n"), + }; +} diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 8c69ab99985..a49aca8bcc4 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -196,6 +196,7 @@ const mocks = vi.hoisted(() => ({ }), resolveMainSessionKey: vi.fn().mockReturnValue("agent:main:main"), resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"), + loadNodeHostConfig: vi.fn().mockResolvedValue(null), webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(5000), readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), @@ -212,6 +213,38 @@ const mocks = vi.hoisted(() => ({ }), runSecurityAudit: vi.fn().mockResolvedValue(createDefaultSecurityAuditResult()), buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []), + resolveGatewayService: vi.fn().mockReturnValue({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: async () => {}, + install: async () => {}, + uninstall: async () => {}, + stop: async () => {}, + restart: async () => ({ outcome: "completed" as const }), + isLoaded: async () => true, + readRuntime: async () => ({ status: "running", pid: 1234 }), + readCommand: async () => ({ + programArguments: ["node", "dist/entry.js", "gateway"], + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", + }), + }), + resolveNodeService: vi.fn().mockReturnValue({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: async () => {}, + install: async () => {}, + uninstall: async () => {}, + stop: async () => {}, + restart: async () => ({ outcome: "completed" as const }), + isLoaded: async () => true, + readRuntime: async () => ({ status: "running", pid: 4321 }), + readCommand: async () => ({ + programArguments: ["node", "dist/entry.js", "node-host"], + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist", + }), + }), })); vi.mock("../channels/config-presence.js", async (importOriginal) => { @@ -387,47 +420,18 @@ vi.mock("../config/config.js", async (importOriginal) => { readBestEffortConfig: vi.fn(async () => mocks.loadConfig()), }; }); -vi.mock("../daemon/service.js", () => ({ - resolveGatewayService: () => ({ - label: "LaunchAgent", - loadedText: "loaded", - notLoadedText: "not loaded", - stage: async () => {}, - install: async () => {}, - uninstall: async () => {}, - stop: async () => {}, - restart: async () => ({ outcome: "completed" as const }), - isLoaded: async () => true, - readRuntime: async () => ({ status: "running", pid: 1234 }), - readCommand: async () => ({ - programArguments: ["node", "dist/entry.js", "gateway"], - sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", - }), - }), - readGatewayServiceState: async () => ({ - installed: true, - loaded: true, - running: true, - runtime: { status: "running", pid: 1234 }, - }), -})); +vi.mock("../daemon/service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayService: mocks.resolveGatewayService, + }; +}); vi.mock("../daemon/node-service.js", () => ({ - resolveNodeService: () => ({ - label: "LaunchAgent", - loadedText: "loaded", - notLoadedText: "not loaded", - stage: async () => {}, - install: async () => {}, - uninstall: async () => {}, - stop: async () => {}, - restart: async () => ({ outcome: "completed" as const }), - isLoaded: async () => true, - readRuntime: async () => ({ status: "running", pid: 4321 }), - readCommand: async () => ({ - programArguments: ["node", "dist/entry.js", "node-host"], - sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist", - }), - }), + resolveNodeService: mocks.resolveNodeService, +})); +vi.mock("../node-host/config.js", () => ({ + loadNodeHostConfig: mocks.loadNodeHostConfig, })); vi.mock("../security/audit.js", () => ({ runSecurityAudit: mocks.runSecurityAudit, @@ -466,6 +470,8 @@ describe("statusCommand", () => { mocks.resolveMainSessionKey.mockReturnValue("agent:main:main"); mocks.resolveStorePath.mockReset(); mocks.resolveStorePath.mockReturnValue("/tmp/sessions.json"); + mocks.loadNodeHostConfig.mockReset(); + mocks.loadNodeHostConfig.mockResolvedValue(null); mocks.probeGateway.mockReset(); mocks.probeGateway.mockResolvedValue(createDefaultProbeGatewayResult()); mocks.callGateway.mockReset(); @@ -483,6 +489,40 @@ describe("statusCommand", () => { mocks.hasPotentialConfiguredChannels.mockReturnValue(true); mocks.runSecurityAudit.mockReset(); mocks.runSecurityAudit.mockResolvedValue(createDefaultSecurityAuditResult()); + mocks.resolveGatewayService.mockReset(); + mocks.resolveGatewayService.mockReturnValue({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: async () => {}, + install: async () => {}, + uninstall: async () => {}, + stop: async () => {}, + restart: async () => ({ outcome: "completed" as const }), + isLoaded: async () => true, + readRuntime: async () => ({ status: "running", pid: 1234 }), + readCommand: async () => ({ + programArguments: ["node", "dist/entry.js", "gateway"], + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", + }), + }); + mocks.resolveNodeService.mockReset(); + mocks.resolveNodeService.mockReturnValue({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: async () => {}, + install: async () => {}, + uninstall: async () => {}, + stop: async () => {}, + restart: async () => ({ outcome: "completed" as const }), + isLoaded: async () => true, + readRuntime: async () => ({ status: "running", pid: 4321 }), + readCommand: async () => ({ + programArguments: ["node", "dist/entry.js", "node-host"], + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist", + }), + }); runtimeLogMock.mockClear(); (runtime.error as Mock<(...args: unknown[]) => void>).mockClear(); }); @@ -584,6 +624,33 @@ describe("statusCommand", () => { ).toBe(true); }); + it("shows node-only gateway info when no local gateway service is installed", async () => { + mocks.resolveGatewayService.mockReturnValueOnce({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: async () => {}, + install: async () => {}, + uninstall: async () => {}, + stop: async () => {}, + restart: async () => ({ outcome: "completed" as const }), + isLoaded: async () => false, + readRuntime: async () => undefined, + readCommand: async () => null, + }); + mocks.loadNodeHostConfig.mockResolvedValueOnce({ + version: 1, + nodeId: "node-1", + gateway: { host: "gateway.example.com", port: 19000 }, + }); + + const joined = await runStatusAndGetJoinedLogs(); + expect(joined).toContain("node → gateway.example.com:19000 · no local gateway"); + expect(joined).not.toContain("Gateway: local · ws://127.0.0.1:18789"); + expect(joined).toContain("openclaw --profile isolated node status"); + expect(joined).not.toContain("Fix reachability first"); + }); + it("shows gateway auth when reachable", async () => { mocks.loadConfig.mockReturnValue({ session: {},