diff --git a/CHANGELOG.md b/CHANGELOG.md index a3049314900..8879cd83b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 6ba177f90d5..8f8193d1f79 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 3a27c91292e..be451109883 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -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: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 256cde6e36e..bb883714d24 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -170,6 +170,61 @@ function collectConfigSecretRefServiceEnvVars(params: { return entries; } +function collectExecSecretRefPassEnvServiceEnvVars(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; + } + 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, { diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index c24a87960dd..f5ff1305543 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -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);