diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md
index 0e221fbbc3c3..1cb9ffa1f486 100644
--- a/docs/cli/gateway.md
+++ b/docs/cli/gateway.md
@@ -334,7 +334,7 @@ If you pass `--url`, that explicit target is added ahead of both. Human output l
- `Local loopback`
-If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
+If multiple probe targets are reachable, it prints all of them. An SSH tunnel, TLS/proxy URL, and configured remote URL can all point at the same gateway even when their transport ports differ; `multiple_gateways` is reserved for distinct or identity-ambiguous reachable gateways. Multiple gateways are supported when you use isolated profiles (e.g., a rescue bot), but most installs still run a single gateway.
```bash
@@ -379,7 +379,7 @@ openclaw gateway probe --json
- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes.
- - `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot.
+ - `multiple_gateways`: distinct gateway identities were reachable, or OpenClaw could not prove reachable targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway does not trigger this warning.
- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target.
- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`.
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index b57bc966d3de..8d9f58e1c39d 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -167,8 +167,10 @@ What to expect:
- `gateway status --deep` can report `Other gateway-like services detected (best effort)`
and print cleanup hints when stale launchd/systemd/schtasks installs are still around.
-- `gateway probe` can warn about `multiple reachable gateways` when more than one target
- answers.
+- `gateway probe` can warn about `multiple reachable gateway identities` when distinct
+ gateways answer, or when OpenClaw cannot prove reachable targets are the same gateway.
+ An SSH tunnel, proxy URL, or configured remote URL to the same gateway is one
+ gateway with multiple transports, even when transport ports differ.
- If that is intentional, isolate ports, config/state, and workspace roots per gateway.
Checklist per instance:
diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md
index e499cf16055e..108a88df669e 100644
--- a/docs/gateway/multiple-gateways.md
+++ b/docs/gateway/multiple-gateways.md
@@ -169,7 +169,7 @@ openclaw --profile rescue browser status
Interpretation:
- `gateway status --deep` helps catch stale launchd/systemd/schtasks services from older installs.
-- `gateway probe` warning text such as `multiple reachable gateways detected` is expected only when you intentionally run more than one isolated gateway.
+- `gateway probe` warning text such as `multiple reachable gateway identities detected` is expected only when you intentionally run more than one isolated gateway, or when OpenClaw cannot prove reachable probe targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway is one gateway with multiple transports, even when transport ports differ.
## Related
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 6e455aa287f4..824854c6e68f 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -625,7 +625,7 @@ Look for:
Common signatures:
- `SSH tunnel failed to start; falling back to direct probes.` → SSH setup failed, but the command still tried direct configured/loopback targets.
-- `multiple reachable gateways detected` → more than one target answered. Usually this means an intentional multi-gateway setup or stale/duplicate listeners.
+- `multiple reachable gateway identities detected` → distinct gateways answered, or OpenClaw could not prove reachable targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway is treated as one gateway with multiple transports, even when transport ports differ.
- `Read-probe diagnostics are limited by gateway scopes (missing operator.read)` → connect worked, but detail RPC is scope-limited; pair device identity or use credentials with `operator.read`.
- `Gateway accepted the WebSocket connection, but follow-up read diagnostics failed` → connect worked, but the full diagnostic RPC set timed out or failed. Treat this as a reachable Gateway with degraded diagnostics; compare `connect.ok` and `connect.rpcOk` in `--json` output.
- `Capability: pairing-pending` or `gateway closed (1008): pairing required` → the gateway answered, but this client still needs pairing/approval before normal operator access.
diff --git a/src/commands/gateway-presence.test.ts b/src/commands/gateway-presence.test.ts
new file mode 100644
index 000000000000..895ed840bc03
--- /dev/null
+++ b/src/commands/gateway-presence.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { pickGatewaySelfPresence } from "./gateway-presence.js";
+
+describe("pickGatewaySelfPresence", () => {
+ it("extracts host and ip from legacy gateway self text", () => {
+ expect(
+ pickGatewaySelfPresence([
+ {
+ text: "Gateway: gateway-host (192.0.2.10) · app 2026.5.22 · mode gateway · reason self",
+ },
+ ]),
+ ).toStrictEqual({
+ host: "gateway-host",
+ ip: "192.0.2.10",
+ version: undefined,
+ platform: undefined,
+ });
+ });
+
+ it("prefers structured gateway self fields over legacy text", () => {
+ expect(
+ pickGatewaySelfPresence([
+ {
+ text: "Gateway: legacy-host (192.0.2.10) · app 2026.5.22 · mode gateway · reason self",
+ host: "structured-host",
+ ip: "192.0.2.11",
+ version: "2026.5.23",
+ platform: "linux",
+ mode: "gateway",
+ reason: "self",
+ },
+ ]),
+ ).toStrictEqual({
+ host: "structured-host",
+ ip: "192.0.2.11",
+ version: "2026.5.23",
+ platform: "linux",
+ });
+ });
+});
diff --git a/src/commands/gateway-presence.ts b/src/commands/gateway-presence.ts
index c17dd4673b6f..2395d7682bc2 100644
--- a/src/commands/gateway-presence.ts
+++ b/src/commands/gateway-presence.ts
@@ -6,8 +6,21 @@ type GatewaySelfPresence = {
ip?: string;
version?: string;
platform?: string;
+ deviceId?: string;
+ instanceId?: string;
};
+function parseLegacyGatewaySelfText(text: string): Pick {
+ const match = text.match(/^Gateway:\s*([^ (·]+)(?:\s*\(([^)]+)\))?/i);
+ if (!match) {
+ return {};
+ }
+ return {
+ host: readStringValue(match[1]),
+ ip: readStringValue(match[2]),
+ };
+}
+
/** Picks host, ip, version, and platform from the gateway self presence record. */
export function pickGatewaySelfPresence(presence: unknown): GatewaySelfPresence | null {
if (!Array.isArray(presence)) {
@@ -22,10 +35,20 @@ export function pickGatewaySelfPresence(presence: unknown): GatewaySelfPresence
if (!self) {
return null;
}
- return {
- host: readStringValue(self.host),
- ip: readStringValue(self.ip),
+ const legacy = typeof self.text === "string" ? parseLegacyGatewaySelfText(self.text) : {};
+ const result: GatewaySelfPresence = {
+ host: readStringValue(self.host) ?? legacy.host,
+ ip: readStringValue(self.ip) ?? legacy.ip,
version: readStringValue(self.version),
platform: readStringValue(self.platform),
};
+ const deviceId = readStringValue(self.deviceId);
+ if (deviceId) {
+ result.deviceId = deviceId;
+ }
+ const instanceId = readStringValue(self.instanceId);
+ if (instanceId) {
+ result.instanceId = instanceId;
+ }
+ return result;
}
diff --git a/src/commands/gateway-status/output.test.ts b/src/commands/gateway-status/output.test.ts
index 273d943ec37a..c720dd2cf0de 100644
--- a/src/commands/gateway-status/output.test.ts
+++ b/src/commands/gateway-status/output.test.ts
@@ -89,6 +89,75 @@ function createTarget(id: string, probe: GatewayProbeResult): GatewayStatusProbe
};
}
+function createReachableTarget(
+ id: string,
+ self: GatewayStatusProbedTarget["self"],
+ target?: Partial,
+ configPath = "/tmp/openclaw/config.json",
+): GatewayStatusProbedTarget {
+ const probe = createProbe("admin_capable", {
+ ok: true,
+ connectLatencyMs: 20,
+ });
+ if (target?.url) {
+ probe.url = target.url;
+ }
+ const base = createTarget(id, probe);
+ return {
+ ...base,
+ target: {
+ ...base.target,
+ ...target,
+ },
+ self,
+ configSummary: {
+ path: configPath,
+ exists: true,
+ valid: true,
+ issues: [],
+ legacyIssues: [],
+ gateway: {
+ mode: null,
+ bind: null,
+ port: null,
+ controlUiEnabled: null,
+ controlUiBasePath: null,
+ authMode: null,
+ authTokenConfigured: false,
+ authPasswordConfigured: false,
+ remoteUrl: null,
+ remoteTokenConfigured: false,
+ remotePasswordConfigured: false,
+ tailscaleMode: null,
+ },
+ discovery: {
+ wideAreaEnabled: null,
+ },
+ },
+ };
+}
+
+const MULTIPLE_GATEWAYS_WARNING = {
+ code: "multiple_gateways",
+ message:
+ "Unconventional setup: multiple reachable gateway identities detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
+};
+
+const GATEWAY_SELF = {
+ host: "gateway-host",
+ ip: "192.0.2.10",
+ version: "2026.5.22",
+ platform: "linux",
+ instanceId: "gateway-instance-1",
+};
+
+const GATEWAY_SELF_NO_PROCESS_ID = {
+ host: "gateway-host",
+ ip: "192.0.2.10",
+ version: "2026.5.22",
+ platform: "linux",
+};
+
describe("gateway status output", () => {
beforeEach(() => {
mocks.writeRuntimeJson.mockReset();
@@ -120,6 +189,113 @@ describe("gateway status output", () => {
});
});
+ it.each([
+ {
+ name: "suppresses warning for SSH tunnel and configured remote with the same self identity",
+ probed: [
+ createReachableTarget("sshTunnel", GATEWAY_SELF, {
+ kind: "sshTunnel",
+ url: "ws://127.0.0.1:18789",
+ tunnel: {
+ kind: "ssh",
+ target: "user@gateway-host",
+ localPort: 18789,
+ remotePort: 18789,
+ pid: 1234,
+ },
+ }),
+ createReachableTarget(
+ "configRemote",
+ {
+ ...GATEWAY_SELF,
+ host: GATEWAY_SELF.host.toUpperCase(),
+ },
+ { kind: "configRemote", url: "ws://gateway-host:18789" },
+ ),
+ ],
+ sshTarget: "user@gateway-host",
+ expectedTargetIds: null,
+ },
+ {
+ name: "suppresses warning for the same self identity on different transport ports",
+ probed: [
+ createReachableTarget("localLoopback", GATEWAY_SELF, {
+ kind: "localLoopback",
+ url: "ws://127.0.0.1:18789",
+ }),
+ createReachableTarget("explicit", GATEWAY_SELF, {
+ kind: "explicit",
+ url: "ws://gateway-host:28789",
+ }),
+ ],
+ sshTarget: null,
+ expectedTargetIds: null,
+ },
+ {
+ name: "warns when same-host probes do not report process identity",
+ probed: [
+ createReachableTarget("localLoopback", GATEWAY_SELF_NO_PROCESS_ID, {
+ kind: "localLoopback",
+ url: "ws://127.0.0.1:18789",
+ }),
+ createReachableTarget("explicit", GATEWAY_SELF_NO_PROCESS_ID, {
+ kind: "explicit",
+ url: "ws://gateway-host:28789",
+ }),
+ ],
+ sshTarget: null,
+ expectedTargetIds: ["localLoopback", "explicit"],
+ },
+ {
+ name: "warns when probes report distinct identities",
+ probed: [
+ createReachableTarget("sshTunnel", {
+ host: "gateway-a",
+ ip: "192.0.2.10",
+ version: "2026.5.22",
+ platform: "linux",
+ instanceId: "gateway-instance-a",
+ }),
+ createReachableTarget("configRemote", {
+ host: "gateway-b",
+ ip: "192.0.2.11",
+ version: "2026.5.22",
+ platform: "linux",
+ instanceId: "gateway-instance-b",
+ }),
+ ],
+ sshTarget: "user@gateway-a",
+ expectedTargetIds: ["sshTunnel", "configRemote"],
+ },
+ {
+ name: "warns when probe identity is unknown",
+ probed: [
+ createReachableTarget("sshTunnel", null),
+ createReachableTarget("configRemote", null),
+ ],
+ sshTarget: "user@gateway-host",
+ expectedTargetIds: ["sshTunnel", "configRemote"],
+ },
+ ])("$name", ({ probed, sshTarget, expectedTargetIds }) => {
+ const warnings = buildGatewayStatusWarnings({
+ probed,
+ sshTarget,
+ sshTunnelStarted: sshTarget !== null,
+ sshTunnelError: null,
+ discoveryCount: 0,
+ });
+ const warning = warnings.find((entry) => entry.code === "multiple_gateways");
+
+ if (expectedTargetIds === null) {
+ expect(warning).toBeUndefined();
+ } else {
+ expect(warning).toStrictEqual({
+ ...MULTIPLE_GATEWAYS_WARNING,
+ targetIds: expectedTargetIds,
+ });
+ }
+ });
+
it("derives summary capability from reachable probes only in json output", () => {
const runtime = createRuntimeCapture();
writeGatewayStatusJson({
diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts
index 2776a5a0c296..15f869ba60e0 100644
--- a/src/commands/gateway-status/output.ts
+++ b/src/commands/gateway-status/output.ts
@@ -23,6 +23,35 @@ export type GatewayStatusWarning = {
const noReachableGatewayDiagnostic =
"No gateway answered any probe and Bonjour discovery returned no local gateways. Run `openclaw gateway status --deep --require-rpc` to inspect service state, config paths, listener owners, and logs; include `ss -ltnp` or `lsof -nP -iTCP: -sTCP:LISTEN` for the configured port when filing a report.";
+function gatewaySelfIdentityKey(entry: GatewayStatusProbedTarget): string | null {
+ if (!entry.self) {
+ return null;
+ }
+ const host = typeof entry.self.host === "string" ? entry.self.host.trim().toLowerCase() : "";
+ const ip = typeof entry.self.ip === "string" ? entry.self.ip.trim().toLowerCase() : "";
+ const discriminator =
+ typeof entry.self.instanceId === "string" && entry.self.instanceId.trim()
+ ? `instance:${entry.self.instanceId.trim().toLowerCase()}`
+ : typeof entry.self.deviceId === "string" && entry.self.deviceId.trim()
+ ? `device:${entry.self.deviceId.trim().toLowerCase()}`
+ : "";
+ if ((!host && !ip) || !discriminator) {
+ return null;
+ }
+ return `${host}\0${ip}\0${discriminator}`;
+}
+
+function hasMultipleReachableGatewayIdentities(reachable: GatewayStatusProbedTarget[]): boolean {
+ if (reachable.length <= 1) {
+ return false;
+ }
+ const identityKeys = reachable.map((entry) => gatewaySelfIdentityKey(entry));
+ if (identityKeys.some((key) => key === null)) {
+ return true;
+ }
+ return new Set(identityKeys).size > 1;
+}
+
function readModelPricingDegradedDetail(health: unknown): string | null {
if (!health || typeof health !== "object") {
return null;
@@ -91,13 +120,13 @@ export function buildGatewayStatusWarnings(params: {
targetIds: params.probed.map((entry) => entry.target.id),
});
}
- if (reachable.length > 1) {
+ if (hasMultipleReachableGatewayIdentities(reachable)) {
// Multiple reachable gateways are valid for isolated profiles but surprising
// enough to call out before users debug against the wrong process.
warnings.push({
code: "multiple_gateways",
message:
- "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
+ "Unconventional setup: multiple reachable gateway identities detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((entry) => entry.target.id),
});
}
diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts
index 82e880ef951f..39dea854f3fb 100644
--- a/src/infra/system-presence.ts
+++ b/src/infra/system-presence.ts
@@ -1,5 +1,6 @@
// Detects system command availability for setup and diagnostics.
import { spawnSync } from "node:child_process";
+import { randomUUID } from "node:crypto";
import os from "node:os";
import {
normalizeLowercaseStringOrEmpty,
@@ -38,6 +39,7 @@ type SystemPresenceUpdate = {
const entries = new Map();
const TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_ENTRIES = 200;
+const SELF_INSTANCE_ID = randomUUID();
function normalizePresenceKey(key: string | undefined): string | undefined {
return normalizeOptionalLowercaseString(key);
@@ -103,6 +105,7 @@ function initSelfPresence() {
modelIdentifier,
mode: "gateway",
reason: "self",
+ instanceId: SELF_INSTANCE_ID,
text,
ts: Date.now(),
};