diff --git a/docs/help/environment.md b/docs/help/environment.md index bc0e68f2ae5b..45975de5e2e2 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -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. diff --git a/docs/help/faq.md b/docs/help/faq.md index 0da5b50e87ae..8866bf186ef1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -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: diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 3a574e520874..139212f98714 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -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 diff --git a/src/cli/gateway-cli/pre-bootstrap.ts b/src/cli/gateway-cli/pre-bootstrap.ts index 28539d367c70..4666ca26bacf 100644 --- a/src/cli/gateway-cli/pre-bootstrap.ts +++ b/src/cli/gateway-cli/pre-bootstrap.ts @@ -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, diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 881d97c5db06..d3d911b7a84b 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -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(); 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 ({ diff --git a/src/config/types.secrets.test.ts b/src/config/types.secrets.test.ts index 305f1b7f7f9c..2229230af402 100644 --- a/src/config/types.secrets.test.ts +++ b/src/config/types.secrets.test.ts @@ -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"])); + }); +}); diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 4c21ed7840fd..e19a7457db05 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -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 { + const ids = new Set(); + const seen = new WeakSet(); + 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") { diff --git a/src/daemon/service-managed-env.ts b/src/daemon/service-managed-env.ts index 73ecd2759f44..502c0e9f8251 100644 --- a/src/daemon/service-managed-env.ts +++ b/src/daemon/service-managed-env.ts @@ -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 | undefined, + platform: NodeJS.Platform = process.platform, +): Set { + // 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; + managedKeys: Iterable; + presentKeys: Iterable; + preserveKeys?: Iterable; +}): 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, keys: Iterable, diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index aba33754c48e..26386b6cfd4f 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -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"); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 14dc543627d6..eb121478f522 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -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; + /** 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; + /** 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; /** 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; @@ -1365,7 +1374,7 @@ async function writeSystemdGatewayEnvironmentFile(params: { fileBackedEnvironment?: Record; environment?: GatewayServiceEnv; }): Promise<{ environmentFiles: string[]; environmentKeys: Set }> { - 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( diff --git a/src/infra/dotenv-global.ts b/src/infra/dotenv-global.ts index 5584a390a378..be03b2c02ec0 100644 --- a/src/infra/dotenv-global.ts +++ b/src/infra/dotenv-global.ts @@ -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; quiet?: boolean; stateEnvPath?: string; }; @@ -76,18 +78,32 @@ export function readDotEnvFile(params: { return { filePath: params.filePath, entries }; } -function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]): Map { +function loadParsedDotEnvFiles( + files: LoadedDotEnvFile[], + overrideKeys?: Iterable, +): Map { 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 }>(); const firstSeen = new Map(); const appliedKeysByFile = new Map(); 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 } 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) ?? []) : [], ), diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 3cce72ca2198..fd00f7eb1fdb 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -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 }) => {