Skip to content

Commit e798655

Browse files
Darren2030obviyus
andauthored
fix(install): preserve SecretRef service env values (#96065)
* fix(install): manage config-secretref env refs via OPENCLAW_SERVICE_MANAGED_ENV_KEYS * fix(install): preserve config secret refs in launchd env * fix(install): preserve secretref env render sources --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent bc7f9a3 commit e798655

9 files changed

Lines changed: 247 additions & 75 deletions

src/cli/daemon-cli/start-repair.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,21 @@ export async function repairLoadedGatewayServiceForStart(params: {
5858
}
5959
}
6060

61-
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
62-
env: installEnv,
63-
port,
64-
runtime: DEFAULT_GATEWAY_DAEMON_RUNTIME,
65-
wrapperPath,
66-
existingEnvironment,
67-
config: cfg,
68-
warn: (message) => {
69-
warnings.push(message);
70-
if (!params.json) {
71-
defaultRuntime.log(`- ${message}`);
72-
}
73-
},
74-
});
61+
const { programArguments, workingDirectory, environment, environmentValueSources } =
62+
await buildGatewayInstallPlan({
63+
env: installEnv,
64+
port,
65+
runtime: DEFAULT_GATEWAY_DAEMON_RUNTIME,
66+
wrapperPath,
67+
existingEnvironment,
68+
config: cfg,
69+
warn: (message) => {
70+
warnings.push(message);
71+
if (!params.json) {
72+
defaultRuntime.log(`- ${message}`);
73+
}
74+
},
75+
});
7576

