Files
openclaw/src/config/state-dir-dotenv.test.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

94 lines
4.0 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readStateDirDotEnvVarsFromStateDir } from "./state-dir-dotenv.js";
describe("readStateDirDotEnvVarsFromStateDir", () => {
async function withDotEnv<T>(content: string, run: (dir: string) => T | Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
await fs.writeFile(path.join(dir, ".env"), content, "utf8");
try {
return await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
it("returns real credential values from the state-dir dotenv", async () => {
await withDotEnv("SUPERMEMORY_API_KEY=sm_real_credential_value\n", async (dir) => {
const result = readStateDirDotEnvVarsFromStateDir(dir);
expect(result["SUPERMEMORY_API_KEY"]).toBe("sm_real_credential_value");
});
});
it("skips values that are unresolved shell variable references", async () => {
const content = [
'SUPERMEMORY_OPENCLAW_API_KEY="${SUPERMEMORY_OPENCLAW_KEY}"',
"QUOTED_SUPERMEMORY_OPENCLAW_API_KEY='\"$SUPERMEMORY_OPENCLAW_KEY\"'",
"QUOTED_CURLY_KEY=\"'${ANOTHER_VAR}'\"",
"BRACE_DEFAULT_KEY=${ANOTHER_VAR:-fallback}",
"QUOTED_BRACE_DEFAULT_KEY='\"${ANOTHER_VAR:-fallback}\"'",
'BRACE_TRIM_KEY="${ANOTHER_VAR#prefix}"',
"BRACE_REPLACE_KEY=${ANOTHER_VAR/pattern/replacement}",
"BRACE_CASE_KEY=${ANOTHER_VAR^^}",
'COMMAND_KEY="$(hostname)"',
"OTHER_KEY=$SOME_SHELL_VAR",
"CURLY_KEY=${ANOTHER_VAR}",
"REAL_KEY=actual_value_here",
].join("\n");
await withDotEnv(content, async (dir) => {
const result = readStateDirDotEnvVarsFromStateDir(dir);
expect(Object.keys(result)).not.toContain("SUPERMEMORY_OPENCLAW_API_KEY");
expect(Object.keys(result)).not.toContain("QUOTED_SUPERMEMORY_OPENCLAW_API_KEY");
expect(Object.keys(result)).not.toContain("QUOTED_CURLY_KEY");
expect(Object.keys(result)).not.toContain("BRACE_DEFAULT_KEY");
expect(Object.keys(result)).not.toContain("QUOTED_BRACE_DEFAULT_KEY");
expect(Object.keys(result)).not.toContain("BRACE_TRIM_KEY");
expect(Object.keys(result)).not.toContain("BRACE_REPLACE_KEY");
expect(Object.keys(result)).not.toContain("BRACE_CASE_KEY");
expect(Object.keys(result)).not.toContain("COMMAND_KEY");
expect(Object.keys(result)).not.toContain("OTHER_KEY");
expect(Object.keys(result)).not.toContain("CURLY_KEY");
expect(result["REAL_KEY"]).toBe("actual_value_here");
});
});
it("preserves credential values that merely contain a dollar sign", async () => {
const content = [
"PASSWORD=abc$2!xyz",
"TOKEN=tok_$prod_v2",
"PRICE=\\$100",
"QUOTED_PASSWORD='\"abc$2!xyz\"'",
"QUOTED_PRICE='\"$100\"'",
"LEADING_DOLLAR_PASSWORD=$ecret123",
"LEADING_DOLLAR_TOKEN=$token_1",
"LOWERCASE_BRACE=${lowercase_literal}",
"PURE_REF=$SOME_VAR",
].join("\n");
await withDotEnv(content, async (dir) => {
const result = readStateDirDotEnvVarsFromStateDir(dir);
expect(result["PASSWORD"]).toBe("abc$2!xyz");
expect(result["TOKEN"]).toBe("tok_$prod_v2");
expect(result["PRICE"]).toBe("\\$100");
expect(result["QUOTED_PASSWORD"]).toBe('"abc$2!xyz"');
expect(result["QUOTED_PRICE"]).toBe('"$100"');
expect(result["LEADING_DOLLAR_PASSWORD"]).toBe("$ecret123");
expect(result["LEADING_DOLLAR_TOKEN"]).toBe("$token_1");
expect(result["LOWERCASE_BRACE"]).toBe("${lowercase_literal}");
expect(Object.keys(result)).not.toContain("PURE_REF");
});
});
it("returns empty object when .env is missing", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-missing-"));
try {
expect(readStateDirDotEnvVarsFromStateDir(dir)).toEqual({});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});