Files
openclaw/src/config/state-dir-dotenv.ts
Alix-007 909c24e3b7 fix(config): skip state-dir dotenv values that are unresolved shell references (#88288)
* fix(config): skip state-dir dotenv values that are unresolved shell references

readStateDirDotEnvVarsFromStateDir accepted any non-empty value from the
state-dir .env file and passed it into the managed service env. When a value
contains an unresolved shell variable reference such as "${SUPERMEMORY_KEY}"
or "$MY_VAR", dotenv preserves the literal string. The value then reaches
the LaunchAgent/systemd wrapper as a single-quoted literal, so the credential
is never resolved.

Add containsUnresolvedShellReference() and skip any value matching
$IDENTIFIER, ${...}, or $(...) in parseStateDirDotEnvContent(). Real credential
values (e.g. "sm_abc123") are unaffected.

Fixes #88274

* fix(config): narrow shell-reference detector to whole-value patterns only

The previous /$[\w{(]/ regex matched any value containing $ followed by
a word character, which would incorrectly drop real credentials that merely
contain a dollar sign (e.g. a password like abc$2!xyz).

Replace with isUnresolvedShellReference() that only matches values whose
ENTIRE content is a recognised reference form:
  - $VAR_NAME (simple reference)
  - ${VAR_NAME} (brace-form reference)
  - $(command) (command substitution)

Add a regression test that verifies dollar-bearing real secrets are kept.

* fix(config): use letter/underscore-anchored pattern to avoid matching dollar-numbers

$100, $2, etc. are NOT shell variable references — shell variable names must
begin with a letter or underscore. The previous /^$[\w_]/ would match them.

Change to /^$[A-Za-z_]\w*$/ so only genuine named-variable references like
$MY_VAR are rejected. Dollar-number sequences are now preserved.

* fix(daemon): drop stale systemd env-file refs for skipped state-dir dotenv keys

When a state-dir .env value is an unresolved shell reference ($VAR/${VAR}/$(cmd))
the parser skips it from the managed environment. A prior install could have
written that literal reference into gateway.systemd.env; because the skipped key
no longer appeared in the incoming env or the managed-key removal sets, the stale
literal survived re-stage and could override fresh inline Environment= values.

Surface the skipped shell-reference keys from the state-dir dotenv parser and add
them to the systemd env-file managed-key removal set so re-staging strips the
obsolete literal while preserving operator-only secrets that were never managed
via state-dir .env. launchd regenerates its env file wholesale, so it is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(config): skip quoted shell parameter dotenv refs

* fix(config): preserve lowercase dollar-prefixed dotenv literals

* fix(daemon): clear stale unresolved systemd env refs

* fix(daemon): avoid re-staging unresolved file env refs

* fix(daemon): drop unresolved file env refs inline

* fix(daemon): drop inline-and-file unresolved env refs

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:01:33 -04:00

151 lines
5.2 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeEnvVarKey,
} from "../infra/host-env-security.js";
import { collectConfigServiceEnvVars } from "./config-env-vars.js";
import { resolveStateDir } from "./paths.js";
import type { OpenClawConfig } from "./types.js";
function isBlockedServiceEnvVar(key: string): boolean {
return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key);
}
function unwrapMatchingLiteralQuotes(value: string): string {
if (value.length < 2) {
return value;
}
const first = value[0];
const last = value.at(-1);
if ((first === `"` || first === `'`) && first === last) {
return value.slice(1, -1);
}
return value;
}
export function isUnresolvedShellReference(value: string): boolean {
const candidate = unwrapMatchingLiteralQuotes(value.trim());
// Match only values whose entire content is a shell variable reference:
// $VAR_NAME (simple reference, OpenClaw env-var style)
// ${VAR_NAME} (brace-form reference)
// $(command) (command substitution)
// A real credential that merely contains a $ (e.g. "abc$2!", "$100") is NOT matched.
return (
/^\$[A-Z_][A-Z0-9_]*$/.test(candidate) ||
/^\$\{[A-Z_][A-Z0-9_]*[^}]*\}$/.test(candidate) ||
/^\$\([^)]*\)$/.test(candidate)
);
}
type ParsedStateDirDotEnv = {
/** Keys whose values are persisted to the managed service environment. */
entries: Record<string, string>;
/**
* Keys that were dropped because their entire value was an unresolved shell
* reference ($VAR, ${VAR}, or $(cmd)). These are still OpenClaw-managed keys:
* a previously generated env file may carry a stale literal reference for them
* that must be removed on re-stage rather than preserved as an operator secret.
*/
skippedShellReferenceKeys: string[];
};
function parseStateDirDotEnvContent(content: string): ParsedStateDirDotEnv {
const parsed = dotenv.parse(content);
const entries: Record<string, string> = {};
const skippedShellReferenceKeys: string[] = [];
for (const [rawKey, value] of Object.entries(parsed)) {
if (!value?.trim()) {
continue;
}
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
continue;
}
if (isBlockedServiceEnvVar(key)) {
continue;
}
// Skip values whose entire content is an unresolved shell variable reference
// ($VAR, ${VAR}, or $(cmd)). dotenv does not expand them, so persisting them
// into a single-quoted LaunchAgent/systemd env file would store the literal
// reference string rather than the intended credential value.
// Values that merely contain $ (e.g. a password like "abc$2!") are kept.
if (isUnresolvedShellReference(value)) {
skippedShellReferenceKeys.push(key);
continue;
}
entries[key] = value;
}
return { entries, skippedShellReferenceKeys };
}
export function readStateDirDotEnvVarsFromStateDir(stateDir: string): Record<string, string> {
return readStateDirDotEnvFromStateDir(stateDir).entries;
}
/**
* Read and parse the state-dir `.env`, returning both the persisted entries and
* the keys that were skipped because they held unresolved shell references. The
* skipped keys are surfaced so generated service env files can remove stale
* literal references for keys OpenClaw previously managed.
*/
export function readStateDirDotEnvFromStateDir(stateDir: string): ParsedStateDirDotEnv {
const dotEnvPath = path.join(stateDir, ".env");
try {
return parseStateDirDotEnvContent(fs.readFileSync(dotEnvPath, "utf8"));
} catch {
return { entries: {}, skippedShellReferenceKeys: [] };
}
}
/**
* Read and parse `~/.openclaw/.env` (or `$OPENCLAW_STATE_DIR/.env`), returning
* a filtered record of key-value pairs suitable for a managed service
* environment source.
*/
export function readStateDirDotEnvVars(
env: Record<string, string | undefined>,
): Record<string, string> {
const stateDir = resolveStateDir(env as NodeJS.ProcessEnv);
return readStateDirDotEnvVarsFromStateDir(stateDir);
}
export type DurableServiceEnvVarSources = {
stateDirDotEnvEnvironment: Record<string, string>;
configEnvironment: Record<string, string>;
durableEnvironment: Record<string, string>;
};
export function collectDurableServiceEnvVarSources(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
}): DurableServiceEnvVarSources {
const stateDirDotEnvEnvironment = readStateDirDotEnvVars(params.env);
const configEnvironment = collectConfigServiceEnvVars(params.config);
return {
stateDirDotEnvEnvironment,
configEnvironment,
durableEnvironment: {
...stateDirDotEnvEnvironment,
...configEnvironment,
},
};
}
/**
* Durable service env sources survive beyond the invoking shell and are safe to
* persist into owner-only gateway service environment sources.
*
* Precedence:
* 1. state-dir `.env` file vars
* 2. config service env vars
*/
export function collectDurableServiceEnvVars(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
}): Record<string, string> {
return collectDurableServiceEnvVarSources(params).durableEnvironment;
}