Skip to content

Commit 6ba0afb

Browse files
committed
fix(gateway): accept port for health and probe
1 parent 392f5b7 commit 6ba0afb

13 files changed

Lines changed: 225 additions & 15 deletions

docs/cli/gateway.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,15 @@ When you set `--url`, the CLI does not fall back to config or environment creden
165165

166166
```bash
167167
openclaw gateway health --url ws://127.0.0.1:18789
168+
openclaw gateway health --port 18789
168169
```
169170

170171
The HTTP `/healthz` endpoint is a liveness probe: it returns once the server can answer HTTP. The HTTP `/readyz` endpoint is stricter and stays red while startup plugin sidecars, channels, or configured hooks are still settling. Local or authenticated detailed readiness responses include an `eventLoop` diagnostic block with event-loop delay, event-loop utilization, CPU core ratio, and a `degraded` flag.
171172

173+
<ParamField path="--port <port>" type="number">
174+
Target a local loopback Gateway on this port.
175+
</ParamField>
176+
172177
### `gateway usage-cost`
173178

174179
Fetch usage-cost summaries from session logs.
@@ -340,8 +345,13 @@ If multiple probe targets are reachable, it prints all of them. An SSH tunnel, T
340345
```bash
341346
openclaw gateway probe
342347
openclaw gateway probe --json
348+
openclaw gateway probe --port 18789
343349
```
344350

351+
<ParamField path="--port <port>" type="number">
352+
Use this port for the local loopback probe target and SSH tunnel remote port.
353+
</ParamField>
354+
345355
<AccordionGroup>
346356
<Accordion title="Interpretation">
347357
- `Reachable: yes` means at least one target accepted a WebSocket connect.

src/cli/gateway-cli/call.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type GatewayRpcOpts = {
1717
timeout?: string;
1818
expectFinal?: boolean;
1919
json?: boolean;
20+
localPortOverride?: number;
2021
};
2122

2223
const DEFAULT_GATEWAY_RPC_TIMEOUT_MS = 10_000;
@@ -50,6 +51,7 @@ export const callGatewayCli = async (method: string, opts: GatewayRpcOpts, param
5051
params,
5152
expectFinal: Boolean(opts.expectFinal),
5253
timeoutMs,
54+
localPortOverride: opts.localPortOverride,
5355
clientName: GATEWAY_CLIENT_NAMES.CLI,
5456
mode: GATEWAY_CLIENT_MODES.CLI,
5557
}),

src/cli/gateway-cli/register.option-collisions.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ vi.mock("../daemon-cli/register-service-commands.js", () => ({
6868
}));
6969

7070
vi.mock("../../commands/health.js", () => ({
71+
emitReachableGatewayAuthDiagnostic: vi.fn(async () => false),
7172
formatHealthChannelLines: () => [],
7273
}));
7374

@@ -171,6 +172,32 @@ describe("gateway register option collisions", () => {
171172
expect(runtime).toBe(defaultRuntime);
172173
},
173174
},
175+
{
176+
name: "forwards --port to gateway probe",
177+
argv: ["gateway", "probe", "--port", "19080", "--json"],
178+
assert: () => {
179+
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
180+
const [opts] = firstGatewayStatusCall();
181+
expect((opts as { port?: string } | undefined)?.port).toBe("19080");
182+
},
183+
},
184+
{
185+
name: "projects gateway health --port into local config",
186+
argv: ["gateway", "health", "--port", "19081", "--json"],
187+
assert: () => {
188+
expect(defaultRuntime.error.mock.calls).toEqual([]);
189+
expect(callGatewayCli).toHaveBeenCalledTimes(1);
190+
const [method, opts] = firstGatewayCall();
191+
expect(method).toBe("health");
192+
const gatewayOpts = opts as
193+
| { config?: { gateway?: { port?: number } }; localPortOverride?: number }
194+
| undefined;
195+
expect(gatewayOpts?.localPortOverride).toBe(19081);
196+
expect(gatewayOpts?.config).toEqual({
197+
gateway: { mode: "local", port: 19081 },
198+
});
199+
},
200+
},
174201
{
175202
name: "passes decimal usage-cost --days values",
176203
argv: ["gateway", "usage-cost", "--days", "7", "--json"],

src/cli/gateway-cli/register.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { defaultRuntime } from "../../runtime.js";
1818
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
1919
import { inheritOptionFromParent } from "../command-options.js";
2020
import { addGatewayServiceCommands } from "../daemon-cli/register-service-commands.js";
21+
import { parseGatewayPortOption } from "../gateway-port-option.js";
2122
import { formatHelpExamples } from "../help-format.js";
2223
import type { GatewayRpcOpts } from "./call.js";
2324
import type { GatewayDiscoverOpts } from "./discover.js";
@@ -168,6 +169,34 @@ function resolveGatewayRpcOptions<T extends { token?: string; password?: string
168169
};
169170
}
170171

