mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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) ?? []) : [],
|
||||
),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user