fix(systemd): apply dotenv changes on gateway restart (#119441)

* fix(systemd): load state dotenv at gateway startup

Signed-off-by: sallyom <somalley@redhat.com>

* fix(systemd): refresh managed dotenv values on restart

Signed-off-by: sallyom <somalley@redhat.com>

* fix(gateway): clear removed managed dotenv values

Signed-off-by: sallyom <somalley@redhat.com>

* fix(dotenv): canonicalize managed override keys

Signed-off-by: sallyom <somalley@redhat.com>

* fix(secrets): preserve providerless env refs

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley
2026-08-07 10:04:04 -04:00
committed by GitHub
parent 01cc71060d
commit 4cbbfc2159
12 changed files with 332 additions and 34 deletions
+2 -2
View File
@@ -7,14 +7,14 @@ read_when:
title: "Environment variables"
---
OpenClaw pulls environment variables from multiple sources. The rule is **never override existing values**.
OpenClaw pulls environment variables from multiple sources. The normal rule is **never override existing values**. For an OpenClaw-installed systemd service, the global `.env` may replace only service values that OpenClaw recorded as managed; operator-owned service values still take precedence.
Workspace `.env` files are a lower-trust source: OpenClaw ignores provider credentials and protected runtime controls from workspace `.env` before applying precedence.
## Precedence (highest to lowest)
1. **Process environment** (what the Gateway process already has from the parent shell/daemon).
2. **`.env` in the current working directory** (dotenv default; does not override; provider credentials and protected runtime controls are ignored).
3. **Global `.env`** at `~/.openclaw/.env` (aka `$OPENCLAW_STATE_DIR/.env`; recommended for provider API keys; does not override).
3. **Global `.env`** at `~/.openclaw/.env` (aka `$OPENCLAW_STATE_DIR/.env`; recommended for provider API keys; does not override except for recorded OpenClaw-managed systemd service values).
4. **Config `env` block** in `~/.openclaw/openclaw.json` (applied only if missing).
5. **Optional login-shell import** (`env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1`), applied only for missing expected keys.
+1 -1
View File
@@ -884,7 +884,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
- `.env` from the current working directory.
- a global fallback `.env` from `~/.openclaw/.env` (`$OPENCLAW_STATE_DIR/.env`).
Neither `.env` file overrides existing env vars. Provider credential and endpoint-routing keys are an exception for workspace `.env`: keys such as `GEMINI_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, or any key ending in `_ENDPOINT` (and other bundled-provider auth or endpoint env vars) are ignored from workspace `.env` and should live in the process environment, `~/.openclaw/.env`, or config `env`.
Normally, neither `.env` file overrides existing env vars. For an OpenClaw-installed systemd service, the global `.env` may replace only service values that OpenClaw recorded as managed; operator-owned service values still take precedence. Provider credential and endpoint-routing keys are an exception for workspace `.env`: keys such as `GEMINI_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, or any key ending in `_ENDPOINT` (and other bundled-provider auth or endpoint env vars) are ignored from workspace `.env` and should live in the process environment, `~/.openclaw/.env`, or config `env`.
Inline env vars in config apply only if missing from the process env:
+1 -1
View File
@@ -39,7 +39,7 @@ openclaw gateway install --force
openclaw gateway start
```
The `OPENCLAW_PROXY_URL` env fallback is best for foreground runs. To use it with an installed service, put it in the service's durable environment (`$OPENCLAW_STATE_DIR/.env`, default `~/.openclaw/.env`), then reinstall so launchd/systemd/Scheduled Tasks picks it up.
The `OPENCLAW_PROXY_URL` env fallback is best for foreground runs. To use it with an installed service, put it in the service's durable environment (`$OPENCLAW_STATE_DIR/.env`, default `~/.openclaw/.env`), then reinstall so launchd/systemd/Scheduled Tasks picks it up. This variable is copied into the generated service environment rather than tracked as a managed dotenv key, so systemd's restart-only managed dotenv refresh does not apply.
### HTTPS proxy endpoint with a private CA
+16
View File
@@ -248,6 +248,8 @@ async function guardGatewayRunSelectedConfig(
{ normalizeEnv },
{ normalizeStateDirEnv, resolveStateDir },
{ resolveConfigDir },
{ collectEnvSecretRefIds },
{ clearMissingManagedServiceEnvKeys, readManagedSystemdServiceEnvKeysFromEnvironment },
] = await Promise.all([
import("node:path"),
import("../../config/config-env-vars.js"),
@@ -255,6 +257,8 @@ async function guardGatewayRunSelectedConfig(
import("../../infra/env.js"),
import("../../config/paths.js"),
import("../../utils.js"),
import("../../config/types.secrets.js"),
import("../../daemon/service-managed-env.js"),
]);
const invocationDestructiveOverride = resolveInvocationDestructiveOverride();
if (params.environmentSelection) {
@@ -269,6 +273,7 @@ async function guardGatewayRunSelectedConfig(
normalizeStateDirEnv(process.env);
const loaded = loadGlobalRuntimeDotEnvFiles({
...(gatewayRunTargetSelectedByConfig ? { entryFilter: isConfigRuntimeEnvVarAllowed } : {}),
overrideKeys: readManagedSystemdServiceEnvKeysFromEnvironment(process.env),
quiet: true,
...resolveGatewayRunDotEnvPaths({
env: process.env,
@@ -340,6 +345,14 @@ async function guardGatewayRunSelectedConfig(
}
return params.opts.reset === true;
}
// The service marker also owns config SecretRefs. Only dotenv-absent keys with no current
// config reference are stale; clearing the broad marker blindly would drop file-backed refs.
clearMissingManagedServiceEnvKeys({
environment: process.env,
managedKeys: readManagedSystemdServiceEnvKeysFromEnvironment(process.env),
presentKeys: trustedEnvLoad.dotenvPresentKeys,
preserveKeys: collectEnvSecretRefIds(snapshot.sourceConfig),
});
const selectionSignature = resolveGatewayConfigSelectionSignature(process.env);
applySelectedConfigEnv(snapshot);
// Only selection inputs survive a selection hop. Reload credentials once the final config and
@@ -547,6 +560,7 @@ export async function reloadTrustedGatewayRunEnvironment(params: {
{ normalizeEnv },
{ normalizeStateDirEnv, resolveStateDir },
{ resolveConfigDir },
{ readManagedSystemdServiceEnvKeysFromEnvironment },
] = await Promise.all([
import("node:path"),
import("../../config/env-vars.js"),
@@ -554,6 +568,7 @@ export async function reloadTrustedGatewayRunEnvironment(params: {
import("../../infra/env.js"),
import("../../config/paths.js"),
import("../../utils.js"),
import("../../daemon/service-managed-env.js"),
]);
const envBeforeReload = { ...process.env };
const selectionSignature = resolveGatewayConfigSelectionSignature(process.env);
@@ -561,6 +576,7 @@ export async function reloadTrustedGatewayRunEnvironment(params: {
normalizeStateDirEnv(process.env);
loadGlobalRuntimeDotEnvFiles({
...(gatewayRunTargetSelectedByConfig ? { entryFilter: isConfigRuntimeEnvVarAllowed } : {}),
overrideKeys: readManagedSystemdServiceEnvKeysFromEnvironment(process.env),
quiet: true,
...resolveGatewayRunDotEnvPaths({
env: process.env,
@@ -15,6 +15,7 @@ import {
} from "../../test-utils/env.js";
import { getFreePort } from "../../test-utils/ports.js";
import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js";
import { withMockedPlatform } from "../../test-utils/vitest-spies.js";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
import { installGatewayRunRuntimeHooks } from "./runtime-hooks.js";
@@ -50,7 +51,9 @@ const runGatewayLoop = vi.fn(async ({ start }: GatewayLoopParams) => {
const normalizeStateDirEnv = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const pinConfigDir = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const pinRuntimePaths = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const detectRespawnSupervisor = vi.fn(() => null as "systemd" | null);
type RuntimeDotEnvLoadResult = {
dotenvPresentKeys: string[];
gatewayEnvAppliedKeys: string[];
stateEnvAppliedKeys: string[];
};
@@ -185,6 +188,7 @@ vi.mock("../../utils.js", async (importOriginal) => ({
vi.mock("../../infra/dotenv-global.js", () => ({
loadGlobalRuntimeDotEnvFiles: (opts?: unknown) =>
loadGlobalRuntimeDotEnvFiles(opts) ?? {
dotenvPresentKeys: [],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: [],
},
@@ -299,7 +303,7 @@ vi.mock("../../infra/supervisor-markers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/supervisor-markers.js")>();
return {
...actual,
detectRespawnSupervisor: () => null,
detectRespawnSupervisor: () => detectRespawnSupervisor(),
};
});
@@ -408,6 +412,7 @@ describe("gateway run option collisions", () => {
});
netState.autoBindHost = "127.0.0.1";
netState.container = false;
detectRespawnSupervisor.mockReset().mockReturnValue(null);
readBestEffortConfig.mockClear();
readConfigFileSnapshotWithPluginMetadata.mockClear();
gatewayLogMessages.length = 0;
@@ -1292,6 +1297,7 @@ describe("gateway run option collisions", () => {
loadGlobalRuntimeDotEnvFiles.mockImplementation(() => {
setTestEnvValue("OPENCLAW_STATE_DIR", "/tmp/openclaw-reset-retargeted");
return {
dotenvPresentKeys: ["OPENCLAW_STATE_DIR"],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: ["OPENCLAW_STATE_DIR"],
};
@@ -1572,6 +1578,106 @@ describe("gateway run option collisions", () => {
expect(secondOptions.startupStartedAt).toBe(2000);
});
it("lets gateway bootstrap refresh inherited service-managed dotenv keys", async () => {
detectRespawnSupervisor.mockReturnValue("systemd");
await withMockedPlatform("linux", () =>
withEnvAsync(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY,ANTHROPIC_API_KEY",
},
async () => {
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
},
),
);
expect(loadGlobalRuntimeDotEnvFiles).toHaveBeenCalledWith(
expect.objectContaining({
overrideKeys: new Set(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]),
}),
);
});
it("limits inherited service-managed dotenv refresh to systemd launches", async () => {
const serviceManagedEnv = await import("../../daemon/service-managed-env.js");
detectRespawnSupervisor.mockReturnValueOnce("systemd");
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY",
},
"linux",
),
).toEqual(new Set(["OPENAI_API_KEY"]));
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"linux",
),
).toEqual(new Set());
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"darwin",
),
).toEqual(new Set());
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"win32",
),
).toEqual(new Set());
});
it("clears only missing managed keys after reading the selected config", async () => {
detectRespawnSupervisor.mockReturnValue("systemd");
configState.snapshot = {
config: {},
exists: true,
sourceConfig: {
models: {
providers: {
openai: {
apiKey: { source: "env", id: "SECRET_REF_KEY" },
},
},
},
},
valid: true,
};
loadGlobalRuntimeDotEnvFiles.mockReturnValue({
dotenvPresentKeys: [],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: [],
});
await withMockedPlatform("linux", () =>
withEnvAsync(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "REMOVED_KEY,SECRET_REF_KEY",
REMOVED_KEY: "stale-service-value",
SECRET_REF_KEY: "file-backed-value",
OPERATOR_KEY: "operator-value",
},
async () => {
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
expect(process.env.REMOVED_KEY).toBeUndefined();
expect(process.env.SECRET_REF_KEY).toBe("file-backed-value");
expect(process.env.OPERATOR_KEY).toBe("operator-value");
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
},
),
);
});
it("re-inspects crash-loop breaker state for each boot iteration", async () => {
runGatewayLoop.mockImplementationOnce(
async ({
+14 -1
View File
@@ -1,6 +1,6 @@
// Verifies secret config type guards and normalization helpers.
import { describe, expect, it } from "vitest";
import { parseEnvTemplateSecretRef } from "./types.secrets.js";
import { collectEnvSecretRefIds, parseEnvTemplateSecretRef } from "./types.secrets.js";
describe("parseEnvTemplateSecretRef", () => {
it("parses ${VAR} template syntax", () => {
@@ -43,3 +43,16 @@ describe("parseEnvTemplateSecretRef", () => {
expect(parseEnvTemplateSecretRef("prefix-$OPENAI_API_KEY")).toBeNull();
});
});
describe("collectEnvSecretRefIds", () => {
it("finds structured and shorthand refs throughout config values", () => {
expect(
collectEnvSecretRefIds({
structured: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
providerless: { source: "env", id: "LEGACY_API_KEY" },
nested: [{ token: "$DISCORD_BOT_TOKEN" }],
ignored: { source: "file", provider: "default", id: "/run/secret" },
}),
).toEqual(new Set(["OPENAI_API_KEY", "LEGACY_API_KEY", "DISCORD_BOT_TOKEN"]));
});
});
+22
View File
@@ -102,6 +102,28 @@ export function parseEnvTemplateSecretRef(
};
}
/** Collect env ids from supported SecretRef shapes anywhere in a config tree. */
export function collectEnvSecretRefIds(value: unknown): Set<string> {
const ids = new Set<string>();
const seen = new WeakSet<object>();
const visit = (candidate: unknown): void => {
const ref = coerceSecretRef(candidate);
if (ref?.source === "env" && isValidEnvSecretRefId(ref.id)) {
ids.add(ref.id);
return;
}
if (typeof candidate !== "object" || candidate === null || seen.has(candidate)) {
return;
}
seen.add(candidate);
for (const child of Array.isArray(candidate) ? candidate : Object.values(candidate)) {
visit(child);
}
};
visit(value);
return ids;
}
/** Detect retired env SecretRef marker strings for migration and explicit rejection. */
export function isLegacySecretRefEnvMarker(value: unknown): value is string {
if (typeof value !== "string") {
+31
View File
@@ -1,6 +1,7 @@
/** Tracks managed service environment keys across reinstall and repair flows. */
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
import { detectRespawnSupervisor } from "../infra/supervisor-markers.js";
import type { GatewayServiceEnvironmentValueSource } from "./service-types.js";
const MANAGED_SERVICE_ENV_KEYS_VAR = "OPENCLAW_SERVICE_MANAGED_ENV_KEYS";
@@ -72,6 +73,36 @@ export function readManagedServiceEnvKeysFromEnvironment(
return new Set();
}
export function readManagedSystemdServiceEnvKeysFromEnvironment(
environment: Record<string, string | undefined> | undefined,
platform: NodeJS.Platform = process.platform,
): Set<string> {
// Only systemd snapshots state dotenv values into its inherited service environment.
// Other supervisors retain their existing reinstall-based precedence contract.
return environment && detectRespawnSupervisor(environment, platform) === "systemd"
? readManagedServiceEnvKeysFromEnvironment(environment)
: new Set();
}
export function clearMissingManagedServiceEnvKeys(params: {
environment: Record<string, string | undefined>;
managedKeys: Iterable<string>;
presentKeys: Iterable<string>;
preserveKeys?: Iterable<string>;
}): void {
const presentKeys = new Set(
[...params.presentKeys, ...(params.preserveKeys ?? [])].flatMap((key) => {
const normalized = normalizeServiceEnvKey(key);
return normalized ? [normalized] : [];
}),
);
const missingKeys = [...params.managedKeys].filter((key) => {
const normalized = normalizeServiceEnvKey(key);
return normalized !== null && !presentKeys.has(normalized);
});
deleteManagedServiceEnvKeys(params.environment, missingKeys);
}
function deleteManagedServiceEnvKeys(
environment: Record<string, string | undefined>,
keys: Iterable<string>,
+47 -19
View File
@@ -1702,7 +1702,7 @@ describe("stageSystemdService", () => {
});
});
it("writes dotenv-backed values to a separate env file and keeps inline env minimal", async () => {
it("leaves dotenv-backed values to gateway startup so restarts observe edits", async () => {
await withStageFixture(async ({ env, stateDir, unitPath, envFilePath }) => {
await fs.writeFile(
path.join(stateDir, ".env"),
@@ -1724,18 +1724,48 @@ describe("stageSystemdService", () => {
},
});
const [unit, envFile, envFileStat] = await Promise.all([
fs.readFile(unitPath, "utf8"),
fs.readFile(envFilePath, "utf8"),
fs.stat(envFilePath),
]);
const unit = await fs.readFile(unitPath, "utf8");
expect(unit).toContain(`EnvironmentFile=-${envFilePath}`);
expect(unit).not.toContain("EnvironmentFile=");
expect(unit).toContain("Environment=OPENCLAW_GATEWAY_PORT=18789");
expect(unit).not.toContain("Environment=OPENCLAW_GATEWAY_TOKEN=dotenv-token");
expect(unit).not.toContain("Environment=LLM_API_KEY=dotenv-key");
expect(envFile).toBe("OPENCLAW_GATEWAY_TOKEN=dotenv-token\nLLM_API_KEY=dotenv-key\n");
expect(envFileStat.mode & 0o777).toBe(0o600);
await expect(fs.access(envFilePath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("drops previously managed dotenv keys on restage while preserving operator entries", async () => {
await withStageFixture(async ({ env, unitPath, envFilePath, stateDir }) => {
const wrapperPath = path.join(stateDir, "openclaw-wrapper");
await fs.writeFile(wrapperPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
await fs.writeFile(
envFilePath,
"OPENAI_API_KEY=stale-managed\nOPERATOR_API_KEY=operator-owned\n",
{ encoding: "utf8", mode: 0o600 },
);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(
unitPath,
[
"[Service]",
`ExecStart=${wrapperPath} gateway run`,
`EnvironmentFile=-${envFilePath}`,
"Environment=OPENCLAW_SERVICE_MANAGED_ENV_KEYS=OPENAI_API_KEY",
].join("\n"),
"utf8",
);
mockSystemctlStatusOk();
await stageSystemdService({
env,
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
programArguments: [wrapperPath, "gateway", "run"],
workingDirectory: "/tmp",
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
});
expect(await fs.readFile(envFilePath, "utf8")).toBe("OPERATOR_API_KEY=operator-owned\n");
expect(await fs.readFile(unitPath, "utf8")).toContain(`EnvironmentFile=-${envFilePath}`);
});
});
@@ -2023,7 +2053,7 @@ describe("stageSystemdService", () => {
});
});
it("keeps inline overrides out of the generated env file", async () => {
it("keeps explicit inline overrides while leaving dotenv values to gateway startup", async () => {
await withStageFixture(async ({ env, stateDir, unitPath, envFilePath }) => {
await fs.writeFile(
path.join(stateDir, ".env"),
@@ -2044,14 +2074,12 @@ describe("stageSystemdService", () => {
},
});
const [unit, envFile] = await Promise.all([
fs.readFile(unitPath, "utf8"),
fs.readFile(envFilePath, "utf8"),
]);
const unit = await fs.readFile(unitPath, "utf8");
expect(unit).toContain(`EnvironmentFile=-${envFilePath}`);
expect(unit).not.toContain("EnvironmentFile=");
expect(unit).toContain("Environment=OPENCLAW_GATEWAY_TOKEN=fresh-token");
expect(envFile).toBe("LLM_API_KEY=dotenv-key\n");
expect(unit).not.toContain("Environment=LLM_API_KEY=dotenv-key");
await expect(fs.access(envFilePath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -2101,7 +2129,7 @@ describe("stageSystemdService", () => {
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(envFile).not.toContain("LLM_API_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");
@@ -2159,10 +2187,10 @@ describe("stageSystemdService", () => {
});
const envFile = await fs.readFile(envFilePath, "utf8");
// Operator secrets must survive; state-dir key gets updated value.
// Operator secrets survive; the state-dir key is loaded directly by Gateway startup.
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");
expect(envFile).not.toContain("LLM_API_KEY");
});
});
+14 -3
View File
@@ -1150,6 +1150,9 @@ async function writeSystemdUnit({
await assertNoSystemGatewayOwnership(env);
const unitPath = resolveSystemdUnitPath(env);
const priorManagedKeys = readManagedServiceEnvKeysFromEnvironment(
(await readSystemdServiceExecStart(env))?.environment,
);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await assertSystemdManagedPathIsNotSymlink(unitPath);
const fileManagedKeys = collectSystemdFileManagedKeys({
@@ -1201,7 +1204,8 @@ async function writeSystemdUnit({
try {
const environmentFileResult = await writeSystemdGatewayEnvironmentFile({
stateDir,
dotenvVars: stateDirDotEnvVars,
stateDirDotEnvKeys: Object.keys(stateDirDotEnvVars),
priorManagedKeys,
inlineManagedKeys,
fileManagedKeys,
skippedManagedKeys: skippedShellReferenceKeys,
@@ -1352,7 +1356,12 @@ async function publishSystemdUnit(params: {
async function writeSystemdGatewayEnvironmentFile(params: {
stateDir: string;
dotenvVars: Record<string, string>;
/** Keys loaded by the Gateway directly from the state-dir .env. They must be removed from
* generated files so a supervisor restart cannot shadow a later .env edit. */
stateDirDotEnvKeys?: Iterable<string>;
/** Keys owned by the previously installed service. Preserve the prior ownership record so
* deleting a managed dotenv key cannot reclassify its stale file value as operator-owned. */
priorManagedKeys?: Iterable<string>;
/** 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>;
@@ -1365,7 +1374,7 @@ async function writeSystemdGatewayEnvironmentFile(params: {
fileBackedEnvironment?: Record<string, string>;
environment?: GatewayServiceEnv;
}): Promise<{ environmentFiles: string[]; environmentKeys: Set<string> }> {
const incoming = { ...params.dotenvVars, ...params.fileBackedEnvironment };
const incoming = { ...params.fileBackedEnvironment };
for (const [key, value] of Object.entries(incoming)) {
if (/[\r\n]/.test(value)) {
throw new Error(
@@ -1411,6 +1420,8 @@ async function writeSystemdGatewayEnvironmentFile(params: {
const managedKeysToDrop = normalizeServiceEnvKeys([
...(params.inlineManagedKeys ?? []),
...(params.fileManagedKeys ?? []),
...(params.priorManagedKeys ?? []),
...(params.stateDirDotEnvKeys ?? []),
...(params.skippedManagedKeys ?? []),
]);
const operatorOnly = Object.fromEntries(
+32 -6
View File
@@ -29,6 +29,8 @@ type LoadedDotEnvFile = {
type GlobalRuntimeDotEnvOptions = {
additionalEnvPaths?: string[];
entryFilter?: (key: string, value: string) => boolean;
/** Keys whose service-managed inherited values may be replaced by trusted dotenv files. */
overrideKeys?: Iterable<string>;
quiet?: boolean;
stateEnvPath?: string;
};
@@ -76,18 +78,32 @@ export function readDotEnvFile(params: {
return { filePath: params.filePath, entries };
}
function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]): Map<string, string[]> {
function loadParsedDotEnvFiles(
files: LoadedDotEnvFile[],
overrideKeys?: Iterable<string>,
): Map<string, string[]> {
const preExistingKeys = new Set(Object.keys(process.env));
const canonicalizeKey = (key: string): string | null =>
normalizeEnvVarKey(key, { portable: true })?.toUpperCase() ?? null;
const normalizedOverrideKeys = new Set(
[...(overrideKeys ?? [])].flatMap((key) => {
const normalized = canonicalizeKey(key);
return normalized ? [normalized] : [];
}),
);
const conflicts = new Map<string, { keptPath: string; ignoredPath: string; keys: Set<string> }>();
const firstSeen = new Map<string, { value: string; filePath: string }>();
const appliedKeysByFile = new Map<string, string[]>();
for (const file of files) {
for (const { key, value } of file.entries) {
if (preExistingKeys.has(key)) {
const canonicalKey = canonicalizeKey(key);
const mayOverride = canonicalKey !== null && normalizedOverrideKeys.has(canonicalKey);
const precedenceKey = mayOverride && canonicalKey ? canonicalKey : key;
if (preExistingKeys.has(key) && !mayOverride) {
continue;
}
const previous = firstSeen.get(key);
const previous = firstSeen.get(precedenceKey);
if (previous) {
if (previous.value !== value) {
// First file wins for deterministic startup; conflicts are logged once
@@ -106,8 +122,17 @@ function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]): Map<string, string[]>
}
continue;
}
firstSeen.set(key, { value, filePath: file.filePath });
if (process.env[key] === undefined) {
firstSeen.set(precedenceKey, { value, filePath: file.filePath });
if (process.env[key] === undefined || mayOverride) {
if (mayOverride) {
// Service ownership is case-insensitive. Refresh every inherited alias so Linux cannot
// retain a stale uppercase value beside a newly parsed lowercase dotenv key.
for (const inheritedKey of preExistingKeys) {
if (canonicalizeKey(inheritedKey) === canonicalKey) {
process.env[inheritedKey] = value;
}
}
}
process.env[key] = value;
const appliedKeys = appliedKeysByFile.get(file.filePath);
if (appliedKeys) {
@@ -164,8 +189,9 @@ export function loadGlobalRuntimeDotEnvFiles(opts?: GlobalRuntimeDotEnvOptions)
parsedFiles.push(gatewayEnv);
}
const parsed = parsedFiles.filter((file): file is LoadedDotEnvFile => file !== null);
const appliedKeysByFile = loadParsedDotEnvFiles(parsed);
const appliedKeysByFile = loadParsedDotEnvFiles(parsed, opts?.overrideKeys);
return {
dotenvPresentKeys: [...new Set(parsed.flatMap((file) => file.entries.map(({ key }) => key)))],
stateEnvAppliedKeys: globalEnvs.flatMap((file) =>
file ? (appliedKeysByFile.get(file.filePath) ?? []) : [],
),
+45
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadCliDotEnv } from "../cli/dotenv.js";
import { captureFullEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { loadGlobalRuntimeDotEnvFiles } from "./dotenv-global.js";
import { loadDotEnv, loadWorkspaceDotEnvFile } from "./dotenv.js";
const loggerMocks = vi.hoisted(() => ({
@@ -145,6 +146,50 @@ describe("loadDotEnv", () => {
});
});
it("lets the state dotenv replace only explicitly service-managed inherited values", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ stateDir }) => {
const stateEnvPath = path.join(stateDir, ".env");
await writeEnvFile(
stateEnvPath,
"MANAGED_API_KEY=from-state\nOPERATOR_API_KEY=from-state\n",
);
process.env.MANAGED_API_KEY = "stale-service-value";
process.env.OPERATOR_API_KEY = "operator-service-value";
const loaded = loadGlobalRuntimeDotEnvFiles({
stateEnvPath,
overrideKeys: ["MANAGED_API_KEY"],
quiet: true,
});
expect(process.env.MANAGED_API_KEY).toBe("from-state");
expect(process.env.OPERATOR_API_KEY).toBe("operator-service-value");
expect(loaded.dotenvPresentKeys).toEqual(["MANAGED_API_KEY", "OPERATOR_API_KEY"]);
});
});
});
it("matches service-managed dotenv override keys case-insensitively", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ stateDir }) => {
const stateEnvPath = path.join(stateDir, ".env");
await writeEnvFile(stateEnvPath, "hass_token=from-state\n");
process.env.HASS_TOKEN = "stale-uppercase-service-value";
process.env.hass_token = "stale-lowercase-service-value";
loadGlobalRuntimeDotEnvFiles({
stateEnvPath,
overrideKeys: ["HASS_TOKEN"],
quiet: true,
});
expect(process.env.HASS_TOKEN).toBe("from-state");
expect(process.env.hass_token).toBe("from-state");
});
});
});
it("loads fallback state .env when CWD .env is missing", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {