From 94d6858160e1fe29e4ff8a3b2a493732f6728a93 Mon Sep 17 00:00:00 2001 From: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:45:09 -0600 Subject: [PATCH] fix(gateway): auto-generate token during `gateway install` to prevent launchd restart loop (#13813) When the gateway is installed as a macOS launch agent and no token is configured, the service enters an infinite restart loop because launchd does not inherit shell environment variables. Auto-generate a token during `gateway install` when auth mode is `token` and no token exists, matching the existing pattern in doctor.ts and configure.gateway.ts. The token is persisted to the config file and embedded in the plist EnvironmentVariables for belt-and-suspenders reliability. Relates-to: #5103, #2433, #1690, #7749 --- src/cli/daemon-cli/install.ts | 79 ++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 1838d09a2..2ac374b95 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -4,9 +4,16 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, } from "../../commands/daemon-runtime.js"; -import { loadConfig, resolveGatewayPort } from "../../config/config.js"; +import { randomToken } from "../../commands/onboard-helpers.js"; +import { + loadConfig, + readConfigFileSnapshot, + resolveGatewayPort, + writeConfigFile, +} from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { resolveGatewayAuth } from "../../gateway/auth.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js"; @@ -93,10 +100,78 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } } + // Resolve effective auth mode to determine if token auto-generation is needed. + // Password-mode and Tailscale-only installs do not need a token. + const resolvedAuth = resolveGatewayAuth({ + authConfig: cfg.gateway?.auth, + tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + }); + const needsToken = + resolvedAuth.mode === "token" && !resolvedAuth.token && !resolvedAuth.allowTailscale; + + let token: string | undefined = + opts.token || + cfg.gateway?.auth?.token || + process.env.OPENCLAW_GATEWAY_TOKEN || + process.env.CLAWDBOT_GATEWAY_TOKEN; + + if (!token && needsToken) { + token = randomToken(); + const warnMsg = "No gateway token found. Auto-generated one and saving to config."; + if (json) { + warnings.push(warnMsg); + } else { + defaultRuntime.log(warnMsg); + } + + // Persist to config file so the gateway reads it at runtime + // (launchd does not inherit shell env vars, and CLI tools also + // read gateway.auth.token from config for gateway calls). + try { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + // Config file exists but is corrupt/unparseable — don't risk overwriting. + // Token is still embedded in the plist EnvironmentVariables. + const msg = "Warning: config file exists but is invalid; skipping token persistence."; + if (json) { + warnings.push(msg); + } else { + defaultRuntime.log(msg); + } + } else { + const baseConfig = snapshot.exists ? snapshot.config : {}; + if (!baseConfig.gateway?.auth?.token) { + await writeConfigFile({ + ...baseConfig, + gateway: { + ...baseConfig.gateway, + auth: { + ...baseConfig.gateway?.auth, + mode: baseConfig.gateway?.auth?.mode ?? "token", + token, + }, + }, + }); + } else { + // Another process wrote a token between loadConfig() and now. + token = baseConfig.gateway.auth.token; + } + } + } catch (err) { + // Non-fatal: token is still embedded in the plist EnvironmentVariables. + const msg = `Warning: could not persist token to config: ${String(err)}`; + if (json) { + warnings.push(msg); + } else { + defaultRuntime.log(msg); + } + } + } + const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token: opts.token || cfg.gateway?.auth?.token || process.env.OPENCLAW_GATEWAY_TOKEN, + token, runtime: runtimeRaw, warn: (message) => { if (json) {