From f8f881f63fab6e2a28074b87bb01274c6befd3f6 Mon Sep 17 00:00:00 2001 From: HCL Date: Mon, 4 May 2026 00:28:33 +0100 Subject: [PATCH] fix(daemon): preserve systemd env-file secrets on re-stage Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + src/commands/configure.daemon.ts | 16 +-- src/commands/daemon-install-helpers.test.ts | 32 +++++ src/commands/daemon-install-helpers.ts | 95 +++++++++++--- src/commands/doctor-gateway-daemon-flow.ts | 16 +-- src/commands/doctor-gateway-services.ts | 2 + .../local/daemon-install.ts | 16 +-- src/daemon/service-managed-env.ts | 6 + src/daemon/service-types.ts | 1 + src/daemon/systemd.test.ts | 111 ++++++++++++++++ src/daemon/systemd.ts | 119 ++++++++++++++++-- 11 files changed, 369 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fb9d6ce51..2fd70dee1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys. - OpenAI/Google Meet: wait for realtime voice `session.updated` before treating the bridge as connected, so Meet joins do not return with audio queued behind an unconfigured realtime session. Thanks @vincentkoc. - Plugins/catalog: merge official external catalog descriptors into partial package channel config metadata, so lagging WeCom/Yuanbao manifests keep their own schema while still exposing host-supplied labels and setup text. Thanks @vincentkoc. - Plugins/catalog: supplement lagging official external WeCom and Yuanbao npm manifests with channel config descriptors and declared tool contracts from the OpenClaw catalog, so trusted package sweeps no longer fail because external package metadata trails the host contract. Thanks @vincentkoc. diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 73fa212dc39..9a0e23bd7b0 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -116,13 +116,14 @@ export async function maybeInstallDaemon(params: { progress.setLabel("Gateway service install blocked."); return; } - const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, - port: params.port, - runtime: daemonRuntime, - warn: (message, title) => note(message, title), - config: cfg, - }); + const { programArguments, workingDirectory, environment, environmentValueSources } = + await buildGatewayInstallPlan({ + env: process.env, + port: params.port, + runtime: daemonRuntime, + warn: (message, title) => note(message, title), + config: cfg, + }); progress.setLabel("Installing Gateway service…"); try { @@ -132,6 +133,7 @@ export async function maybeInstallDaemon(params: { programArguments, workingDirectory, environment, + environmentValueSources, }); progress.setLabel("Gateway service installed."); } catch (err) { diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index be451109883..4d5ed2c9caa 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -838,6 +838,38 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { expect(plan.environment.CUSTOM_TOOL_HOME).toBe("/Users/test/.custom-tool"); }); + it("keeps source metadata for EnvironmentFile-backed preserved vars", async () => { + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + HOME: "/from-service", + OPENCLAW_PORT: "3000", + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + existingEnvironment: { + OPENROUTER_API_KEY: "or-operator-key", + CUSTOM_TOOL_HOME: "/Users/test/.custom-tool", + OPENCLAW_GATEWAY_TOKEN: "old-token", + }, + existingEnvironmentValueSources: { + OPENROUTER_API_KEY: "file", + CUSTOM_TOOL_HOME: "inline", + OPENCLAW_GATEWAY_TOKEN: "file", + }, + }); + + expect(plan.environment.OPENROUTER_API_KEY).toBe("or-operator-key"); + expect(plan.environmentValueSources?.OPENROUTER_API_KEY).toBe("file"); + expect(plan.environment.CUSTOM_TOOL_HOME).toBe("/Users/test/.custom-tool"); + expect(plan.environmentValueSources?.CUSTOM_TOOL_HOME).toBe("inline"); + expect(plan.environment.OPENCLAW_GATEWAY_TOKEN).toBeUndefined(); + expect(plan.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBeUndefined(); + }); + it("does not embed auth-profile env refs when the key is already durable", async () => { await writeStateDirDotEnv("OPENAI_API_KEY=dotenv-openai\n", { stateDir: path.join(tmpDir, ".openclaw"), diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index bb883714d24..687de2c9986 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -20,6 +20,7 @@ import { writeManagedServiceEnvKeysToEnvironment, } from "../daemon/service-managed-env.js"; import { isNonMinimalServicePathEntry } from "../daemon/service-path-policy.js"; +import type { GatewayServiceEnvironmentValueSource } from "../daemon/service-types.js"; import { isDangerousHostEnvOverrideVarName, isDangerousHostEnvVarName, @@ -40,6 +41,7 @@ type GatewayInstallPlan = { programArguments: string[]; workingDirectory?: string; environment: Record; + environmentValueSources?: Record; }; let daemonInstallAuthProfileSourceRuntimePromise: @@ -360,6 +362,22 @@ function collectPreservedExistingServiceEnvVars( return preserved; } +function readExistingEnvironmentValueSource(params: { + existingEnvironmentValueSources?: Record< + string, + GatewayServiceEnvironmentValueSource | undefined + >; + normalizedKey: string; +}): GatewayServiceEnvironmentValueSource | undefined { + for (const [rawKey, source] of Object.entries(params.existingEnvironmentValueSources ?? {})) { + const key = normalizeEnvVarKey(rawKey, { portable: true })?.toUpperCase(); + if (key === params.normalizedKey) { + return source; + } + } + return undefined; +} + function resolveGatewayInstallWorkingDirectory(params: { env: Record; platform: NodeJS.Platform; @@ -381,8 +399,15 @@ async function buildGatewayInstallEnvironment(params: { warn?: DaemonInstallWarnFn; serviceEnvironment: Record; existingEnvironment?: Record; + existingEnvironmentValueSources?: Record< + string, + GatewayServiceEnvironmentValueSource | undefined + >; platform: NodeJS.Platform; -}): Promise> { +}): Promise<{ + environment: Record; + environmentValueSources: Record; +}> { const durableEnvironment = collectDurableServiceEnvVars({ env: params.env, config: params.config, @@ -404,21 +429,47 @@ async function buildGatewayInstallEnvironment(params: { authStore: params.authStore, warn: params.warn, }); + const preservedExistingEnvironment = collectPreservedExistingServiceEnvVars( + params.existingEnvironment, + readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment), + ); const environment: Record = { - ...collectPreservedExistingServiceEnvVars( - params.existingEnvironment, - readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment), - ), + ...preservedExistingEnvironment, ...durableEnvironment, ...configSecretRefEnvironment, ...execSecretRefPassEnvEnvironment, ...authProfileEnvironment, }; + const environmentValueSources: Record = + {}; + for (const rawKey of Object.keys(preservedExistingEnvironment)) { + const normalizedKey = normalizeEnvVarKey(rawKey, { portable: true })?.toUpperCase(); + environmentValueSources[rawKey] = normalizedKey + ? (readExistingEnvironmentValueSource({ + existingEnvironmentValueSources: params.existingEnvironmentValueSources, + normalizedKey, + }) ?? "inline") + : "inline"; + } + for (const key of Object.keys({ + ...durableEnvironment, + ...configSecretRefEnvironment, + ...execSecretRefPassEnvEnvironment, + ...authProfileEnvironment, + })) { + environmentValueSources[key] = "inline"; + } const managedServiceEnvKeys = formatManagedServiceEnvKeys(durableEnvironment, { omitKeys: Object.keys(params.serviceEnvironment), }); writeManagedServiceEnvKeysToEnvironment(environment, managedServiceEnvKeys); + if (environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS) { + environmentValueSources.OPENCLAW_SERVICE_MANAGED_ENV_KEYS = "inline"; + } Object.assign(environment, params.serviceEnvironment); + for (const key of Object.keys(params.serviceEnvironment)) { + environmentValueSources[key] = "inline"; + } const mergedPath = mergeServicePath( params.serviceEnvironment.PATH, params.existingEnvironment?.PATH, @@ -427,8 +478,14 @@ async function buildGatewayInstallEnvironment(params: { ); if (mergedPath) { environment.PATH = mergedPath; + environmentValueSources.PATH = "inline"; } - return environment; + for (const key of Object.keys(environmentValueSources)) { + if (!Object.hasOwn(environment, key)) { + delete environmentValueSources[key]; + } + } + return { environment, environmentValueSources }; } export async function buildGatewayInstallPlan(params: { @@ -444,6 +501,10 @@ export async function buildGatewayInstallPlan(params: { /** Full config to extract env vars from (env vars + inline env keys). */ config?: OpenClawConfig; authStore?: AuthProfileStore; + existingEnvironmentValueSources?: Record< + string, + GatewayServiceEnvironmentValueSource | undefined + >; }): Promise { const platform = params.platform ?? process.platform; const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({ @@ -483,6 +544,17 @@ export async function buildGatewayInstallPlan(params: { extraPathDirs: resolveDaemonNodeBinDir(nodePath), }); + const { environment, environmentValueSources } = await buildGatewayInstallEnvironment({ + env: serviceInputEnv, + config: params.config, + authStore: params.authStore, + warn: params.warn, + serviceEnvironment, + existingEnvironment: params.existingEnvironment, + existingEnvironmentValueSources: params.existingEnvironmentValueSources, + platform, + }); + // Lowest to highest: preserved custom vars, durable config, auth env refs, generated service env. return { programArguments, @@ -491,15 +563,8 @@ export async function buildGatewayInstallPlan(params: { platform, workingDirectory, }), - environment: await buildGatewayInstallEnvironment({ - env: serviceInputEnv, - config: params.config, - authStore: params.authStore, - warn: params.warn, - serviceEnvironment, - existingEnvironment: params.existingEnvironment, - platform, - }), + environment, + ...(Object.keys(environmentValueSources).length > 0 ? { environmentValueSources } : {}), }; } diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 6210e039b39..26a3e46e4ed 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -237,13 +237,14 @@ export async function maybeRepairGatewayDaemon(params: { return; } const port = resolveGatewayPort(params.cfg, process.env); - const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, - port, - runtime: daemonRuntime, - warn: (message, title) => note(message, title), - config: params.cfg, - }); + const { programArguments, workingDirectory, environment, environmentValueSources } = + await buildGatewayInstallPlan({ + env: process.env, + port, + runtime: daemonRuntime, + warn: (message, title) => note(message, title), + config: params.cfg, + }); try { await service.install({ env: process.env, @@ -251,6 +252,7 @@ export async function maybeRepairGatewayDaemon(params: { programArguments, workingDirectory, environment, + environmentValueSources, }); } catch (err) { note(`Gateway service install failed: ${String(err)}`, "Gateway"); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 228aa1405cb..9144effb44b 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -109,6 +109,7 @@ async function buildExpectedGatewayServicePlan(params: { runtime: params.runtime, nodePath: params.nodePath, existingEnvironment: params.command.environment, + existingEnvironmentValueSources: params.command.environmentValueSources, warn: (message, title) => note(message, title), config: params.cfg, }); @@ -593,6 +594,7 @@ export async function maybeRepairGatewayServiceConfig( programArguments: updatedPlan.programArguments, workingDirectory: updatedPlan.workingDirectory, environment: updatedPlan.environment, + environmentValueSources: updatedPlan.environmentValueSources, }); } catch (err) { runtime.error(`Gateway service update failed: ${String(err)}`); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index 5157a8a9bcf..feaf2f3b8c1 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -62,13 +62,14 @@ export async function installGatewayDaemonNonInteractive(params: { runtime.exit(1); return { installed: false }; } - const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, - port, - runtime: daemonRuntimeRaw, - warn: (message) => runtime.log(message), - config: params.nextConfig, - }); + const { programArguments, workingDirectory, environment, environmentValueSources } = + await buildGatewayInstallPlan({ + env: process.env, + port, + runtime: daemonRuntimeRaw, + warn: (message) => runtime.log(message), + config: params.nextConfig, + }); try { await service.install({ env: process.env, @@ -76,6 +77,7 @@ export async function installGatewayDaemonNonInteractive(params: { programArguments, workingDirectory, environment, + environmentValueSources, }); } catch (err) { runtime.error(`Gateway service install failed: ${String(err)}`); diff --git a/src/daemon/service-managed-env.ts b/src/daemon/service-managed-env.ts index 84c25beea32..5048b7233cb 100644 --- a/src/daemon/service-managed-env.ts +++ b/src/daemon/service-managed-env.ts @@ -24,6 +24,12 @@ export function isEnvironmentFileOnlySource( return source === "file"; } +export function hasEnvironmentFileSource( + source: GatewayServiceEnvironmentValueSource | undefined, +): boolean { + return source === "file" || source === "inline-and-file"; +} + function parseManagedServiceEnvKeys(value: string | undefined): Set { const keys = new Set(); for (const entry of value?.split(",") ?? []) { diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index 0a6cf6b5ef3..08a66a0ae43 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -8,6 +8,7 @@ export type GatewayServiceInstallArgs = { programArguments: string[]; workingDirectory?: string; environment?: GatewayServiceEnv; + environmentValueSources?: Record; description?: string; }; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 646774dc71f..6e9b0341881 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -815,6 +815,117 @@ describe("stageSystemdService", () => { expect(envFile).toBe("LLM_API_KEY=dotenv-key\n"); }); }); + + it("clears stale inline-managed keys from env file on re-stage (#76860)", async () => { + await withStageFixture(async ({ env, stateDir, unitPath, envFilePath }) => { + // Existing env file carries a stale OPENCLAW_GATEWAY_TOKEN that the + // operator previously wrote there but staging now supplies inline. + await fs.writeFile( + envFilePath, + ["OPENCLAW_GATEWAY_TOKEN=stale-gateway-token", "OPENROUTER_API_KEY=or-operator-key"].join( + "\n", + ) + "\n", + { encoding: "utf8", mode: 0o600 }, + ); + + await fs.writeFile(path.join(stateDir, ".env"), "LLM_API_KEY=dotenv-key\n", "utf8"); + + mockSystemctlStatusOk(); + + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + workingDirectory: "/tmp", + // Staging manages OPENCLAW_GATEWAY_TOKEN inline; OPENCLAW_SERVICE_MANAGED_ENV_KEYS + // marks it as an OpenClaw-managed key so the stale env-file copy is cleared. + environment: { + OPENCLAW_GATEWAY_TOKEN: "fresh-gateway-token", + LLM_API_KEY: "dotenv-key", + OPENROUTER_API_KEY: "or-operator-key", + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENCLAW_GATEWAY_TOKEN", + }, + environmentValueSources: { + OPENCLAW_GATEWAY_TOKEN: "inline-and-file", + LLM_API_KEY: "inline", + OPENROUTER_API_KEY: "file", + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", + }, + }); + + const [unit, envFile] = await Promise.all([ + fs.readFile(unitPath, "utf8"), + fs.readFile(envFilePath, "utf8"), + ]); + // Stale inline-managed key must be removed from the env file so the + // fresh inline Environment= value wins (EnvironmentFile would override it). + expect(envFile).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + // Operator-added key not managed inline must survive. + expect(envFile).toContain("OPENROUTER_API_KEY=or-operator-key"); + expect(envFile).toContain("LLM_API_KEY=dotenv-key"); + expect(unit).toContain("Environment=OPENCLAW_GATEWAY_TOKEN=fresh-gateway-token"); + expect(unit).not.toContain("Environment=OPENROUTER_API_KEY=or-operator-key"); + expect(unit).not.toContain("Environment=LLM_API_KEY=dotenv-key"); + }); + }); + + it("preserves operator secrets when incoming .env is empty (#76860)", async () => { + await withStageFixture(async ({ env, envFilePath }) => { + // Existing env file has only operator-added secrets; state-dir .env is absent/empty. + await fs.writeFile(envFilePath, "OPENROUTER_API_KEY=or-operator-key\n", { + encoding: "utf8", + mode: 0o600, + }); + + mockSystemctlStatusOk(); + + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + const envFile = await fs.readFile(envFilePath, "utf8"); + // Operator-only secret must survive even when no dotenv vars are staged. + expect(envFile).toContain("OPENROUTER_API_KEY=or-operator-key"); + }); + }); + + it("preserves operator-added secrets in existing env file on re-stage (#76860)", async () => { + await withStageFixture(async ({ env, stateDir, envFilePath }) => { + // Simulate operator pre-populating gateway.systemd.env with provider API keys. + await fs.writeFile( + envFilePath, + [ + "ANTHROPIC_API_KEY=sk-ant-operator-secret", + "OPENROUTER_API_KEY=or-operator-key", + "LLM_API_KEY=old-value", + ].join("\n") + "\n", + { encoding: "utf8", mode: 0o600 }, + ); + + // State-dir .env only provides LLM_API_KEY (not the provider secrets). + await fs.writeFile(path.join(stateDir, ".env"), "LLM_API_KEY=new-value\n", "utf8"); + + mockSystemctlStatusOk(); + + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment: { LLM_API_KEY: "new-value" }, + }); + + const envFile = await fs.readFile(envFilePath, "utf8"); + // Operator secrets must survive; state-dir key gets updated value. + expect(envFile).toContain("ANTHROPIC_API_KEY=sk-ant-operator-secret"); + expect(envFile).toContain("OPENROUTER_API_KEY=or-operator-key"); + expect(envFile).toContain("LLM_API_KEY=new-value"); + }); + }); }); describe("systemd service install and uninstall", () => { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 67747a31704..2c49a078da2 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { readStateDirDotEnvVarsFromStateDir } from "../config/state-dir-dotenv.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; @@ -16,6 +17,11 @@ import { execFileUtf8 } from "./exec-file.js"; import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; import { resolveHomeDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; +import { + hasEnvironmentFileSource, + hasInlineEnvironmentSource, + readManagedServiceEnvKeysFromEnvironment, +} from "./service-managed-env.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceCommandConfig, @@ -147,6 +153,50 @@ function mergeEnvironmentValueSources( return sources; } +function normalizeSystemdEnvironmentKey(key: string): string | null { + return normalizeEnvVarKey(key, { portable: true })?.toUpperCase() ?? null; +} + +function readSystemdEnvironmentValueSource(params: { + environmentValueSources?: Record; + key: string; +}): GatewayServiceEnvironmentValueSource | undefined { + const normalizedKey = normalizeSystemdEnvironmentKey(params.key); + if (!normalizedKey) { + return undefined; + } + for (const [rawKey, source] of Object.entries(params.environmentValueSources ?? {})) { + if (normalizeSystemdEnvironmentKey(rawKey) === normalizedKey) { + return source; + } + } + return undefined; +} + +function collectSystemdInlineManagedKeys(params: { + environment?: GatewayServiceEnv; + environmentValueSources?: Record; +}): Set { + const keys = readManagedServiceEnvKeysFromEnvironment(params.environment); + for (const [rawKey, value] of Object.entries(params.environment ?? {})) { + if (typeof value !== "string" || !value.trim()) { + continue; + } + const key = normalizeSystemdEnvironmentKey(rawKey); + if (!key) { + continue; + } + const source = readSystemdEnvironmentValueSource({ + environmentValueSources: params.environmentValueSources, + key: rawKey, + }); + if (hasInlineEnvironmentSource(source) && !hasEnvironmentFileSource(source)) { + keys.add(key); + } + } + return keys; +} + function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { // Support the common unit-specifier used in user services. return input.replaceAll("%h", toPosixPath(resolveHomeDir(env))); @@ -502,6 +552,7 @@ async function writeSystemdUnit({ programArguments, workingDirectory, environment, + environmentValueSources, description, }: Omit): Promise<{ unitPath: string; backedUp: boolean }> { await assertSystemdAvailable(env); @@ -531,15 +582,28 @@ async function writeSystemdUnit({ return inlineValue.trim() === value.trim(); }), ); - const environmentFiles = await writeSystemdGatewayEnvironmentFile({ + const inlineManagedKeys = collectSystemdInlineManagedKeys({ + environment, + environmentValueSources, + }); + const environmentFileResult = await writeSystemdGatewayEnvironmentFile({ stateDir, dotenvVars: stateDirDotEnvVars, + inlineManagedKeys, }); const environmentSansDotEnvEntries = Object.fromEntries( Object.entries(environment ?? {}).filter(([key, value]) => { if (typeof value !== "string") { return false; } + const normalizedKey = normalizeSystemdEnvironmentKey(key); + if ( + normalizedKey && + environmentFileResult.environmentKeys.has(normalizedKey) && + !inlineManagedKeys.has(normalizedKey) + ) { + return false; + } const stateDirValue = stateDirDotEnvVars[key]; if (typeof stateDirValue !== "string") { return true; @@ -552,7 +616,7 @@ async function writeSystemdUnit({ programArguments, workingDirectory, environment: environmentSansDotEnvEntries, - environmentFiles, + environmentFiles: environmentFileResult.environmentFiles, }); await fs.writeFile(unitPath, unit, "utf8"); return { unitPath, backedUp }; @@ -561,12 +625,12 @@ async function writeSystemdUnit({ async function writeSystemdGatewayEnvironmentFile(params: { stateDir: string; dotenvVars: Record; -}): Promise { - const entries = Object.entries(params.dotenvVars); - if (entries.length === 0) { - return []; - } - for (const [key, value] of entries) { + /** OpenClaw-managed keys that must not be preserved from an old env file; stale file values + * would override fresh inline Environment= entries because EnvironmentFile takes precedence. */ + inlineManagedKeys?: ReadonlySet; +}): Promise<{ environmentFiles: string[]; environmentKeys: Set }> { + const incoming = params.dotenvVars; + for (const [key, value] of Object.entries(incoming)) { if (/[\r\n]/.test(value)) { throw new Error( `state-dir .env contains a multiline value for ${key}; systemd EnvironmentFile values must be single-line`, @@ -574,10 +638,45 @@ async function writeSystemdGatewayEnvironmentFile(params: { } } const envFilePath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME); - const content = entries.map(([key, value]) => `${key}=${value}`).join("\n"); + + // Read the existing env file first so we can preserve operator-added secrets + // (e.g. provider API keys) across upgrades and re-stages. + // OpenClaw-managed keys (identified by inlineManagedKeys) are excluded: a stale + // file copy would override the fresh inline Environment= value because systemd's + // EnvironmentFile takes precedence over inline Environment= directives. + let existing: Record = {}; + try { + existing = await readSystemdEnvironmentFile(envFilePath); + } catch { + // File does not exist yet — nothing to preserve. + } + const operatorOnly = params.inlineManagedKeys + ? Object.fromEntries( + Object.entries(existing).filter(([key]) => { + const normalized = normalizeSystemdEnvironmentKey(key); + return !normalized || !params.inlineManagedKeys!.has(normalized); + }), + ) + : existing; + const merged = { ...operatorOnly, ...incoming }; + const environmentKeys = new Set( + Object.keys(merged).flatMap((key) => { + const normalized = normalizeSystemdEnvironmentKey(key); + return normalized ? [normalized] : []; + }), + ); + + // If the merged result is empty there is nothing to write and no file needed. + if (Object.keys(merged).length === 0) { + return { environmentFiles: [], environmentKeys }; + } + + const content = Object.entries(merged) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); await fs.chmod(envFilePath, 0o600); - return [envFilePath]; + return { environmentFiles: [envFilePath], environmentKeys }; } export async function stageSystemdService({