From e2e1197fa9ca4aa2bc30cdcb69f74f98a1ceade0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 20:00:20 +0900 Subject: [PATCH] refactor(gateway): clarify local mode guardrails --- docs/cli/gateway.md | 3 +- docs/cli/onboard.md | 1 + docs/gateway/index.md | 12 ++--- docs/gateway/troubleshooting.md | 2 +- docs/help/troubleshooting.md | 2 +- docs/install/fly.md | 2 +- docs/start/openclaw.md | 1 + .../gateway-cli/run.option-collisions.test.ts | 3 +- src/cli/gateway-cli/run.ts | 51 +++++++++++++++---- .../onboard-non-interactive.gateway.test.ts | 32 +++++++++++- 10 files changed, 86 insertions(+), 23 deletions(-) diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index f3f2c2f6ced..52c752e1e74 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -37,6 +37,7 @@ Notes: - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. - `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly. +- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to “guess local” for you. - Binding beyond loopback without auth is blocked (safety guardrail). - `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed). - `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit. @@ -51,7 +52,7 @@ Notes: - `--password-file `: read the gateway password from a file. - `--tailscale `: expose the Gateway via Tailscale. - `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown. -- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. +- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. This bypasses the startup guard for ad-hoc/dev bootstrap only; it does not write or repair the config file. - `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md). - `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`). - `--force`: kill any existing listener on the selected port before starting. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index fe70c1f081c..2e368e36fb3 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -83,6 +83,7 @@ Gateway token options in non-interactive mode: - With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance. - With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly. - Local onboarding writes `gateway.mode="local"` into the config. If a later config file is missing `gateway.mode`, treat that as config damage or an incomplete manual edit, not as a valid local-mode shortcut. +- `--allow-unconfigured` is a separate gateway runtime escape hatch. It does not mean onboarding may omit `gateway.mode`. Example: diff --git a/docs/gateway/index.md b/docs/gateway/index.md index bedab8eaec6..077043bc59e 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -259,12 +259,12 @@ Events are not replayed. On sequence gaps, refresh state (`health`, `system-pres ## Common failure signatures -| Signature | Likely issue | -| -------------------------------------------------------------- | ---------------------------------------- | -| `refusing to bind gateway ... without auth` | Non-loopback bind without token/password | -| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict | -| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode | -| `unauthorized` during connect | Auth mismatch between client and gateway | +| Signature | Likely issue | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `refusing to bind gateway ... without auth` | Non-loopback bind without token/password | +| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict | +| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode, or local-mode stamp is missing from a damaged config | +| `unauthorized` during connect | Auth mismatch between client and gateway | For full diagnosis ladders, use [Gateway Troubleshooting](/gateway/troubleshooting). diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 7275f3c5cea..557eb7cb6e5 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -170,7 +170,7 @@ Look for: Common signatures: -- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled, or the config file was clobbered and lost `gateway.mode`. Fix: set `gateway.mode="local"` in your config, or re-run `openclaw onboard --mode local` / `openclaw setup` to restamp the expected local-mode config. If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`. +- `Gateway start blocked: set gateway.mode=local` or `existing config is missing gateway.mode` → local gateway mode is not enabled, or the config file was clobbered and lost `gateway.mode`. Fix: set `gateway.mode="local"` in your config, or re-run `openclaw onboard --mode local` / `openclaw setup` to restamp the expected local-mode config. If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`. - `refusing to bind gateway ... without auth` → non-loopback bind without token/password. - `another gateway instance is already listening` / `EADDRINUSE` → port conflict. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 58865b27b8b..97fc7dbd5e4 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -165,7 +165,7 @@ flowchart TD Common log signatures: - - `Gateway start blocked: set gateway.mode=local` → gateway mode is unset/remote. + - `Gateway start blocked: set gateway.mode=local` or `existing config is missing gateway.mode` → gateway mode is remote, or the config file is missing the local-mode stamp and should be repaired. - `refusing to bind gateway ... without auth` → non-loopback bind without token/password. - `another gateway instance is already listening` or `EADDRINUSE` → port already taken. diff --git a/docs/install/fly.md b/docs/install/fly.md index 0a5c9b22338..3cdfe52dee0 100644 --- a/docs/install/fly.md +++ b/docs/install/fly.md @@ -297,7 +297,7 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory). ### Config Not Being Read -If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/openclaw.json` should be read on restart. +`--allow-unconfigured` only bypasses the startup guard. It does not create or repair `/data/openclaw.json`, so make sure your real config exists and includes `gateway.mode="local"` when you want a normal local gateway start. Verify the config exists: diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 1cd7a888a80..b4df4988cdb 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -59,6 +59,7 @@ openclaw gateway --port 18789 ```json5 { + gateway: { mode: "local" }, channels: { whatsapp: { allowFrom: ["+15555550123"] } }, } ``` diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 874f7a5cd5e..5d475bb529d 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -227,8 +227,9 @@ describe("gateway run option collisions", () => { await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:1"); expect(runtimeErrors).toContain( - "Gateway start blocked: set gateway.mode=local (current: unset) or pass --allow-unconfigured.", + "Gateway start blocked: existing config is missing gateway.mode. Treat this as suspicious or clobbered config. Re-run `openclaw onboard --mode local` or `openclaw setup`, set gateway.mode=local manually, or pass --allow-unconfigured.", ); + expect(runtimeErrors).toContain("Config write audit: /tmp/logs/config-audit.jsonl"); expect(startGatewayServer).not.toHaveBeenCalled(); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 9764f14c3e5..80b701e4aa7 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -141,6 +141,36 @@ function formatModeErrorList(modes: readonly T[]): string { return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`; } +function getGatewayStartGuardErrors(params: { + allowUnconfigured?: boolean; + configExists: boolean; + configAuditPath: string; + mode: string | undefined; +}): string[] { + if (params.allowUnconfigured || params.mode === "local") { + return []; + } + if (!params.configExists) { + return [ + `Missing config. Run \`${formatCliCommand("openclaw setup")}\` or set gateway.mode=local (or pass --allow-unconfigured).`, + ]; + } + if (params.mode === undefined) { + return [ + [ + "Gateway start blocked: existing config is missing gateway.mode.", + "Treat this as suspicious or clobbered config.", + `Re-run \`${formatCliCommand("openclaw onboard --mode local")}\` or \`${formatCliCommand("openclaw setup")}\`, set gateway.mode=local manually, or pass --allow-unconfigured.`, + ].join(" "), + `Config write audit: ${params.configAuditPath}`, + ]; + } + return [ + `Gateway start blocked: set gateway.mode=local (current: ${params.mode}) or pass --allow-unconfigured.`, + `Config write audit: ${params.configAuditPath}`, + ]; +} + function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts { const resolved: GatewayRunOpts = { ...opts }; @@ -349,16 +379,15 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl"); const effectiveCfg = snapshot?.valid ? snapshot.config : cfg; const mode = effectiveCfg.gateway?.mode; - if (!opts.allowUnconfigured && mode !== "local") { - if (!configExists) { - defaultRuntime.error( - `Missing config. Run \`${formatCliCommand("openclaw setup")}\` or set gateway.mode=local (or pass --allow-unconfigured).`, - ); - } else { - defaultRuntime.error( - `Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`, - ); - defaultRuntime.error(`Config write audit: ${configAuditPath}`); + const guardErrors = getGatewayStartGuardErrors({ + allowUnconfigured: opts.allowUnconfigured, + configExists, + configAuditPath, + mode, + }); + if (guardErrors.length > 0) { + for (const error of guardErrors) { + defaultRuntime.error(error); } defaultRuntime.exit(1); return; @@ -536,7 +565,7 @@ export function addGatewayRunCommand(cmd: Command): Command { ) .option( "--allow-unconfigured", - "Allow gateway start without gateway.mode=local in config", + "Allow gateway start without enforcing gateway.mode=local in config (does not repair config)", false, ) .option("--dev", "Create a dev config + workspace if missing (no BOOTSTRAP.md)", false) diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index ccb00be71ee..4646f1c9c75 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -255,18 +255,48 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const configPath = resolveStateConfigPath(process.env, stateDir); const cfg = await readJsonFile<{ - gateway?: { auth?: { mode?: string; token?: string } }; + gateway?: { mode?: string; auth?: { mode?: string; token?: string } }; agents?: { defaults?: { workspace?: string } }; tools?: { profile?: string }; }>(configPath); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); + expect(cfg?.gateway?.mode).toBe("local"); expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); }); }, 60_000); + it("keeps gateway.mode=local on the install-daemon onboarding path", async () => { + await withStateDir("state-install-daemon-local-mode-", async (stateDir) => { + const workspace = path.join(stateDir, "openclaw"); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: true, + gatewayBind: "loopback", + }, + runtime, + ); + + const configPath = resolveStateConfigPath(process.env, stateDir); + const cfg = await readJsonFile<{ + gateway?: { mode?: string; bind?: string }; + }>(configPath); + + expect(cfg?.gateway?.mode).toBe("local"); + expect(cfg?.gateway?.bind).toBe("loopback"); + expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); + }); + }, 60_000); + it("uses OPENCLAW_GATEWAY_TOKEN when --gateway-token is omitted", async () => { await withStateDir("state-env-token-", async (stateDir) => { const envToken = "tok_env_fallback_123";