From 3ed3248d7bf50e51773ee5d63bb402cce4430bd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 09:44:43 +0100 Subject: [PATCH] fix(gateway): preserve config SecretRef env for services --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + src/commands/daemon-install-helpers.test.ts | 81 +++++++++++++++++++++ src/commands/daemon-install-helpers.ts | 66 +++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d2efdd7d5..272a4c83aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh. - Auto-reply/commands: stop bare `/reset` and `/new` after reset hooks acknowledge the command, so non-ACP channels no longer fall through into empty provider calls while `/reset ` and `/new ` still seed the next model turn. Fixes #73367. Thanks @hoyanhan and @wenxu007. - Auto-reply: preserve voice-note media from silent turns while continuing to suppress text and non-voice media, so `NO_REPLY` TTS replies still deliver the requested audio bubble. (#73406) Thanks @zqchris. - Channels/Mattermost: stop enqueueing regular inbound posts as system events, so Mattermost user messages reach the model only as user-role inbound-envelope content instead of also appearing as `System: Mattermost message...` directives. Fixes #71795. Thanks @juan-flores077. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 7d397c9ea6b..6f7f9cc793e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -105,6 +105,7 @@ openclaw gateway ``` If OpenClaw is already running as a background service, restart it via the OpenClaw Mac app or by stopping and restarting the `openclaw gateway run` process. + For managed service installs, run `openclaw gateway install` from a shell where `DISCORD_BOT_TOKEN` is present, or store the variable in `~/.openclaw/.env`, so the service can resolve the env SecretRef after restart. diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 7927f2e0c54..3eaaacef213 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -284,6 +284,87 @@ describe("buildGatewayInstallPlan", () => { ); }); + it("includes env SecretRef values from config into the service environment", async () => { + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + OPENCLAW_PORT: "3000", + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv({ + DISCORD_BOT_TOKEN: "discord-test-token", + }), + port: 3000, + runtime: "node", + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }, + }); + + expect(plan.environment.DISCORD_BOT_TOKEN).toBe("discord-test-token"); + expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); + }); + + it("does not embed gateway auth SecretRef values into the service environment", async () => { + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + OPENCLAW_PORT: "3000", + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv({ + OPENCLAW_GATEWAY_TOKEN: "gateway-test-token", + }), + port: 3000, + runtime: "node", + config: { + gateway: { + auth: { + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + }, + }); + + expect(plan.environment.OPENCLAW_GATEWAY_TOKEN).toBeUndefined(); + expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); + }); + + it("does not inline config env SecretRef values already backed by state-dir dotenv", async () => { + await writeStateDirDotEnv("DISCORD_BOT_TOKEN=discord-dotenv-token\n", { + stateDir: path.join(isolatedHome, ".openclaw"), + }); + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + OPENCLAW_PORT: "3000", + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv({ + DISCORD_BOT_TOKEN: "discord-shell-token", + }), + port: 3000, + runtime: "node", + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }, + }); + + expect(plan.environment.DISCORD_BOT_TOKEN).toBeUndefined(); + expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("DISCORD_BOT_TOKEN"); + }); + it("skips auth-profile store load when no auth-profile source exists", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 202406d1d3d..0a8311513d6 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -4,6 +4,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js"; import type { OpenClawConfig } from "../config/types.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayStateDir } from "../daemon/paths.js"; import { @@ -22,6 +23,7 @@ import { isDangerousHostEnvVarName, normalizeEnvVarKey, } from "../infra/host-env-security.js"; +import { discoverConfigSecretTargets } from "../secrets/target-registry.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, @@ -45,6 +47,11 @@ let daemonInstallAuthProfileStoreRuntimePromise: | Promise | undefined; +const NON_PERSISTED_CONFIG_SECRET_ENV_TARGET_IDS = new Set([ + "gateway.auth.password", + "gateway.auth.token", +]); + function loadDaemonInstallAuthProfileSourceRuntime() { daemonInstallAuthProfileSourceRuntimePromise ??= import("./daemon-install-auth-profiles-source.runtime.js"); @@ -109,6 +116,58 @@ async function collectAuthProfileServiceEnvVars(params: { return entries; } +function collectConfigSecretRefServiceEnvVars(params: { + env: Record; + config?: OpenClawConfig; + durableEnvironment: Record; + warn?: DaemonInstallWarnFn; +}): Record { + if (!params.config) { + return {}; + } + const entries: Record = {}; + for (const target of discoverConfigSecretTargets(params.config)) { + if (!target.entry.includeInPlan) { + continue; + } + if (NON_PERSISTED_CONFIG_SECRET_ENV_TARGET_IDS.has(target.entry.id)) { + continue; + } + const { ref } = resolveSecretInputRef({ + value: target.value, + refValue: target.refValue, + defaults: params.config.secrets?.defaults, + }); + if (!ref || ref.source !== "env") { + continue; + } + const key = normalizeEnvVarKey(ref.id, { portable: true }); + if (!key) { + params.warn?.( + `Config SecretRef env id "${ref.id}" is not portable and was not added to the service environment`, + "Config SecretRef", + ); + continue; + } + if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { + params.warn?.( + `Config SecretRef env 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, @@ -213,6 +272,12 @@ async function buildGatewayInstallEnvironment(params: { env: params.env, config: params.config, }); + const configSecretRefEnvironment = collectConfigSecretRefServiceEnvVars({ + env: params.env, + config: params.config, + durableEnvironment, + warn: params.warn, + }); const authProfileEnvironment = await collectAuthProfileServiceEnvVars({ env: params.env, authStore: params.authStore, @@ -224,6 +289,7 @@ async function buildGatewayInstallEnvironment(params: { readManagedServiceEnvKeysFromEnvironment(params.existingEnvironment), ), ...durableEnvironment, + ...configSecretRefEnvironment, ...authProfileEnvironment, }; const managedServiceEnvKeys = formatManagedServiceEnvKeys(durableEnvironment, {