7677
await params.service.install({
7778
env: installEnv as GatewayServiceEnv,
@@ -80,6 +81,7 @@ export async function repairLoadedGatewayServiceForStart(params: {
8081
programArguments,
8182
workingDirectory,
8283
environment,
84+
environmentValueSources,
8385
});
8486

8587
let loaded;

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

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ describe("buildGatewayInstallPlan", () => {
411411
);
412412
});
413413

414-
it("includes env SecretRef values from config into the service environment", async () => {
414+
it("renders config env SecretRefs as file-backed managed values on Linux", async () => {
415415
mockNodeGatewayPlanFixture({
416416
serviceEnvironment: {
417417
OPENCLAW_PORT: "3000",
@@ -424,6 +424,7 @@ describe("buildGatewayInstallPlan", () => {
424424
}),
425425
port: 3000,
426426
runtime: "node",
427+
platform: "linux",
427428
config: {
428429
channels: {
429430
discord: {
@@ -434,7 +435,77 @@ describe("buildGatewayInstallPlan", () => {
434435
});
435436

436437
expect(plan.environment.DISCORD_BOT_TOKEN).toBe("discord-test-token");
437-
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined();
438+
expect(plan.environmentValueSources?.DISCORD_BOT_TOKEN).toBe("file");
439+
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("DISCORD_BOT_TOKEN");
440+
});
441+
442+
it("retains config env SecretRefs for Windows task scripts", async () => {
443+
mockNodeGatewayPlanFixture({
444+
serviceEnvironment: {
445+
OPENCLAW_PORT: "3000",
446+
},
447+
});
448+
449+
const plan = await buildGatewayInstallPlan({
450+
env: isolatedPlanEnv({
451+
DISCORD_BOT_TOKEN: "discord-test-token",
452+
}),
453+
port: 3000,
454+
runtime: "node",
455+
platform: "win32",
456+
config: {
457+
channels: {
458+
discord: {
459+
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
460+
},
461+
},
462+
},
463+
});
464+
465+
expect(plan.environment.DISCORD_BOT_TOKEN).toBe("discord-test-token");
466+
expect(plan.environmentValueSources?.DISCORD_BOT_TOKEN).toBe("inline");
467+
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("DISCORD_BOT_TOKEN");
468+
});
469+
470+
it("keeps config env SecretRefs managed when auth profiles reuse the key", async () => {
471+
mockNodeGatewayPlanFixture({
472+
serviceEnvironment: {
473+
OPENCLAW_PORT: "3000",
474+
},
475+
});
476+
477+
const plan = await buildGatewayInstallPlan({
478+
env: isolatedPlanEnv({
479+
OPENAI_API_KEY: "sk-openai-test",
480+
}),
481+
port: 3000,
482+
runtime: "node",
483+
platform: "linux",
484+
config: {
485+
models: {
486+
providers: {
487+
openai: {
488+
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
489+
models: [],
490+
},
491+
},
492+
},
493+
},
494+
authStore: {
495+
version: 1,
496+
profiles: {
497+
"openai:default": {
498+
type: "api_key",
499+
provider: "openai",
500+
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
501+
},
502+
},
503+
},
504+
});
505+
506+
expect(plan.environment.OPENAI_API_KEY).toBe("sk-openai-test");
507+
expect(plan.environmentValueSources?.OPENAI_API_KEY).toBe("file");
508+
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("OPENAI_API_KEY");
438509
});
439510

440511
it("includes passEnv values for configured exec SecretRef providers", async () => {
@@ -1180,6 +1251,49 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
11801251
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("MINIMAX_API_KEY");
11811252
});
11821253

1254+
it("retains config SecretRef env values for macOS LaunchAgent env files", async () => {
1255+
mockNodeGatewayPlanFixture({
1256+
serviceEnvironment: {
1257+
HOME: "/from-service",
1258+
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway",
1259+
OPENCLAW_PORT: "3000",
1260+
},
1261+
});
1262+
1263+
const plan = await buildGatewayInstallPlan({
1264+
env: {
1265+
HOME: tmpDir,
1266+
TELEGRAM_DEFAULT_BOTTOKEN: "telegram-shell-token",
1267+
},
1268+
port: 3000,
1269+
runtime: "node",
1270+
platform: "darwin",
1271+
config: {
1272+
env: {
1273+
vars: {
1274+
TELEGRAM_DEFAULT_BOTTOKEN: "your-real-telegram-default-token-here",
1275+
},
1276+
},
1277+
channels: {
1278+
telegram: {
1279+
accounts: {
1280+
default: {
1281+
botToken: {
1282+
source: "env",
1283+
provider: "default",
1284+
id: "TELEGRAM_DEFAULT_BOTTOKEN",
1285+
},
1286+
},
1287+
},
1288+
},
1289+
},
1290+
},
1291+
});
1292+
1293+
expect(plan.environment.TELEGRAM_DEFAULT_BOTTOKEN).toBe("telegram-shell-token");
1294+
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("TELEGRAM_DEFAULT_BOTTOKEN");
1295+
});
1296+
11831297
it("retains .env values when config env has an unresolved self reference", async () => {
11841298
await writeStateDirDotEnv("MINIMAX_API_KEY=minimax-dotenv-key\n", {
11851299
stateDir: path.join(tmpDir, ".openclaw"),

src/commands/daemon-install-helpers.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ type ExecSecretRefPassEnvSource = {
170170
function collectConfigSecretRefServiceEnvVars(params: {
171171
env: Record<string, string | undefined>;
172172
config?: OpenClawConfig;
173-
durableEnvironment: Record<string, string | undefined>;
173+
stateDirDotEnvEnvironment: Record<string, string | undefined>;
174174
warn?: DaemonInstallWarnFn;
175175
}): Record<string, string> {
176176
if (!params.config) {
@@ -207,7 +207,7 @@ function collectConfigSecretRefServiceEnvVars(params: {
207207
);
208208
continue;
209209
}
210-
if (Object.hasOwn(params.durableEnvironment, key)) {
210+
if (Object.hasOwn(params.stateDirDotEnvEnvironment, key)) {
211211
continue;
212212
}
213213
const value = params.env[key]?.trim();
@@ -496,6 +496,26 @@ function readExistingEnvironmentValueSource(params: {
496496
return undefined;
497497
}
498498

499+
function omitEnvironmentEntriesShadowedBy(
500+
entries: Record<string, string | undefined>,
501+
shadowEntries: Array<Record<string, string | undefined>>,
502+
): Record<string, string | undefined> {
503+
const shadowKeys = new Set(
504+
shadowEntries.flatMap((environment) =>
505+
Object.keys(environment).flatMap((key) => {
506+
const normalized = normalizeEnvVarKey(key, { portable: true })?.toUpperCase();
507+
return normalized ? [normalized] : [];
508+
}),
509+
),
510+
);
511+
return Object.fromEntries(
512+
Object.entries(entries).filter(([key]) => {
513+
const normalized = normalizeEnvVarKey(key, { portable: true })?.toUpperCase();
514+
return !normalized || !shadowKeys.has(normalized);
515+
}),
516+
);
517+
}
518+
499519
function resolveGatewayInstallWorkingDirectory(params: {
500520
env: Record<string, string | undefined>;
501521
platform: NodeJS.Platform;
@@ -534,7 +554,7 @@ async function buildGatewayInstallEnvironment(params: {
534554
const configSecretRefEnvironment = collectConfigSecretRefServiceEnvVars({
535555
env: params.env,
536556
config: params.config,
537-
durableEnvironment,
557+
stateDirDotEnvEnvironment,
538558
warn: params.warn,
539559
});
540560
const authStore = await resolveAuthProfileStoreForServiceEnv(params.authStore);
@@ -550,35 +570,48 @@ async function buildGatewayInstallEnvironment(params: {
550570
authStore,
551571
warn: params.warn,
552572
});
573+
const stateDirDotEnvRenderEnvironment = omitEnvironmentEntriesShadowedBy(
574+
stateDirDotEnvEnvironment,
575+
[
576+
configEnvironment,
577+
configSecretRefEnvironment,
578+
execSecretRefPassEnvEnvironment,
579+
authProfileEnvironment,
580+
],
581+
);
553582
const preservedExistingEnvironment = collectPreservedExistingServiceEnvVars(
554583
params.existingEnvironment,
555584
readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment),
556585
);
557586
const plan = createMutableServiceEnvPlan();
558587
addServiceEnvPlanEntries(plan, preservedExistingEnvironment, {
559-
source: "existing-preserved",
560588
valueSource: ({ normalizedKey }) =>
561589
readExistingEnvironmentValueSource({
562590
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
563591
normalizedKey,
564592
}) ?? "inline",
565593
});
566-
addServiceEnvPlanEntries(plan, stateDirDotEnvEnvironment, { source: "state-dotenv" });
567-
addServiceEnvPlanEntries(plan, configEnvironment, { source: "config-env" });
568-
addServiceEnvPlanEntries(plan, configSecretRefEnvironment, { source: "config-secretref-env" });
569-
addServiceEnvPlanEntries(plan, execSecretRefPassEnvEnvironment, { source: "exec-passenv" });
570-
addServiceEnvPlanEntries(plan, authProfileEnvironment, { source: "auth-profile-env" });
571-
const managedServiceEnvKeys = formatManagedServiceEnvKeys(durableEnvironment, {
572-
omitKeys: Object.keys(params.serviceEnvironment),
573-
});
594+
addServiceEnvPlanEntries(plan, stateDirDotEnvEnvironment, {});
595+
addServiceEnvPlanEntries(plan, configEnvironment, {});
596+
addServiceEnvPlanEntries(plan, configSecretRefEnvironment, {});
597+
addServiceEnvPlanEntries(plan, execSecretRefPassEnvEnvironment, {});
598+
addServiceEnvPlanEntries(plan, authProfileEnvironment, {});
599+
const managedServiceEnvKeys = formatManagedServiceEnvKeys(
600+
{
601+
...durableEnvironment,
602+
...configSecretRefEnvironment,
603+
},
604+
{ omitKeys: Object.keys(params.serviceEnvironment) },
605+
);
574606
applyManagedServiceEnvRenderPolicy({
575607
plan,
576608
managedServiceEnvKeys,
577609
serviceEnvironment: params.serviceEnvironment,
578610
platform: params.platform,
611+
stateDirDotEnvEnvironment: stateDirDotEnvRenderEnvironment,
612+
configSecretRefEnvironment,
579613
});
580614
addServiceEnvPlanEntries(plan, params.serviceEnvironment, {
581-
source: "service-generated",
582615
includeRawKeys: true,
583616
});
584617
const mergedPath = mergeServicePath(

src/daemon/service-env-plan.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,15 @@
22
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
33
import type { GatewayServiceEnvironmentValueSource } from "./service-types.js";
44

5-
/** Provenance labels for environment values rendered into managed services. */
6-
export type ServiceEnvSource =
7-
| "state-dotenv"
8-
| "config-env"
9-
| "config-secretref-env"
10-
| "exec-passenv"
11-
| "auth-profile-env"
12-
| "existing-preserved"
13-
| "service-generated";
14-
15-
export type ServiceEnvPlanEntry = {
16-
rawKey: string;
17-
normalizedKey: string;
18-
value: string;
19-
source: ServiceEnvSource;
20-
};
21-
225
export type MutableServiceEnvPlan = {
236
environment: Record<string, string | undefined>;
247
environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
25-
entriesByNormalizedKey: Map<string, ServiceEnvPlanEntry>;
268
};
279

2810
export function createMutableServiceEnvPlan(): MutableServiceEnvPlan {
2911
return {
3012
environment: {},
3113
environmentValueSources: {},
32-
entriesByNormalizedKey: new Map(),
3314
};
3415
}
3516

@@ -41,7 +22,6 @@ export function addServiceEnvPlanEntries(
4122
plan: MutableServiceEnvPlan,
4223
entries: Record<string, string | undefined>,
4324
options: {
44-
source: ServiceEnvSource;
4525
includeRawKeys?: boolean;
4626
valueSource?:
4727
| GatewayServiceEnvironmentValueSource
@@ -72,14 +52,6 @@ export function addServiceEnvPlanEntries(
7252
? options.valueSource({ rawKey, normalizedKey })
7353
: options.valueSource;
7454
plan.environmentValueSources[rawKey] = valueSource ?? "inline";
75-
// Last writer wins per normalized key so later, higher-priority env sources
76-
// can decide render policy without scanning duplicate casing.
77-
plan.entriesByNormalizedKey.set(normalizedKey, {
78-
rawKey,
79-
normalizedKey,
80-
value,
81-
source: options.source,
82-
});
8355
}
8456
}
8557

0 commit comments

Comments
 (0)