fix(gateway): refresh stale embedded service tokens

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.

Co-authored-by: Harry Xie <harryhsieh963@yahoo.com>
This commit is contained in:
Harry Xie
2026-04-25 23:42:14 -07:00
committed by GitHub
parent 8c87a637e9
commit 77719899f3
3 changed files with 138 additions and 14 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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<string, string> | 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<typeof resolveGatewayService>;
async function getGatewayServiceAutoRefreshMessage(params: {
currentCommand: GatewayServiceCommandConfig | null;
env: Record<string, string | undefined>;
}): Promise<boolean> {
installEnv: NodeJS.ProcessEnv;
port: number;
runtime: GatewayDaemonRuntime;
existingEnvironment?: Record<string, string | undefined>;
config: OpenClawConfig;
}): Promise<string | undefined> {
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;
}
}