diff --git a/src/commands/configure.gateway.e2e.test.ts b/src/commands/configure.gateway.e2e.test.ts index a4d7846325..a36f7acbf3 100644 --- a/src/commands/configure.gateway.e2e.test.ts +++ b/src/commands/configure.gateway.e2e.test.ts @@ -82,30 +82,33 @@ async function runTrustedProxyPrompt(params: { tailscaleMode?: "off" | "serve"; }) { return runGatewayPrompt({ - selectQueue: ["loopback", "trusted-proxy", params.tailscaleMode ?? "off"], + selectQueue: ["lan", params.tailscaleMode ?? "off", "trusted-proxy"], textQueue: params.textQueue, authConfigFactory: ({ mode, trustedProxy }) => ({ mode, trustedProxy }), }); } describe("promptGatewayConfig", () => { - it("generates a token when the prompt returns undefined", async () => { + it("skips gateway auth setup for loopback-only gateways", async () => { const { result } = await runGatewayPrompt({ - selectQueue: ["loopback", "token", "off"], - textQueue: ["18789", undefined], + selectQueue: ["loopback", "off"], + textQueue: ["18789"], randomToken: "generated-token", authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }), }); - expect(result.token).toBe("generated-token"); + expect(result.token).toBeUndefined(); + expect(result.config.gateway?.auth).toBeUndefined(); + expect(mocks.buildGatewayAuthConfig).not.toHaveBeenCalled(); }); - it("does not set password to literal 'undefined' when prompt returns undefined", async () => { + it("configures password auth when gateway is exposed", async () => { const { call } = await runGatewayPrompt({ - selectQueue: ["loopback", "password", "off"], + selectQueue: ["lan", "off", "password"], textQueue: ["18789", undefined], randomToken: "unused", authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }), }); + expect(call?.mode).toBe("password"); expect(call?.password).not.toBe("undefined"); expect(call?.password).toBe(""); }); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index b0676f311a..4780549b2c 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -85,22 +85,7 @@ export async function promptGatewayConfig( customBindHost = typeof input === "string" ? input : undefined; } - let authMode = guardCancel( - await select({ - message: "Gateway auth", - options: [ - { value: "token", label: "Token", hint: "Recommended default" }, - { value: "password", label: "Password" }, - { - value: "trusted-proxy", - label: "Trusted Proxy", - hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)", - }, - ], - initialValue: "token", - }), - runtime, - ) as GatewayAuthChoice; + let authMode: GatewayAuthChoice = "token"; let tailscaleMode = guardCancel( await select({ @@ -137,22 +122,44 @@ export async function promptGatewayConfig( bind = "loopback"; } - if (tailscaleMode === "funnel" && authMode !== "password") { - note("Tailscale funnel requires password auth.", "Note"); - authMode = "password"; - } + const loopbackOnlyGateway = bind === "loopback" && tailscaleMode === "off"; + if (loopbackOnlyGateway) { + note("Loopback-only gateway does not require gateway.auth. Keeping auth disabled.", "Note"); + } else { + authMode = guardCancel( + await select({ + message: "Gateway auth", + options: [ + { value: "token", label: "Token", hint: "Recommended default" }, + { value: "password", label: "Password" }, + { + value: "trusted-proxy", + label: "Trusted Proxy", + hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)", + }, + ], + initialValue: tailscaleMode === "funnel" ? "password" : "token", + }), + runtime, + ) as GatewayAuthChoice; - if (authMode === "trusted-proxy" && bind === "loopback") { - note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note"); - bind = "lan"; - } - if (authMode === "trusted-proxy" && tailscaleMode !== "off") { - note( - "Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.", - "Note", - ); - tailscaleMode = "off"; - tailscaleResetOnExit = false; + if (tailscaleMode === "funnel" && authMode !== "password") { + note("Tailscale funnel requires password auth.", "Note"); + authMode = "password"; + } + + if (authMode === "trusted-proxy" && bind === "loopback") { + note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note"); + bind = "lan"; + } + if (authMode === "trusted-proxy" && tailscaleMode !== "off") { + note( + "Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.", + "Note", + ); + tailscaleMode = "off"; + tailscaleResetOnExit = false; + } } let gatewayToken: string | undefined; @@ -163,7 +170,7 @@ export async function promptGatewayConfig( let trustedProxies: string[] | undefined; let next = cfg; - if (authMode === "token") { + if (!loopbackOnlyGateway && authMode === "token") { const tokenInput = guardCancel( await text({ message: "Gateway token (blank to generate)", @@ -174,7 +181,7 @@ export async function promptGatewayConfig( gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); } - if (authMode === "password") { + if (!loopbackOnlyGateway && authMode === "password") { const password = guardCancel( await text({ message: "Gateway password", @@ -185,7 +192,7 @@ export async function promptGatewayConfig( gatewayPassword = String(password ?? "").trim(); } - if (authMode === "trusted-proxy") { + if (!loopbackOnlyGateway && authMode === "trusted-proxy") { note( [ "Trusted proxy mode: OpenClaw trusts user identity from a reverse proxy.", @@ -261,22 +268,26 @@ export async function promptGatewayConfig( }; } - const authConfig = buildGatewayAuthConfig({ - existing: next.gateway?.auth, - mode: authMode, - token: gatewayToken, - password: gatewayPassword, - trustedProxy: trustedProxyConfig, - }); + const authConfig = loopbackOnlyGateway + ? undefined + : buildGatewayAuthConfig({ + existing: next.gateway?.auth, + mode: authMode, + token: gatewayToken, + password: gatewayPassword, + trustedProxy: trustedProxyConfig, + }); + const gatewayWithoutAuth = { ...next.gateway }; + delete gatewayWithoutAuth.auth; next = { ...next, gateway: { - ...next.gateway, + ...gatewayWithoutAuth, mode: "local", port, bind, - auth: authConfig, + ...(authConfig ? { auth: authConfig } : {}), ...(customBindHost && { customBindHost }), ...(trustedProxies && { trustedProxies }), tailscale: { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cbd2fc3868..ac2d77b0ce 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -123,14 +123,18 @@ export async function doctorCommand( note(gatewayDetails.remoteFallbackNote, "Gateway"); } if (resolveMode(cfg) === "local") { + const gatewayBind = cfg.gateway?.bind ?? "loopback"; + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const requireGatewayAuth = gatewayBind !== "loopback" || tailscaleMode !== "off"; const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, - tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + tailscaleMode, }); - const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token); + const needsToken = + requireGatewayAuth && auth.mode !== "password" && (auth.mode !== "token" || !auth.token); if (needsToken) { note( - "Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).", + "Gateway auth is off or missing a token. Token auth is recommended when the gateway is exposed beyond local loopback.", "Gateway auth", ); const shouldSetToken =