Security: default gateway auth bootstrap and explicit mode none (#20686)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: be1b73182c
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-19 02:35:50 -05:00
committed by GitHub
parent a2e846f649
commit c5698caca3
18 changed files with 678 additions and 77 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras.
- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr.
- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus.
- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos.

View File

@@ -1976,7 +1976,7 @@ See [Plugins](/tools/plugin).
port: 18789,
bind: "loopback",
auth: {
mode: "token", // token | password | trusted-proxy
mode: "token", // none | token | password | trusted-proxy
token: "your-token",
// password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD
// trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth
@@ -2022,6 +2022,7 @@ See [Plugins](/tools/plugin).
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). Defaults to `true` when `tailscale.mode = "serve"`.
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.

View File

@@ -1385,9 +1385,9 @@ Notes:
### Why do I need a token on localhost now
The wizard generates a gateway token by default (even on loopback) so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. Paste the token into the Control UI settings (or your client config) to connect.
OpenClaw enforces token auth by default, including loopback. If no token is configured, gateway startup auto-generates one and saves it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway.
If you **really** want open loopback, remove `gateway.auth` from your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`.
If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`.
### Do I have to restart after changing config

View File

@@ -98,6 +98,25 @@ describe("ensureBrowserControlAuth", () => {
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("respects explicit none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
browser: {
enabled: true,
},
};
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("reuses auth from latest config snapshot", async () => {
const cfg: OpenClawConfig = {
browser: {

View File

@@ -49,6 +49,27 @@ describe("ensureBrowserControlAuth", () => {
});
});
describe("none mode", () => {
it("should not auto-generate token when auth mode is none", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
expect(result.auth.password).toBeUndefined();
});
});
describe("token mode", () => {
it("should return existing token if configured", async () => {
const cfg: OpenClawConfig = {

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
export type BrowserControlAuth = {
token?: string;
@@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "none") {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth };
}
@@ -71,25 +75,21 @@ export async function ensureBrowserControlAuth(params: {
if (latestCfg.gateway?.auth?.mode === "password") {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "none") {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth: latestAuth };
}
const generatedToken = crypto.randomBytes(24).toString("hex");
const nextCfg: OpenClawConfig = {
...latestCfg,
gateway: {
...latestCfg.gateway,
auth: {
...latestCfg.gateway?.auth,
mode: "token",
token: generatedToken,
},
},
};
await writeConfigFile(nextCfg);
const ensured = await ensureGatewayStartupAuth({
cfg: latestCfg,
env,
persist: true,
});
const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env);
return {
auth: { token: generatedToken },
generatedToken,
auth: ensuredAuth,
generatedToken: ensured.generatedToken,
};
}

View File

@@ -132,4 +132,22 @@ describe("gateway run option collisions", () => {
}),
);
});
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
const { addGatewayRunCommand } = await import("./run.js");
const program = new Command();
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
await program.parseAsync(["gateway", "run", "--allow-unconfigured"], {
from: "user",
});
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
expect.objectContaining({
bind: "loopback",
}),
);
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { Command } from "commander";
import type { GatewayAuthMode } from "../../config/config.js";
import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js";
import {
CONFIG_PATH,
loadConfig,
@@ -193,7 +193,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
return;
}
const tailscaleRaw = toOptionString(opts.tailscale);
const tailscaleMode =
const tailscaleMode: GatewayTailscaleMode | null =
tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel"
? tailscaleRaw
: null;
@@ -239,14 +239,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
}
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
const authConfig = {
...cfg.gateway?.auth,
...(authMode ? { mode: authMode } : {}),
...(passwordRaw ? { password: passwordRaw } : {}),
...(tokenRaw ? { token: tokenRaw } : {}),
};
const authOverride =
authMode || passwordRaw || tokenRaw || authModeRaw
? {
...(authMode ? { mode: authMode } : {}),
...(tokenRaw ? { token: tokenRaw } : {}),
...(passwordRaw ? { password: passwordRaw } : {}),
}
: undefined;
const resolvedAuth = resolveGatewayAuth({
authConfig,
authConfig: cfg.gateway?.auth,
authOverride,
env: process.env,
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
});
@@ -257,6 +260,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
const hasSharedSecret =
(resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword);
const canBootstrapToken = resolvedAuthMode === "token" && !hasToken;
const authHints: string[] = [];
if (miskeys.hasGatewayToken) {
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
@@ -266,19 +270,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
);
}
if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) {
defaultRuntime.error(
[
"Gateway auth is set to token, but no token is configured.",
"Set gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN), or pass --token.",
...authHints,
]
.filter(Boolean)
.join("\n"),
);
defaultRuntime.exit(1);
return;
}
if (resolvedAuthMode === "password" && !hasPassword) {
defaultRuntime.error(
[
@@ -292,7 +283,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1);
return;
}
if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") {
if (resolvedAuthMode === "none") {
gatewayLog.warn(
"Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.",
);
}
if (
bind !== "loopback" &&
!hasSharedSecret &&
!canBootstrapToken &&
resolvedAuthMode !== "trusted-proxy"
) {
defaultRuntime.error(
[
`Refusing to bind gateway to ${bind} without auth.`,
@@ -305,6 +306,13 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1);
return;
}
const tailscaleOverride =
tailscaleMode || opts.tailscaleResetOnExit
? {
...(tailscaleMode ? { mode: tailscaleMode } : {}),
...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}),
}
: undefined;
try {
await runGatewayLoop({
@@ -312,21 +320,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
start: async () =>
await startGatewayServer(port, {
bind,
auth:
authMode || passwordRaw || tokenRaw || authModeRaw
? {
mode: authMode ?? undefined,
token: tokenRaw,
password: passwordRaw,
}
: undefined,
tailscale:
tailscaleMode || opts.tailscaleResetOnExit
? {
mode: tailscaleMode ?? undefined,
resetOnExit: Boolean(opts.tailscaleResetOnExit),
}
: undefined,
auth: authOverride,
tailscale: tailscaleOverride,
}),
});
} catch (err) {

View File

@@ -76,7 +76,7 @@ export type GatewayControlUiConfig = {
dangerouslyDisableDeviceAuth?: boolean;
};
export type GatewayAuthMode = "token" | "password" | "trusted-proxy";
export type GatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
/**
* Configuration for trusted reverse proxy authentication.
@@ -104,7 +104,7 @@ export type GatewayTrustedProxyConfig = {
};
export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when set. */
/** Authentication mode for Gateway connections. Defaults to token when unset. */
mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;

View File

@@ -410,7 +410,12 @@ export const OpenClawSchema = z
auth: z
.object({
mode: z
.union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")])
.union([
z.literal("none"),
z.literal("token"),
z.literal("password"),
z.literal("trusted-proxy"),
])
.optional(),
token: z.string().optional().register(sensitive),
password: z.string().optional().register(sensitive),

View File

@@ -34,6 +34,7 @@ describe("gateway auth", () => {
}),
).toMatchObject({
mode: "password",
modeSource: "password",
token: "env-token",
password: "env-password",
});
@@ -49,12 +50,42 @@ describe("gateway auth", () => {
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "none",
mode: "token",
modeSource: "default",
token: undefined,
password: undefined,
});
});
it("resolves explicit auth mode none from config", () => {
expect(
resolveGatewayAuth({
authConfig: { mode: "none" },
env: {} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "none",
modeSource: "config",
token: undefined,
password: undefined,
});
});
it("marks mode source as override when runtime mode override is provided", () => {
expect(
resolveGatewayAuth({
authConfig: { mode: "password", password: "config-password" },
authOverride: { mode: "token" },
env: {} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "token",
modeSource: "override",
token: undefined,
password: "config-password",
});
});
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
@@ -90,6 +121,34 @@ describe("gateway auth", () => {
expect(res.reason).toBe("token_missing_config");
});
it("allows explicit auth mode none", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: false },
connectAuth: null,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("keeps none mode authoritative even when token is present", async () => {
const auth = resolveGatewayAuth({
authConfig: { mode: "none", token: "configured-token" },
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
mode: "none",
modeSource: "config",
token: "configured-token",
});
const res = await authorizeGatewayConnect({
auth,
connectAuth: null,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("reports missing and mismatched password reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },

View File

@@ -20,9 +20,16 @@ import {
} from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuthModeSource =
| "override"
| "config"
| "password"
| "token"
| "default";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
modeSource?: ResolvedGatewayAuthModeSource;
token?: string;
password?: string;
allowTailscale: boolean;
@@ -178,24 +185,55 @@ async function resolveVerifiedTailscaleUser(params: {
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): ResolvedGatewayAuth {
const authConfig = params.authConfig ?? {};
const baseAuthConfig = params.authConfig ?? {};
const authOverride = params.authOverride ?? undefined;
const authConfig: GatewayAuthConfig = { ...baseAuthConfig };
if (authOverride) {
if (authOverride.mode !== undefined) {
authConfig.mode = authOverride.mode;
}
if (authOverride.token !== undefined) {
authConfig.token = authOverride.token;
}
if (authOverride.password !== undefined) {
authConfig.password = authOverride.password;
}
if (authOverride.allowTailscale !== undefined) {
authConfig.allowTailscale = authOverride.allowTailscale;
}
if (authOverride.rateLimit !== undefined) {
authConfig.rateLimit = authOverride.rateLimit;
}
if (authOverride.trustedProxy !== undefined) {
authConfig.trustedProxy = authOverride.trustedProxy;
}
}
const env = params.env ?? process.env;
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
if (authConfig.mode) {
let modeSource: ResolvedGatewayAuth["modeSource"];
if (authOverride?.mode !== undefined) {
mode = authOverride.mode;
modeSource = "override";
} else if (authConfig.mode) {
mode = authConfig.mode;
modeSource = "config";
} else if (password) {
mode = "password";
modeSource = "password";
} else if (token) {
mode = "token";
modeSource = "token";
} else {
mode = "none";
mode = "token";
modeSource = "default";
}
const allowTailscale =
@@ -204,6 +242,7 @@ export function resolveGatewayAuth(params: {
return {
mode,
modeSource,
token,
password,
allowTailscale,
@@ -317,6 +356,10 @@ export async function authorizeGatewayConnect(params: {
return { ok: false, reason: result.reason };
}
if (auth.mode === "none") {
return { ok: true, method: "none" };
}
const limiter = params.rateLimiter;
const ip =
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;

View File

@@ -115,5 +115,42 @@ describe("resolveGatewayRuntimeConfig", () => {
expect(result.authMode).toBe("token");
expect(result.bindHost).toBe("0.0.0.0");
});
it("should allow loopback binding with explicit none mode", async () => {
const cfg = {
gateway: {
bind: "loopback" as const,
auth: {
mode: "none" as const,
},
},
};
const result = await resolveGatewayRuntimeConfig({
cfg,
port: 18789,
});
expect(result.authMode).toBe("none");
expect(result.bindHost).toBe("127.0.0.1");
});
it("should reject lan binding with explicit none mode", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "none" as const,
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("refusing to bind gateway");
});
});
});

View File

@@ -12,6 +12,7 @@ import {
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { resolveHooksConfig } from "./hooks.js";
import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
export type GatewayRuntimeConfig = {
bindHost: string;
@@ -57,21 +58,13 @@ export async function resolveGatewayRuntimeConfig(params: {
typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0
? controlUiRootRaw.trim()
: undefined;
const authBase = params.cfg.gateway?.auth ?? {};
const authOverrides = params.auth ?? {};
const authConfig = {
...authBase,
...authOverrides,
};
const tailscaleBase = params.cfg.gateway?.tailscale ?? {};
const tailscaleOverrides = params.tailscale ?? {};
const tailscaleConfig = {
...tailscaleBase,
...tailscaleOverrides,
};
const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides);
const tailscaleMode = tailscaleConfig.mode ?? "off";
const resolvedAuth = resolveGatewayAuth({
authConfig,
authConfig: params.cfg.gateway?.auth,
authOverride: params.auth,
env: process.env,
tailscaleMode,
});

View File

@@ -616,6 +616,36 @@ describe("gateway server auth/connect", () => {
});
});
describe("explicit none auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = { mode: "none" };
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
});
test("allows loopback connect without shared secret when mode is none", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(true);
ws.close();
});
});
describe("tailscale auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;

View File

@@ -84,6 +84,7 @@ import {
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { ensureGatewayStartupAuth } from "./startup-auth.js";
export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js";
@@ -227,7 +228,26 @@ export async function startGatewayServer(
}
}
const cfgAtStart = loadConfig();
let cfgAtStart = loadConfig();
const authBootstrap = await ensureGatewayStartupAuth({
cfg: cfgAtStart,
env: process.env,
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
persist: true,
});
cfgAtStart = authBootstrap.cfg;
if (authBootstrap.generatedToken) {
if (authBootstrap.persistedGeneratedToken) {
log.info(
"Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).",
);
} else {
log.warn(
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token <token>`.",
);
}
}
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
writeConfigFile: mocks.writeConfigFile,
};
});
import { ensureGatewayStartupAuth } from "./startup-auth.js";
describe("ensureGatewayStartupAuth", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.writeConfigFile.mockReset();
});
it("generates and persists a token when startup auth is missing", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {},
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0];
expect(persisted?.gateway?.auth?.mode).toBe("token");
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken);
});
it("does not generate when token already exists", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "configured-token",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("configured-token");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in password mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("password");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in trusted-proxy mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("trusted-proxy");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in explicit none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("none");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("treats undefined token override as no override", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "from-config",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token", token: undefined },
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("from-config");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips explicit non-token mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips explicit none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips implicit password mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
password: "configured-password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

147
src/gateway/startup-auth.ts Normal file
View File

@@ -0,0 +1,147 @@
import crypto from "node:crypto";
import type {
GatewayAuthConfig,
GatewayTailscaleConfig,
OpenClawConfig,
} from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js";
export function mergeGatewayAuthConfig(
base?: GatewayAuthConfig,
override?: GatewayAuthConfig,
): GatewayAuthConfig {
const merged: GatewayAuthConfig = { ...base };
if (!override) {
return merged;
}
if (override.mode !== undefined) {
merged.mode = override.mode;
}
if (override.token !== undefined) {
merged.token = override.token;
}
if (override.password !== undefined) {
merged.password = override.password;
}
if (override.allowTailscale !== undefined) {
merged.allowTailscale = override.allowTailscale;
}
if (override.rateLimit !== undefined) {
merged.rateLimit = override.rateLimit;
}
if (override.trustedProxy !== undefined) {
merged.trustedProxy = override.trustedProxy;
}
return merged;
}
export function mergeGatewayTailscaleConfig(
base?: GatewayTailscaleConfig,
override?: GatewayTailscaleConfig,
): GatewayTailscaleConfig {
const merged: GatewayTailscaleConfig = { ...base };
if (!override) {
return merged;
}
if (override.mode !== undefined) {
merged.mode = override.mode;
}
if (override.resetOnExit !== undefined) {
merged.resetOnExit = override.resetOnExit;
}
return merged;
}
function resolveGatewayAuthFromConfig(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
}) {
const tailscaleConfig = mergeGatewayTailscaleConfig(
params.cfg.gateway?.tailscale,
params.tailscaleOverride,
);
return resolveGatewayAuth({
authConfig: params.cfg.gateway?.auth,
authOverride: params.authOverride,
env: params.env,
tailscaleMode: tailscaleConfig.mode ?? "off",
});
}
function shouldPersistGeneratedToken(params: {
persistRequested: boolean;
resolvedAuth: ResolvedGatewayAuth;
}): boolean {
if (!params.persistRequested) {
return false;
}
// Keep CLI/runtime mode overrides ephemeral: startup should not silently
// mutate durable auth policy when mode was chosen by an override flag.
if (params.resolvedAuth.modeSource === "override") {
return false;
}
return true;
}
export async function ensureGatewayStartupAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
persist?: boolean;
}): Promise<{
cfg: OpenClawConfig;
auth: ReturnType<typeof resolveGatewayAuth>;
generatedToken?: string;
persistedGeneratedToken: boolean;
}> {
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const resolved = resolveGatewayAuthFromConfig({
cfg: params.cfg,
env,
authOverride: params.authOverride,
tailscaleOverride: params.tailscaleOverride,
});
if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) {
return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false };
}
const generatedToken = crypto.randomBytes(24).toString("hex");
const nextCfg: OpenClawConfig = {
...params.cfg,
gateway: {
...params.cfg.gateway,
auth: {
...params.cfg.gateway?.auth,
mode: "token",
token: generatedToken,
},
},
};
const persist = shouldPersistGeneratedToken({
persistRequested,
resolvedAuth: resolved,
});
if (persist) {
await writeConfigFile(nextCfg);
}
const nextAuth = resolveGatewayAuthFromConfig({
cfg: nextCfg,
env,
authOverride: params.authOverride,
tailscaleOverride: params.tailscaleOverride,
});
return {
cfg: nextCfg,
auth: nextAuth,
generatedToken,
persistedGeneratedToken: persist,
};
}