mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -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:
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user