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
This commit is contained in:
Luke 2026-03-29 21:12:08 +11:00 committed by GitHub
parent 571da81a35
commit 88ca0b2c3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 367 additions and 55 deletions

View File

@ -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

View File

@ -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,
},
});

View File

@ -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:");
});
});

View File

@ -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"})`,

View File

@ -65,6 +65,7 @@ describe("buildStatusAllReportLines", () => {
channelIssues: [],
gatewayReachable: false,
health: null,
nodeOnlyGateway: null,
},
});

View File

@ -17,6 +17,7 @@ let statusAllModulePromise: Promise<typeof import("./status-all.js")> | undefine
let statusCommandTextRuntimePromise:
| Promise<typeof import("./status.command.text-runtime.js")>
| undefined;
let statusNodeModeModulePromise: Promise<typeof import("./status.node-mode.js")> | 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")}`);

View File

@ -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,

View File

@ -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"),
});
});
});

View File

@ -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<NodeOnlyServiceLike, "installed">;
node: NodeOnlyServiceLike;
}): Promise<NodeOnlyGatewayInfo | null> {
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"),
};
}

View File

@ -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<typeof import("../daemon/service.js")>();
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: {},