Skip to content

Commit f8f881f

Browse files
HCLclaude
authored andcommitted
fix(daemon): preserve systemd env-file secrets on re-stage
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
1 parent d841394 commit f8f881f

11 files changed

Lines changed: 369 additions & 46 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232

3333
### Fixes
3434

35+
- Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys.
3536
- OpenAI/Google Meet: wait for realtime voice `session.updated` before treating the bridge as connected, so Meet joins do not return with audio queued behind an unconfigured realtime session. Thanks @vincentkoc.
3637
- Plugins/catalog: merge official external catalog descriptors into partial package channel config metadata, so lagging WeCom/Yuanbao manifests keep their own schema while still exposing host-supplied labels and setup text. Thanks @vincentkoc.
3738
- Plugins/catalog: supplement lagging official external WeCom and Yuanbao npm manifests with channel config descriptors and declared tool contracts from the OpenClaw catalog, so trusted package sweeps no longer fail because external package metadata trails the host contract. Thanks @vincentkoc.

src/commands/configure.daemon.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,14 @@ export async function maybeInstallDaemon(params: {
116116
progress.setLabel("Gateway service install blocked.");
117117
return;
118118
}
119-
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
120-
env: process.env,
121-
port: params.port,
122-
runtime: daemonRuntime,
123-
warn: (message, title) => note(message, title),
124-
config: cfg,
125-
});
119+
const { programArguments, workingDirectory, environment, environmentValueSources } =
120+
await buildGatewayInstallPlan({
121+
env: process.env,
122+
port: params.port,
123+
runtime: daemonRuntime,
124+
warn: (message, title) => note(message, title),
125+
config: cfg,
126+
});
126127

127128
progress.setLabel("Installing Gateway service…");
128129
try {
@@ -132,6 +133,7 @@ export async function maybeInstallDaemon(params: {
132133
programArguments,
133134
workingDirectory,
134135
environment,
136+
environmentValueSources,
135137
});
136138
progress.setLabel("Gateway service installed.");
137139
} catch (err) {

src/commands/daemon-install-helpers.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,38 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
838838
expect(plan.environment.CUSTOM_TOOL_HOME).toBe("/Users/test/.custom-tool");
839839
});
840840

