| import { Command } from "commander"; |
| import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; |
| import { createCliRuntimeCapture } from "../test-runtime-capture.js"; |
|
|
| const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ |
| close: vi.fn(async () => {}), |
| })); |
| const setGatewayWsLogStyle = vi.fn((_style: string) => undefined); |
| const setVerbose = vi.fn((_enabled: boolean) => undefined); |
| const forceFreePortAndWait = vi.fn(async (_port: number, _opts: unknown) => ({ |
| killed: [], |
| waitedMs: 0, |
| escalatedToSigkill: false, |
| })); |
| const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0); |
| const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {}); |
| const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => { |
| await start(); |
| }); |
| const configState = vi.hoisted(() => ({ |
| cfg: {} as Record<string, unknown>, |
| snapshot: { exists: false } as Record<string, unknown>, |
| })); |
|
|
| const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); |
|
|
| vi.mock("../../config/config.js", () => ({ |
| getConfigPath: () => "/tmp/openclaw-test-missing-config.json", |
| loadConfig: () => configState.cfg, |
| readConfigFileSnapshot: async () => configState.snapshot, |
| resolveStateDir: () => "/tmp", |
| resolveGatewayPort: () => 18789, |
| })); |
|
|
| vi.mock("../../gateway/auth.js", () => ({ |
| resolveGatewayAuth: (params: { |
| authConfig?: { mode?: string; token?: unknown; password?: unknown }; |
| authOverride?: { mode?: string; token?: unknown; password?: unknown }; |
| env?: NodeJS.ProcessEnv; |
| }) => { |
| const mode = params.authOverride?.mode ?? params.authConfig?.mode ?? "token"; |
| const token = |
| (typeof params.authOverride?.token === "string" ? params.authOverride.token : undefined) ?? |
| (typeof params.authConfig?.token === "string" ? params.authConfig.token : undefined) ?? |
| params.env?.OPENCLAW_GATEWAY_TOKEN; |
| const password = |
| (typeof params.authOverride?.password === "string" |
| ? params.authOverride.password |
| : undefined) ?? |
| (typeof params.authConfig?.password === "string" ? params.authConfig.password : undefined) ?? |
| params.env?.OPENCLAW_GATEWAY_PASSWORD; |
| return { |
| mode, |
| token, |
| password, |
| allowTailscale: false, |
| }; |
| }, |
| })); |
|
|
| vi.mock("../../gateway/server.js", () => ({ |
| startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts), |
| })); |
|
|
| vi.mock("../../gateway/ws-logging.js", () => ({ |
| setGatewayWsLogStyle: (style: string) => setGatewayWsLogStyle(style), |
| })); |
|
|
| vi.mock("../../globals.js", () => ({ |
| setVerbose: (enabled: boolean) => setVerbose(enabled), |
| })); |
|
|
| vi.mock("../../infra/gateway-lock.js", () => ({ |
| GatewayLockError: class GatewayLockError extends Error {}, |
| })); |
|
|
| vi.mock("../../infra/ports.js", () => ({ |
| formatPortDiagnostics: () => [], |
| inspectPortUsage: async () => ({ status: "free" }), |
| })); |
|
|
| vi.mock("../../logging/console.js", () => ({ |
| setConsoleSubsystemFilter: () => undefined, |
| setConsoleTimestampPrefix: () => undefined, |
| })); |
|
|
| vi.mock("../../logging/subsystem.js", () => ({ |
| createSubsystemLogger: () => ({ |
| info: () => undefined, |
| warn: () => undefined, |
| error: () => undefined, |
| }), |
| })); |
|
|
| vi.mock("../../runtime.js", () => ({ |
| defaultRuntime, |
| })); |
|
|
| vi.mock("../command-format.js", () => ({ |
| formatCliCommand: (cmd: string) => cmd, |
| })); |
|
|
| vi.mock("../ports.js", () => ({ |
| forceFreePortAndWait: (port: number, opts: unknown) => forceFreePortAndWait(port, opts), |
| waitForPortBindable: (port: number, opts?: unknown) => waitForPortBindable(port, opts), |
| })); |
|
|
| vi.mock("./dev.js", () => ({ |
| ensureDevGatewayConfig: (opts?: unknown) => ensureDevGatewayConfig(opts), |
| })); |
|
|
| vi.mock("./run-loop.js", () => ({ |
| runGatewayLoop: (params: { start: () => Promise<unknown> }) => runGatewayLoop(params), |
| })); |
|
|
| describe("gateway run option collisions", () => { |
| let addGatewayRunCommand: typeof import("./run.js").addGatewayRunCommand; |
| let sharedProgram: Command; |
|
|
| beforeAll(async () => { |
| ({ addGatewayRunCommand } = await import("./run.js")); |
| sharedProgram = new Command(); |
| sharedProgram.exitOverride(); |
| const gateway = addGatewayRunCommand(sharedProgram.command("gateway")); |
| addGatewayRunCommand(gateway.command("run")); |
| }); |
|
|
| beforeEach(() => { |
| resetRuntimeCapture(); |
| configState.cfg = {}; |
| configState.snapshot = { exists: false }; |
| startGatewayServer.mockClear(); |
| setGatewayWsLogStyle.mockClear(); |
| setVerbose.mockClear(); |
| forceFreePortAndWait.mockClear(); |
| waitForPortBindable.mockClear(); |
| ensureDevGatewayConfig.mockClear(); |
| runGatewayLoop.mockClear(); |
| }); |
|
|
| async function runGatewayCli(argv: string[]) { |
| await sharedProgram.parseAsync(argv, { from: "user" }); |
| } |
|
|
| function expectAuthOverrideMode(mode: string) { |
| expect(startGatewayServer).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ |
| auth: expect.objectContaining({ |
| mode, |
| }), |
| }), |
| ); |
| } |
|
|
| it("forwards parent-captured options to `gateway run` subcommand", async () => { |
| await runGatewayCli([ |
| "gateway", |
| "run", |
| "--token", |
| "tok_run", |
| "--allow-unconfigured", |
| "--ws-log", |
| "full", |
| "--force", |
| ]); |
|
|
| expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything()); |
| expect(waitForPortBindable).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ host: "127.0.0.1" }), |
| ); |
| expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full"); |
| expect(startGatewayServer).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ |
| auth: expect.objectContaining({ |
| token: "tok_run", |
| }), |
| }), |
| ); |
| }); |
|
|
| it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { |
| await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); |
|
|
| expect(startGatewayServer).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ |
| bind: "loopback", |
| }), |
| ); |
| }); |
|
|
| it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => { |
| await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]); |
|
|
| expectAuthOverrideMode(mode); |
| }); |
|
|
| it("prints all supported modes on invalid --auth value", async () => { |
| await expect( |
| runGatewayCli(["gateway", "run", "--auth", "bad-mode", "--allow-unconfigured"]), |
| ).rejects.toThrow("__exit__:1"); |
|
|
| expect(runtimeErrors).toContain( |
| 'Invalid --auth (use "none", "token", "password", or "trusted-proxy")', |
| ); |
| }); |
|
|
| it("allows password mode preflight when password is configured via SecretRef", async () => { |
| configState.cfg = { |
| gateway: { |
| auth: { |
| mode: "password", |
| password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| defaults: { |
| env: "default", |
| }, |
| }, |
| }; |
| configState.snapshot = { exists: true, parsed: configState.cfg }; |
|
|
| await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); |
|
|
| expect(startGatewayServer).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ |
| bind: "loopback", |
| }), |
| ); |
| }); |
|
|
| it("reads gateway password from --password-file", async () => { |
| await withTempSecretFiles( |
| "openclaw-gateway-run-", |
| { password: "pw_from_file\n" }, |
| async ({ passwordFile }) => { |
| await runGatewayCli([ |
| "gateway", |
| "run", |
| "--auth", |
| "password", |
| "--password-file", |
| passwordFile ?? "", |
| "--allow-unconfigured", |
| ]); |
| }, |
| ); |
|
|
| expect(startGatewayServer).toHaveBeenCalledWith( |
| 18789, |
| expect.objectContaining({ |
| auth: expect.objectContaining({ |
| mode: "password", |
| password: "pw_from_file", |
| }), |
| }), |
| ); |
| expect(runtimeErrors).not.toContain( |
| "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", |
| ); |
| }); |
|
|
| it("warns when gateway password is passed inline", async () => { |
| await runGatewayCli([ |
| "gateway", |
| "run", |
| "--auth", |
| "password", |
| "--password", |
| "pw_inline", |
| "--allow-unconfigured", |
| ]); |
|
|
| expect(runtimeErrors).toContain( |
| "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", |
| ); |
| }); |
|
|
| it("rejects using both --password and --password-file", async () => { |
| await withTempSecretFiles( |
| "openclaw-gateway-run-", |
| { password: "pw_from_file\n" }, |
| async ({ passwordFile }) => { |
| await expect( |
| runGatewayCli([ |
| "gateway", |
| "run", |
| "--password", |
| "pw_inline", |
| "--password-file", |
| passwordFile ?? "", |
| "--allow-unconfigured", |
| ]), |
| ).rejects.toThrow("__exit__:1"); |
| }, |
| ); |
|
|
| expect(runtimeErrors).toContain("Use either --password or --password-file."); |
| }); |
| }); |
|
|