diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d14dff836d..52ea5fc88c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. - Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault. - Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008. - Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek. diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 982ada9c21f..65a2aeccfa1 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -361,6 +361,89 @@ describe("runDaemonInstall", () => { expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); }); + it("reinstalls when the loaded service still embeds OPENCLAW_GATEWAY_TOKEN", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_GATEWAY_TOKEN: "stale-service-token", + }, + } as never); + + await runDaemonInstall({ json: true }); + + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + expect(actionState.warnings).toContain( + "Gateway service OPENCLAW_GATEWAY_TOKEN differs from the current install plan; refreshing the install.", + ); + }); + + it("returns already-installed when the embedded gateway token matches the install plan", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_GATEWAY_TOKEN: "durable-token", + }, + } as never); + buildGatewayInstallPlanMock.mockResolvedValueOnce({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "durable-token", + }, + }); + + await runDaemonInstall({ json: true }); + + expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); + }); + + it("reinstalls when the embedded gateway token differs from the install plan", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_GATEWAY_TOKEN: "stale-service-token", + }, + } as never); + buildGatewayInstallPlanMock.mockResolvedValueOnce({ + programArguments: ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "fresh-token", + }, + }); + + await runDaemonInstall({ json: true }); + + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + expect(actionState.warnings).toContain( + "Gateway service OPENCLAW_GATEWAY_TOKEN differs from the current install plan; refreshing the install.", + ); + }); + + it("does not reinstall when OPENCLAW_GATEWAY_TOKEN comes from an env file", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_GATEWAY_TOKEN: "env-file-token", + }, + environmentValueSources: { + OPENCLAW_GATEWAY_TOKEN: "file", + }, + } as never); + + await runDaemonInstall({ json: true }); + + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); + }); + it("reinstalls when an existing service is missing the nvm TLS CA bundle", async () => { service.isLoaded.mockResolvedValue(true); resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 0bbd2b517a6..c426120ec7b 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -3,12 +3,16 @@ import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.j import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, + type GatewayDaemonRuntime, } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js"; import { readConfigFileSnapshotForWrite } from "../../config/io.js"; import { resolveGatewayPort } from "../../config/paths.js"; +import type { OpenClawConfig } from "../../config/types.js"; +import { readEmbeddedGatewayToken } from "../../daemon/service-audit.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import type { GatewayServiceCommandConfig } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; import { isDangerousHostEnvOverrideVarName, @@ -16,6 +20,7 @@ import { normalizeEnvVarKey, } from "../../infra/host-env-security.js"; import { defaultRuntime } from "../../runtime.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { formatCliCommand } from "../command-format.js"; import { buildDaemonServiceSnapshot, installDaemonServiceAndEmit } from "./response.js"; import { @@ -98,6 +103,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const service = resolveGatewayService(); let loaded = false; let existingServiceEnv: Record | undefined; + let existingServiceCommand: GatewayServiceCommandConfig | null = null; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { @@ -109,7 +115,8 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } } if (loaded) { - existingServiceEnv = (await service.readCommand(process.env).catch(() => null))?.environment; + existingServiceCommand = await service.readCommand(process.env).catch(() => null); + existingServiceEnv = existingServiceCommand?.environment; } const installEnv = mergeInstallInvocationEnv({ env: process.env, @@ -117,12 +124,20 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }); if (loaded) { if (!opts.force) { - if (await gatewayServiceNeedsAutoNodeExtraCaCertsRefresh({ service, env: process.env })) { - const message = "Gateway service is missing the nvm TLS CA bundle; refreshing the install."; + const autoRefreshMessage = await getGatewayServiceAutoRefreshMessage({ + currentCommand: existingServiceCommand, + env: process.env, + installEnv, + port, + runtime: runtimeRaw, + existingEnvironment: existingServiceEnv, + config: cfg, + }); + if (autoRefreshMessage) { if (json) { - warnings.push(message); + warnings.push(autoRefreshMessage); } else { - defaultRuntime.log(message); + defaultRuntime.log(autoRefreshMessage); } } else { emit({ @@ -196,18 +211,40 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }); } -async function gatewayServiceNeedsAutoNodeExtraCaCertsRefresh(params: { - service: ReturnType; +async function getGatewayServiceAutoRefreshMessage(params: { + currentCommand: GatewayServiceCommandConfig | null; env: Record; -}): Promise { + installEnv: NodeJS.ProcessEnv; + port: number; + runtime: GatewayDaemonRuntime; + existingEnvironment?: Record; + config: OpenClawConfig; +}): Promise { try { - const currentCommand = await params.service.readCommand(params.env); + const currentCommand = params.currentCommand; if (!currentCommand) { - return false; + return undefined; + } + const currentEmbeddedToken = readEmbeddedGatewayToken(currentCommand); + if (currentEmbeddedToken) { + const plannedInstall = await buildGatewayInstallPlan({ + env: params.installEnv, + port: params.port, + runtime: params.runtime, + existingEnvironment: params.existingEnvironment, + warn: () => undefined, + config: params.config, + }); + const plannedEmbeddedToken = normalizeOptionalString( + plannedInstall.environment.OPENCLAW_GATEWAY_TOKEN, + ); + if (currentEmbeddedToken !== plannedEmbeddedToken) { + return "Gateway service OPENCLAW_GATEWAY_TOKEN differs from the current install plan; refreshing the install."; + } } const currentExecPath = currentCommand.programArguments[0]?.trim(); if (!currentExecPath) { - return false; + return undefined; } const currentEnvironment = currentCommand.environment ?? {}; const currentNodeExtraCaCerts = currentEnvironment.NODE_EXTRA_CA_CERTS?.trim(); @@ -221,10 +258,13 @@ async function gatewayServiceNeedsAutoNodeExtraCaCertsRefresh(params: { includeDarwinDefaults: false, }).NODE_EXTRA_CA_CERTS; if (!expectedNodeExtraCaCerts) { - return false; + return undefined; } - return currentNodeExtraCaCerts !== expectedNodeExtraCaCerts; + if (currentNodeExtraCaCerts !== expectedNodeExtraCaCerts) { + return "Gateway service is missing the nvm TLS CA bundle; refreshing the install."; + } + return undefined; } catch { - return false; + return undefined; } }