fix(daemon): preserve systemd env-file secrets on re-stage

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HCL
2026-05-04 00:28:33 +01:00
committed by Peter Steinberger
parent d841394eba
commit f8f881f63f
11 changed files with 369 additions and 46 deletions

View File

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

View File

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

View File

@@ -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"),

View File

@@ -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<string, string | undefined>;
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
};
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<string, string | undefined>;
platform: NodeJS.Platform;
@@ -381,8 +399,15 @@ async function buildGatewayInstallEnvironment(params: {
warn?: DaemonInstallWarnFn;
serviceEnvironment: Record<string, string | undefined>;
existingEnvironment?: Record<string, string | undefined>;
existingEnvironmentValueSources?: Record<
string,
GatewayServiceEnvironmentValueSource | undefined
>;
platform: NodeJS.Platform;
}): Promise<Record<string, string | undefined>> {
}): Promise<{
environment: Record<string, string | undefined>;
environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
}> {
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<string, string | undefined> = {
...collectPreservedExistingServiceEnvVars(
params.existingEnvironment,
readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment),
),
...preservedExistingEnvironment,
...durableEnvironment,
...configSecretRefEnvironment,
...execSecretRefPassEnvEnvironment,
...authProfileEnvironment,
};
const environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource | undefined> =
{};
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<GatewayInstallPlan> {
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 } : {}),
};
}

View File

@@ -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");

View File

@@ -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)}`);

View File

@@ -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)}`);

View File

@@ -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<string> {
const keys = new Set<string>();
for (const entry of value?.split(",") ?? []) {

View File

@@ -8,6 +8,7 @@ export type GatewayServiceInstallArgs = {
programArguments: string[];
workingDirectory?: string;
environment?: GatewayServiceEnv;
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
description?: string;
};

View File

@@ -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", () => {

View File

@@ -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<string, GatewayServiceEnvironmentValueSource | undefined>;
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<string, GatewayServiceEnvironmentValueSource | undefined>;
}): Set<string> {
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<GatewayServiceInstallArgs, "stdout">): 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<string, string>;
}): Promise<string[]> {
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<string>;
}): Promise<{ environmentFiles: string[]; environmentKeys: Set<string> }> {
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<string, string> = {};
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({