172+
async function resolveGatewayRpcOptionsWithLocalPort(
173+
opts: GatewayRpcOpts & { port?: unknown },
174+
command?: Command,
175+
): Promise<GatewayRpcOpts> {
176+
const rpcOpts = resolveGatewayRpcOptions(opts, command);
177+
const port = parseGatewayPortOption(opts.port ?? inheritOptionFromParent(command, "port"));
178+
if (port === undefined) {
179+
return rpcOpts;
180+
}
181+
if (typeof opts.url === "string" && opts.url.trim()) {
182+
throw new Error("Use either --url or --port, not both.");
183+
}
184+
const { readBestEffortConfig } = await loadConfigModule();
185+
const config = await readBestEffortConfig();
186+
return {
187+
...rpcOpts,
188+
localPortOverride: port,
189+
config: {
190+
...config,
191+
gateway: {
192+
...config.gateway,
193+
mode: "local",
194+
port,
195+
},
196+
},
197+
};
198+
}
199+
171200
async function renderCostUsageSummaryAsync(
172201
summary: CostUsageSummary,
173202
days: number,
@@ -553,10 +582,11 @@ export function registerGatewayCli(program: Command) {
553582
gateway
554583
.command("health")
555584
.description("Fetch Gateway health")
585+
.option("--port <port>", "Local Gateway port")
556586
.action(async (opts, command) => {
557587
await runGatewayCommand(
558588
async () => {
559-
const rpcOpts = resolveGatewayRpcOptions(opts, command);
589+
const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command);
560590
const [
561591
{ emitReachableGatewayAuthDiagnostic, formatHealthChannelLines },
562592
{ styleHealthChannelLine },
@@ -728,6 +758,7 @@ export function registerGatewayCli(program: Command) {
728758
"Show gateway reachability, auth capability, and read-probe summary (local + remote)",
729759
)
730760
.option("--url <url>", "Explicit Gateway WebSocket URL (still probes localhost)")
761+
.option("--port <port>", "Local Gateway port")
731762
.option("--ssh <target>", "SSH target for remote gateway tunnel (user@host or user@host:port)")
732763
.option("--ssh-identity <path>", "SSH identity file path")
733764
.option("--ssh-auto", "Try to derive an SSH target from Bonjour discovery", false)
@@ -739,7 +770,13 @@ export function registerGatewayCli(program: Command) {
739770
await runGatewayCommand(async () => {
740771
const rpcOpts = resolveGatewayRpcOptions(opts, command);
741772
const { gatewayStatusCommand } = await loadGatewayStatusModule();
742-
await gatewayStatusCommand(rpcOpts, defaultRuntime);
773+
await gatewayStatusCommand(
774+
{
775+
...rpcOpts,
776+
port: opts.port ?? inheritOptionFromParent(command, "port"),
777+
},
778+
defaultRuntime,
779+
);
743780
});
744781
});
745782

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Gateway port option tests cover strict CLI validation before transports open.
2+
import { describe, expect, it } from "vitest";
3+
import { parseGatewayPortOption } from "./gateway-port-option.js";
4+
5+
describe("parseGatewayPortOption", () => {
6+
it("accepts TCP port values", () => {
7+
expect(parseGatewayPortOption("18789")).toBe(18789);
8+
expect(parseGatewayPortOption(65_535)).toBe(65_535);
9+
});
10+
11+
it("treats absent values as no override", () => {
12+
expect(parseGatewayPortOption(undefined)).toBeUndefined();
13+
expect(parseGatewayPortOption("")).toBeUndefined();
14+
});
15+
16+
it.each(["0", "65536", "1e4", "18789ms"])("rejects invalid port value %s", (value) => {
17+
expect(() => parseGatewayPortOption(value)).toThrow(
18+
"--port must be an integer between 1 and 65535.",
19+
);
20+
});
21+
});

src/cli/gateway-port-option.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Shared parser for CLI flags that select a local Gateway TCP port.
2+
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
3+
4+
const MAX_TCP_PORT = 65_535;
5+
6+
export function parseGatewayPortOption(raw: unknown, flagName = "--port"): number | undefined {
7+
if (raw === undefined || raw === null) {
8+
return undefined;
9+
}
10+
11+
const value =
12+
typeof raw === "string"
13+
? raw.trim()
14+
: typeof raw === "number" || typeof raw === "bigint"
15+
? String(raw)
16+
: "";
17+
if (!value) {
18+
return undefined;
19+
}
20+
21+
const parsed = parseStrictPositiveInteger(value);
22+
if (parsed === undefined || parsed > MAX_TCP_PORT) {
23+
throw new Error(`${flagName} must be an integer between 1 and ${MAX_TCP_PORT}.`);
24+
}
25+
return parsed;
26+
}

src/commands/gateway-status.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,14 @@ function mockLocalTokenEnvRefConfig(envTokenId = "MISSING_GATEWAY_TOKEN") {
297297

298298
async function runGatewayStatus(
299299
runtime: ReturnType<typeof createRuntimeCapture>["runtime"],
300-
opts: { timeout: string; json?: boolean; ssh?: string; sshAuto?: boolean; sshIdentity?: string },
300+
opts: {
301+
timeout: string;
302+
json?: boolean;
303+
port?: unknown;
304+
ssh?: string;
305+
sshAuto?: boolean;
306+
sshIdentity?: string;
307+
},
301308
) {
302309
await gatewayStatusCommand(opts, asRuntimeEnv(runtime));
303310
}
@@ -914,6 +921,27 @@ describe("gateway-status command", () => {
914921
expect(requireProbeCall("ws://127.0.0.1:18789").timeoutMs).toBe(15_000);
915922
});
916923

924+
it("uses --port for the local loopback probe target", async () => {
925+
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
926+
probeGateway.mockClear();
927+
readBestEffortConfig.mockResolvedValueOnce({
928+
gateway: {
929+
mode: "local",
930+
port: 18789,
931+
auth: { mode: "token", token: "ltok" },
932+
},
933+
} as never);
934+
935+
await runGatewayStatus(runtime, { timeout: "15000", json: true, port: "19080" });
936+
937+
expect(runtimeErrors).toHaveLength(0);
938+
expect(requireProbeCall("ws://127.0.0.1:19080").timeoutMs).toBe(15_000);
939+
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
940+
network?: { localLoopbackUrl?: string | null };
941+
};
942+
expect(parsed.network?.localLoopbackUrl).toBe("ws://127.0.0.1:19080");
943+
});
944+
917945
it("uses configured handshake timeout as the default local probe budget", async () => {
918946
const { runtime } = createRuntimeCapture();
919947
probeGateway.mockClear();

src/commands/gateway-status.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** CLI entrypoint for `openclaw gateway status`. */
22
import { isRich } from "../../packages/terminal-core/src/theme.js";
3+
import { parseGatewayPortOption } from "../cli/gateway-port-option.js";
34
import { withProgress } from "../cli/progress.js";
45
import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js";
56
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
@@ -42,6 +43,7 @@ export async function gatewayStatusCommand(
4243
url?: string;
4344
token?: string;
4445
password?: string;
46+
port?: unknown;
4547
timeout?: unknown;
4648
json?: boolean;
4749
ssh?: string;
@@ -55,12 +57,13 @@ export async function gatewayStatusCommand(
5557
const rich = isRich() && opts.json !== true;
5658
const defaultTimeoutMs = Math.max(3000, cfg.gateway?.handshakeTimeoutMs ?? 0);
5759
const overallTimeoutMs = parseTimeoutMs(opts.timeout, defaultTimeoutMs);
60+
const portOverride = parseGatewayPortOption(opts.port);
5861
const wideAreaDomain = resolveWideAreaDiscoveryDomain({
5962
configDomain: cfg.discovery?.wideArea?.domain,
6063
});
61-
const baseTargets = resolveTargets(cfg, opts.url);
62-
const network = buildNetworkHints(cfg);
63-
const remotePort = resolveGatewayPort(cfg);
64+
const baseTargets = resolveTargets(cfg, opts.url, portOverride);
65+
const network = buildNetworkHints(cfg, portOverride);
66+
const remotePort = portOverride ?? resolveGatewayPort(cfg);
6467
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
6568

6669
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);

src/commands/gateway-status/helpers.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,22 @@ describe("gateway-status local target scheme", () => {
316316
const hints = buildNetworkHints(cfg as never);
317317
expect(hints.localLoopbackUrl).toBe("wss://127.0.0.1:18789");
318318
});
319+
320+
it("uses a local port override for loopback targets and network hints", () => {
321+
const cfg = {
322+
gateway: {
323+
mode: "local",
324+
port: 18789,
325+
},
326+
};
327+
328+
const targets = resolveTargets(cfg as never, undefined, 19080);
329+
const localLoopbackTarget = targets.find((target) => target.id === "localLoopback");
330+
expect(localLoopbackTarget?.url).toBe("ws://127.0.0.1:19080");
331+
332+
const hints = buildNetworkHints(cfg as never, 19080);
333+
expect(hints.localLoopbackUrl).toBe("ws://127.0.0.1:19080");
334+
});
319335
});
320336

321337
describe("resolveProbeBudgetMs", () => {

src/commands/gateway-status/helpers.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ function normalizeWsUrl(value: string): string | null {
8787
}
8888

8989
/** Builds the deduplicated ordered gateway probe targets from CLI input and config. */
90-
export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): GatewayStatusTarget[] {
90+
export function resolveTargets(
91+
cfg: OpenClawConfig,
92+
explicitUrl?: string,
93+
localPortOverride?: number,
94+
): GatewayStatusTarget[] {
9195
const targets: GatewayStatusTarget[] = [];
9296
const add = (t: GatewayStatusTarget) => {
9397
if (!targets.some((x) => x.url === t.url)) {
@@ -111,7 +115,7 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
111115
});
112116
}
113117

114-
const port = resolveGatewayPort(cfg);
118+
const port = localPortOverride ?? resolveGatewayPort(cfg);
115119
const localScheme = cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
116120
add({
117121
id: "localLoopback",
@@ -252,10 +256,10 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
252256
};
253257
}
254258

255-
/** Builds local and tailnet gateway URL hints for the configured gateway port. */
256-
export function buildNetworkHints(cfg: OpenClawConfig) {
259+
/** Builds local and tailnet gateway URL hints for the selected gateway port. */
260+
export function buildNetworkHints(cfg: OpenClawConfig, localPortOverride?: number) {
257261
const { tailnetIPv4 } = inspectBestEffortPrimaryTailnetIPv4();
258-
const port = resolveGatewayPort(cfg);
262+
const port = localPortOverride ?? resolveGatewayPort(cfg);
259263
const localScheme = cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
260264
return {
261265
localLoopbackUrl: `${localScheme}://127.0.0.1:${port}`,

0 commit comments

Comments
 (0)