fix(secretrefs): preserve exec resolver env

This commit is contained in:
Vincent Koc
2026-05-02 21:14:31 -07:00
parent d04a8976b1
commit b258c3fc65
5 changed files with 142 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.

View File

@@ -2,6 +2,9 @@ services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-openclaw:local}
build: .
env_file:
- path: .env
required: false
environment:
HOME: /home/node
TERM: xterm-256color
@@ -71,6 +74,9 @@ services:
openclaw-cli:
image: ${OPENCLAW_IMAGE:-openclaw:local}
network_mode: "service:openclaw-gateway"
env_file:
- path: .env
required: false
cap_drop:
- NET_RAW
- NET_ADMIN

View File

@@ -310,6 +310,74 @@ describe("buildGatewayInstallPlan", () => {
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined();
});
it("includes passEnv values for configured exec SecretRef providers", async () => {
mockNodeGatewayPlanFixture({
serviceEnvironment: {
OPENCLAW_PORT: "3000",
},
});
const plan = await buildGatewayInstallPlan({
env: isolatedPlanEnv({
OP_CONNECT_TOKEN: "op-connect-token",
}),
port: 3000,
runtime: "node",
config: {
secrets: {
providers: {
onepassword: {
source: "exec",
command: "/usr/bin/op",
args: ["read", "op://Private/Discord/password"],
passEnv: ["OP_CONNECT_TOKEN"],
allowInsecurePath: true,
},
},
},
channels: {
discord: {
token: { source: "exec", provider: "onepassword", id: "value" },
},
},
},
});
expect(plan.environment.OP_CONNECT_TOKEN).toBe("op-connect-token");
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined();
});
it("does not include passEnv values for unused exec SecretRef providers", async () => {
mockNodeGatewayPlanFixture({
serviceEnvironment: {
OPENCLAW_PORT: "3000",
},
});
const plan = await buildGatewayInstallPlan({
env: isolatedPlanEnv({
OP_CONNECT_TOKEN: "op-connect-token",
}),
port: 3000,
runtime: "node",
config: {
secrets: {
providers: {
onepassword: {
source: "exec",
command: "/usr/bin/op",
passEnv: ["OP_CONNECT_TOKEN"],
allowInsecurePath: true,
},
},
},
},
});
expect(plan.environment.OP_CONNECT_TOKEN).toBeUndefined();
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined();
});
it("does not embed gateway auth SecretRef values into the service environment", async () => {
mockNodeGatewayPlanFixture({
serviceEnvironment: {

View File

@@ -170,6 +170,61 @@ function collectConfigSecretRefServiceEnvVars(params: {
return entries;
}
function collectExecSecretRefPassEnvServiceEnvVars(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
durableEnvironment: Record<string, string | undefined>;
warn?: DaemonInstallWarnFn;
}): Record<string, string> {
if (!params.config) {
return {};
}
const entries: Record<string, string> = {};
for (const target of discoverConfigSecretTargets(params.config)) {
if (!target.entry.includeInPlan) {
continue;
}
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults: params.config.secrets?.defaults,
});
if (!ref || ref.source !== "exec") {
continue;
}
const provider = params.config.secrets?.providers?.[ref.provider];
if (!provider || provider.source !== "exec") {
continue;
}
for (const rawKey of provider.passEnv ?? []) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
params.warn?.(
`Exec SecretRef passEnv id "${rawKey}" is not portable and was not added to the service environment`,
"Config SecretRef",
);
continue;
}
if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) {
params.warn?.(
`Exec SecretRef passEnv ref "${key}" blocked by host-env security policy`,
"Config SecretRef",
);
continue;
}
if (Object.hasOwn(params.durableEnvironment, key)) {
continue;
}
const value = params.env[key]?.trim();
if (!value) {
continue;
}
entries[key] = value;
}
}
return entries;
}
function mergeServicePath(
nextPath: string | undefined,
existingPath: string | undefined,
@@ -338,6 +393,12 @@ async function buildGatewayInstallEnvironment(params: {
durableEnvironment,
warn: params.warn,
});
const execSecretRefPassEnvEnvironment = collectExecSecretRefPassEnvServiceEnvVars({
env: params.env,
config: params.config,
durableEnvironment,
warn: params.warn,
});
const authProfileEnvironment = await collectAuthProfileServiceEnvVars({
env: params.env,
authStore: params.authStore,
@@ -350,6 +411,7 @@ async function buildGatewayInstallEnvironment(params: {
),
...durableEnvironment,
...configSecretRefEnvironment,
...execSecretRefPassEnvEnvironment,
...authProfileEnvironment,
};
const managedServiceEnvKeys = formatManagedServiceEnvKeys(durableEnvironment, {

View File

@@ -610,6 +610,11 @@ describe("scripts/docker/setup.sh", () => {
);
});
it("keeps docker-compose optional env files aligned across services", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(compose.match(/env_file:\n {6}- path: \.env\n {8}required: false/g)).toHaveLength(2);
});
it("keeps docker-compose timezone env defaults aligned across services", async () => {
const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8");
expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2);