diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 193b18683184..256bd9b18346 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -315,6 +315,8 @@ async function runGatewayStatus( json?: boolean; port?: unknown; url?: string; + token?: string; + password?: string; ssh?: string; sshAuto?: boolean; sshIdentity?: string; @@ -447,6 +449,7 @@ describe("gateway-status command", () => { timeout: "1000", json: true, url: "wss://remote.example:18789", + token: "explicit-remote-token", }); expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled(); @@ -458,6 +461,132 @@ describe("gateway-status command", () => { ); }); + it.each([ + { + source: "configured token", + auth: { mode: "token", token: "configured-local-token" }, + env: {}, + options: {}, + }, + { + source: "configured password", + auth: { mode: "password", password: "configured-local-password" }, + env: {}, + options: {}, + }, + { + source: "environment token", + auth: { mode: "token" }, + env: { OPENCLAW_GATEWAY_TOKEN: "ambient-local-token" }, + options: {}, + }, + { + source: "environment password", + auth: { mode: "password" }, + env: { OPENCLAW_GATEWAY_PASSWORD: "ambient-local-password" }, + options: {}, + }, + { + source: "whitespace token", + auth: { mode: "token", token: "configured-local-token" }, + env: {}, + options: { token: " " }, + }, + { + source: "whitespace password", + auth: { mode: "password", password: "configured-local-password" }, + env: {}, + options: { password: " " }, + }, + { + source: "explicit loopback URL", + auth: { mode: "token", token: "configured-local-token" }, + env: {}, + options: { url: "ws://127.0.0.1:18991" }, + }, + ])( + "rejects a local $source before probing an explicit Gateway URL", + async ({ auth, env, options }) => { + const configuredGateway = { gateway: { mode: "local", auth } }; + + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + ...env, + }, + async () => { + await readBestEffortConfig.withImplementation( + async () => configuredGateway as never, + async () => { + const { runtime } = createRuntimeCapture(); + await expect( + runGatewayStatus(runtime, { + timeout: "1000", + json: true, + url: "wss://attacker.example:18789", + ...options, + }), + ).rejects.toMatchObject({ + name: "GatewayExplicitAuthRequiredError", + message: expect.stringContaining( + "gateway url override requires explicit credentials", + ), + }); + + expect(readBestEffortConfig).not.toHaveBeenCalled(); + expect(discoverGatewayBeacons).not.toHaveBeenCalled(); + expect(startSshPortForward).not.toHaveBeenCalled(); + expect(probeGateway).not.toHaveBeenCalled(); + }, + ); + }, + ); + }, + ); + + it.each([ + { + credential: "token", + options: { token: "explicit-remote-token" }, + expectedAuth: { token: "explicit-remote-token", password: undefined }, + }, + { + credential: "password", + options: { password: "explicit-remote-password" }, + expectedAuth: { token: undefined, password: "explicit-remote-password" }, + }, + ])( + "honors an explicit $credential for an explicit Gateway URL", + async ({ options, expectedAuth }) => { + const explicitUrl = "wss://attacker.example:18789"; + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + auth: { mode: "token", token: "configured-local-token" }, + }, + } as never); + + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: "ambient-local-token", + OPENCLAW_GATEWAY_PASSWORD: "ambient-local-password", + }, + async () => { + const { runtime } = createRuntimeCapture(); + await runGatewayStatus(runtime, { + timeout: "1000", + json: true, + url: explicitUrl, + ...options, + }); + + expect(requireProbeCall(explicitUrl).auth).toEqual(expectedAuth); + }, + ); + }, + ); + it("includes diagnostic next steps when no gateway is reachable or discoverable", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); const defaultProbeGateway = probeGateway.getMockImplementation(); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index d35e3ac589dc..322d3badf317 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -3,6 +3,7 @@ import { isRich } from "../../packages/terminal-core/src/theme.js"; import { parseGatewayPortOption } from "../cli/gateway-port-option.js"; import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; +import { ensureExplicitGatewayAuth, resolveExplicitGatewayAuth } from "../gateway/call.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -52,6 +53,12 @@ export async function gatewayStatusCommand( }, runtime: RuntimeEnv, ) { + ensureExplicitGatewayAuth({ + urlOverride: opts.url?.trim(), + urlOverrideSource: "cli", + explicitAuth: resolveExplicitGatewayAuth(opts), + errorHint: "Fix: pass --token or --password with --url.", + }); const startedAt = Date.now(); const cfg = await readBestEffortConfig(); const rich = isRich() && opts.json !== true;