841+
it("keeps source metadata for EnvironmentFile-backed preserved vars", async () => {
842+
mockNodeGatewayPlanFixture({
843+
serviceEnvironment: {
844+
HOME: "/from-service",
845+
OPENCLAW_PORT: "3000",
846+
},
847+
});
848+
849+
const plan = await buildGatewayInstallPlan({
850+
env: { HOME: tmpDir },
851+
port: 3000,
852+
runtime: "node",
853+
existingEnvironment: {
854+
OPENROUTER_API_KEY: "or-operator-key",
855+
CUSTOM_TOOL_HOME: "/Users/test/.custom-tool",
856+
OPENCLAW_GATEWAY_TOKEN: "old-token",
857+
},
858+
existingEnvironmentValueSources: {
859+
OPENROUTER_API_KEY: "file",
860+
CUSTOM_TOOL_HOME: "inline",
861+
OPENCLAW_GATEWAY_TOKEN: "file",
862+
},
863+
});
864+
865+
expect(plan.environment.OPENROUTER_API_KEY).toBe("or-operator-key");
866+
expect(plan.environmentValueSources?.OPENROUTER_API_KEY).toBe("file");
867+
expect(plan.environment.CUSTOM_TOOL_HOME).toBe("/Users/test/.custom-tool");
868+
expect(plan.environmentValueSources?.CUSTOM_TOOL_HOME).toBe("inline");
869+
expect(plan.environment.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
870+
expect(plan.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
871+
});
872+
841873
it("does not embed auth-profile env refs when the key is already durable", async () => {
842874
await writeStateDirDotEnv("OPENAI_API_KEY=dotenv-openai\n", {
843875
stateDir: path.join(tmpDir, ".openclaw"),

src/commands/daemon-install-helpers.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
writeManagedServiceEnvKeysToEnvironment,
2121
} from "../daemon/service-managed-env.js";
2222
import { isNonMinimalServicePathEntry } from "../daemon/service-path-policy.js";
23+
import type { GatewayServiceEnvironmentValueSource } from "../daemon/service-types.js";
2324
import {
2425
isDangerousHostEnvOverrideVarName,
2526
isDangerousHostEnvVarName,
@@ -40,6 +41,7 @@ type GatewayInstallPlan = {
4041
programArguments: string[];
4142
workingDirectory?: string;
4243
environment: Record<string, string | undefined>;
44+
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
4345
};
4446

4547
let daemonInstallAuthProfileSourceRuntimePromise:
@@ -360,6 +362,22 @@ function collectPreservedExistingServiceEnvVars(
360362
return preserved;
361363
}
362364

365+
function readExistingEnvironmentValueSource(params: {
366+
existingEnvironmentValueSources?: Record<
367+
string,
368+
GatewayServiceEnvironmentValueSource | undefined
369+
>;
370+
normalizedKey: string;
371+
}): GatewayServiceEnvironmentValueSource | undefined {
372+
for (const [rawKey, source] of Object.entries(params.existingEnvironmentValueSources ?? {})) {
373+
const key = normalizeEnvVarKey(rawKey, { portable: true })?.toUpperCase();
374+
if (key === params.normalizedKey) {
375+
return source;
376+
}
377+
}
378+
return undefined;
379+
}
380+
363381
function resolveGatewayInstallWorkingDirectory(params: {
364382
env: Record<string, string | undefined>;
365383
platform: NodeJS.Platform;
@@ -381,8 +399,15 @@ async function buildGatewayInstallEnvironment(params: {
381399
warn?: DaemonInstallWarnFn;
382400
serviceEnvironment: Record<string, string | undefined>;
383401
existingEnvironment?: Record<string, string | undefined>;
402+
existingEnvironmentValueSources?: Record<
403+
string,
404+
GatewayServiceEnvironmentValueSource | undefined
405+
>;
384406
platform: NodeJS.Platform;
385-
}): Promise<Record<string, string | undefined>> {
407+
}): Promise<{
408+
environment: Record<string, string | undefined>;
409+
environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
410+
}> {
386411
const durableEnvironment = collectDurableServiceEnvVars({
387412
env: params.env,
388413
config: params.config,
@@ -404,21 +429,47 @@ async function buildGatewayInstallEnvironment(params: {
404429
authStore: params.authStore,
405430
warn: params.warn,
406431
});
432+
const preservedExistingEnvironment = collectPreservedExistingServiceEnvVars(
433+
params.existingEnvironment,
434+
readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment),
435+
);
407436
const environment: Record<string, string | undefined> = {
408-
...collectPreservedExistingServiceEnvVars(
409-
params.existingEnvironment,
410-
readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment),
411-
),
437+
...preservedExistingEnvironment,
412438
...durableEnvironment,
413439
...configSecretRefEnvironment,
414440
...execSecretRefPassEnvEnvironment,
415441
...authProfileEnvironment,
416442
};
443+
const environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource | undefined> =
444+
{};
445+
for (const rawKey of Object.keys(preservedExistingEnvironment)) {
446+
const normalizedKey = normalizeEnvVarKey(rawKey, { portable: true })?.toUpperCase();
447+
environmentValueSources[rawKey] = normalizedKey
448+
? (readExistingEnvironmentValueSource({
449+
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
450+
normalizedKey,
451+
}) ?? "inline")
452+
: "inline";
453+
}
454+
for (const key of Object.keys({
455+
...durableEnvironment,
456+
...configSecretRefEnvironment,
457+
...execSecretRefPassEnvEnvironment,
458+
...authProfileEnvironment,
459+
})) {
460+
environmentValueSources[key] = "inline";
461+
}
417462
const managedServiceEnvKeys = formatManagedServiceEnvKeys(durableEnvironment, {
418463
omitKeys: Object.keys(params.serviceEnvironment),
419464
});
420465
writeManagedServiceEnvKeysToEnvironment(environment, managedServiceEnvKeys);
466+
if (environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS) {
467+
environmentValueSources.OPENCLAW_SERVICE_MANAGED_ENV_KEYS = "inline";
468+
}
421469
Object.assign(environment, params.serviceEnvironment);
470+
for (const key of Object.keys(params.serviceEnvironment)) {
471+
environmentValueSources[key] = "inline";
472+
}
422473
const mergedPath = mergeServicePath(
423474
params.serviceEnvironment.PATH,
424475
params.existingEnvironment?.PATH,
@@ -427,8 +478,14 @@ async function buildGatewayInstallEnvironment(params: {
427478
);
428479
if (mergedPath) {
429480
environment.PATH = mergedPath;
481+
environmentValueSources.PATH = "inline";
482+
}
483+
for (const key of Object.keys(environmentValueSources)) {
484+
if (!Object.hasOwn(environment, key)) {
485+
delete environmentValueSources[key];
486+
}
430487
}
431-
return environment;
488+
return { environment, environmentValueSources };
432489
}
433490

434491
export async function buildGatewayInstallPlan(params: {
@@ -444,6 +501,10 @@ export async function buildGatewayInstallPlan(params: {
444501
/** Full config to extract env vars from (env vars + inline env keys). */
445502
config?: OpenClawConfig;
446503
authStore?: AuthProfileStore;
504+
existingEnvironmentValueSources?: Record<
505+
string,
506+
GatewayServiceEnvironmentValueSource | undefined
507+
>;
447508
}): Promise<GatewayInstallPlan> {
448509
const platform = params.platform ?? process.platform;
449510
const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({
@@ -483,6 +544,17 @@ export async function buildGatewayInstallPlan(params: {
483544
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
484545
});
485546

547+
const { environment, environmentValueSources } = await buildGatewayInstallEnvironment({
548+
env: serviceInputEnv,
549+
config: params.config,
550+
authStore: params.authStore,
551+
warn: params.warn,
552+
serviceEnvironment,
553+
existingEnvironment: params.existingEnvironment,
554+
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
555+
platform,
556+
});
557+
486558
// Lowest to highest: preserved custom vars, durable config, auth env refs, generated service env.
487559
return {
488560
programArguments,
@@ -491,15 +563,8 @@ export async function buildGatewayInstallPlan(params: {
491563
platform,
492564
workingDirectory,
493565
}),
494-
environment: await buildGatewayInstallEnvironment({
495-
env: serviceInputEnv,
496-
config: params.config,
497-
authStore: params.authStore,
498-
warn: params.warn,
499-
serviceEnvironment,
500-
existingEnvironment: params.existingEnvironment,
501-
platform,
502-
}),
566+
environment,
567+
...(Object.keys(environmentValueSources).length > 0 ? { environmentValueSources } : {}),
503568
};
504569
}
505570

src/commands/doctor-gateway-daemon-flow.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,20 +237,22 @@ export async function maybeRepairGatewayDaemon(params: {
237237
return;
238238
}
239239
const port = resolveGatewayPort(params.cfg, process.env);
240-
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
241-
env: process.env,
242-
port,
243-
runtime: daemonRuntime,
244-
warn: (message, title) => note(message, title),
245-
config: params.cfg,
246-
});
240+
const { programArguments, workingDirectory, environment, environmentValueSources } =
241+
await buildGatewayInstallPlan({
242+
env: process.env,
243+
port,
244+
runtime: daemonRuntime,
245+
warn: (message, title) => note(message, title),
246+
config: params.cfg,
247+
});
247248
try {
248249
await service.install({
249250
env: process.env,
250251
stdout: process.stdout,
251252
programArguments,
252253
workingDirectory,
253254
environment,
255+
environmentValueSources,
254256
});
255257
} catch (err) {
256258
note(`Gateway service install failed: ${String(err)}`, "Gateway");

src/commands/doctor-gateway-services.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ async function buildExpectedGatewayServicePlan(params: {
109109
runtime: params.runtime,
110110
nodePath: params.nodePath,
111111
existingEnvironment: params.command.environment,
112+
existingEnvironmentValueSources: params.command.environmentValueSources,
112113
warn: (message, title) => note(message, title),
113114
config: params.cfg,
114115
});
@@ -593,6 +594,7 @@ export async function maybeRepairGatewayServiceConfig(
593594
programArguments: updatedPlan.programArguments,
594595
workingDirectory: updatedPlan.workingDirectory,
595596
environment: updatedPlan.environment,
597+
environmentValueSources: updatedPlan.environmentValueSources,
596598
});
597599
} catch (err) {
598600
runtime.error(`Gateway service update failed: ${String(err)}`);

src/commands/onboard-non-interactive/local/daemon-install.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,22 @@ export async function installGatewayDaemonNonInteractive(params: {
6262
runtime.exit(1);
6363
return { installed: false };
6464
}
65-
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
66-
env: process.env,
67-
port,
68-
runtime: daemonRuntimeRaw,
69-
warn: (message) => runtime.log(message),
70-
config: params.nextConfig,
71-
});
65+
const { programArguments, workingDirectory, environment, environmentValueSources } =
66+
await buildGatewayInstallPlan({
67+
env: process.env,
68+
port,
69+
runtime: daemonRuntimeRaw,
70+
warn: (message) => runtime.log(message),
71+
config: params.nextConfig,
72+
});
7273
try {
7374
await service.install({
7475
env: process.env,
7576
stdout: process.stdout,
7677
programArguments,
7778
workingDirectory,
7879
environment,
80+
environmentValueSources,
7981
});
8082
} catch (err) {
8183
runtime.error(`Gateway service install failed: ${String(err)}`);

src/daemon/service-managed-env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export function isEnvironmentFileOnlySource(
2424
return source === "file";
2525
}
2626

27+
export function hasEnvironmentFileSource(
28+
source: GatewayServiceEnvironmentValueSource | undefined,
29+
): boolean {
30+
return source === "file" || source === "inline-and-file";
31+
}
32+
2733
function parseManagedServiceEnvKeys(value: string | undefined): Set<string> {
2834
const keys = new Set<string>();
2935
for (const entry of value?.split(",") ?? []) {

src/daemon/service-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type GatewayServiceInstallArgs = {
88
programArguments: string[];
99
workingDirectory?: string;
1010
environment?: GatewayServiceEnv;
11+
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
1112
description?: string;
1213
};
1314

0 commit comments

Comments
 (0)