Skip to content

Commit af0cf4e

Browse files
committed
fix(install): preserve secretref env render sources
1 parent 01a607b commit af0cf4e

9 files changed

Lines changed: 194 additions & 69 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: 73 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: {
@@ -433,10 +434,80 @@ describe("buildGatewayInstallPlan", () => {
433434
},
434435
});
435436

436-
expect(plan.environment.DISCORD_BOT_TOKEN).toBeUndefined();
437+
expect(plan.environment.DISCORD_BOT_TOKEN).toBe("discord-test-token");
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");
437467
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("DISCORD_BOT_TOKEN");
438468
});
439469

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");
509+
});
510+
440511
it("includes passEnv values for configured exec SecretRef providers", async () => {
441512
mockNodeGatewayPlanFixture({
442513
serviceEnvironment: {

src/commands/daemon-install-helpers.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
@@ -550,24 +570,32 @@ 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" });
594+
addServiceEnvPlanEntries(plan, stateDirDotEnvEnvironment, {});
595+
addServiceEnvPlanEntries(plan, configEnvironment, {});
596+
addServiceEnvPlanEntries(plan, configSecretRefEnvironment, {});
597+
addServiceEnvPlanEntries(plan, execSecretRefPassEnvEnvironment, {});
598+
addServiceEnvPlanEntries(plan, authProfileEnvironment, {});
571599
const managedServiceEnvKeys = formatManagedServiceEnvKeys(
572600
{
573601
...durableEnvironment,
@@ -580,9 +608,10 @@ async function buildGatewayInstallEnvironment(params: {
580608
managedServiceEnvKeys,
581609
serviceEnvironment: params.serviceEnvironment,
582610
platform: params.platform,
611+
stateDirDotEnvEnvironment: stateDirDotEnvRenderEnvironment,
612+
configSecretRefEnvironment,
583613
});
584614
addServiceEnvPlanEntries(plan, params.serviceEnvironment, {
585-
source: "service-generated",
586615
includeRawKeys: true,
587616
});
588617
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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/** Applies platform render policy for managed daemon service environment values. */
2-
import type { MutableServiceEnvPlan } from "./service-env-plan.js";
2+
import {
3+
normalizeServiceEnvPlanKey,
4+
type MutableServiceEnvPlan,
5+
} from "./service-env-plan.js";
36
import {
47
readManagedServiceEnvKeysFromEnvironment,
58
writeManagedServiceEnvKeysToEnvironment,
69
} from "./service-managed-env.js";
10+
import type { GatewayServiceEnvironmentValueSource } from "./service-types.js";
711

812
function isLaunchAgentServiceEnvironment(params: {
913
platform: NodeJS.Platform;
@@ -15,33 +19,56 @@ function isLaunchAgentServiceEnvironment(params: {
1519
);
1620
}
1721

22+
function addManagedServiceEnvEntries(params: {
23+
plan: MutableServiceEnvPlan;
24+
entries: Record<string, string | undefined>;
25+
managedKeys: ReadonlySet<string>;
26+
valueSource: GatewayServiceEnvironmentValueSource;
27+
}): void {
28+
for (const [rawKey, value] of Object.entries(params.entries)) {
29+
if (typeof value !== "string" || !value.trim()) {
30+
continue;
31+
}
32+
const key = normalizeServiceEnvPlanKey(rawKey);
33+
if (!key || !params.managedKeys.has(key)) {
34+
continue;
35+
}
36+
params.plan.environment[rawKey] = value;
37+
params.plan.environmentValueSources[rawKey] = params.valueSource;
38+
}
39+
}
40+
1841
export function applyManagedServiceEnvRenderPolicy(params: {
1942
plan: MutableServiceEnvPlan;
2043
managedServiceEnvKeys: string | undefined;
2144
serviceEnvironment: Record<string, string | undefined>;
2245
platform: NodeJS.Platform;
46+
stateDirDotEnvEnvironment: Record<string, string | undefined>;
47+
configSecretRefEnvironment: Record<string, string | undefined>;
2348
}): void {
49+
const launchAgent = isLaunchAgentServiceEnvironment(params);
2450
writeManagedServiceEnvKeysToEnvironment(params.plan.environment, params.managedServiceEnvKeys);
2551
if (params.plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS) {
2652
params.plan.environmentValueSources.OPENCLAW_SERVICE_MANAGED_ENV_KEYS = "inline";
2753
}
28-
if (!isLaunchAgentServiceEnvironment(params)) {
29-
return;
30-
}
3154
const managedKeys = readManagedServiceEnvKeysFromEnvironment({
3255
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: params.managedServiceEnvKeys,
3356
});
3457
if (managedKeys.size === 0) {
3558
return;
3659
}
37-
for (const entry of params.plan.entriesByNormalizedKey.values()) {
38-
if (
39-
!managedKeys.has(entry.normalizedKey) ||
40-
(entry.source !== "state-dotenv" && entry.source !== "config-secretref-env")
41-
) {
42-
continue;
43-
}
44-
params.plan.environment[entry.rawKey] = entry.value;
45-
params.plan.environmentValueSources[entry.rawKey] = "inline";
60+
if (launchAgent) {
61+
addManagedServiceEnvEntries({
62+
plan: params.plan,
63+
entries: params.stateDirDotEnvEnvironment,
64+
managedKeys,
65+
valueSource: "inline",
66+
});
4667
}
68+
addManagedServiceEnvEntries({
69+
plan: params.plan,
70+
entries: params.configSecretRefEnvironment,
71+
managedKeys,
72+
valueSource: params.platform === "linux" ? "file" : "inline",
73+
});
4774
}

src/daemon/systemd.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,10 +1310,12 @@ describe("stageSystemdService", () => {
13101310
environment: {
13111311
OPENCLAW_GATEWAY_TOKEN: "file-backed-token",
13121312
OPENCLAW_GATEWAY_PORT: "18789",
1313+
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENCLAW_GATEWAY_TOKEN",
13131314
OPENCLAW_SERVICE_KIND: "node",
13141315
},
13151316
environmentValueSources: {
13161317
OPENCLAW_GATEWAY_TOKEN: "file",
1318+
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
13171319
},
13181320
});
13191321

src/daemon/systemd.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ function collectSystemdInlineManagedKeys(params: {
274274
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource | undefined>;
275275
}): Set<string> {
276276
const keys = readManagedServiceEnvKeysFromEnvironment(params.environment);
277+
for (const key of collectSystemdFileManagedKeys({
278+
environmentValueSources: params.environmentValueSources,
279+
})) {
280+
keys.delete(key);
281+
}
277282
for (const [rawKey, value] of Object.entries(params.environment ?? {})) {
278283
if (typeof value !== "string" || !value.trim()) {
279284
continue;

0 commit comments

Comments
 (0)