mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 22:20:42 +00:00
* Harden update environment path resolution * docs(changelog): credit windows update env path hardening Adds the user-facing Unreleased Fixes entry for the workspace LOCALAPPDATA blocklist + portable Git path-prepend hardening change in this PR.
877 lines
32 KiB
TypeScript
877 lines
32 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { loadCliDotEnv } from "../cli/dotenv.js";
|
|
import { loadDotEnv, loadWorkspaceDotEnvFile } from "./dotenv.js";
|
|
|
|
const loggerMocks = vi.hoisted(() => ({
|
|
warn: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../logging/subsystem.js", () => ({
|
|
createSubsystemLogger: vi.fn(() => loggerMocks),
|
|
}));
|
|
|
|
const CREDENTIAL_AND_GATEWAY_ENV_KEYS = [
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_API_KEY_SECONDARY",
|
|
"ANTHROPIC_OAUTH_TOKEN",
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_API_KEYS",
|
|
"OPENAI_API_KEY_SECONDARY",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEY",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEYS",
|
|
"OPENCLAW_LIVE_GEMINI_KEY",
|
|
"OPENCLAW_LIVE_OPENAI_KEY",
|
|
"OPENCLAW_GATEWAY_TOKEN",
|
|
"OPENCLAW_GATEWAY_PASSWORD",
|
|
"OPENCLAW_GATEWAY_SECRET",
|
|
] as const;
|
|
|
|
const BUNDLED_TRUST_ROOT_ENV_LINES = [
|
|
"OPENCLAW_BROWSER_CONTROL_MODULE=data:text/javascript,boom",
|
|
"OPENCLAW_BUNDLED_HOOKS_DIR=./attacker-hooks",
|
|
"OPENCLAW_BUNDLED_PLUGINS_DIR=./attacker-plugins",
|
|
"OPENCLAW_BUNDLED_SKILLS_DIR=./attacker-skills",
|
|
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1",
|
|
] as const;
|
|
|
|
const BUNDLED_TRUST_ROOT_ENV_KEYS = BUNDLED_TRUST_ROOT_ENV_LINES.map(
|
|
(line) => line.split("=")[0] ?? "",
|
|
);
|
|
|
|
const WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS = [
|
|
"ComSpec",
|
|
"COMSPEC",
|
|
"LocalAppData",
|
|
"LOCALAPPDATA",
|
|
"ProgramFiles",
|
|
"PROGRAMFILES",
|
|
"ProgramW6432",
|
|
"PROGRAMW6432",
|
|
"SystemRoot",
|
|
"SYSTEMROOT",
|
|
"windir",
|
|
"WINDIR",
|
|
] as const;
|
|
|
|
async function writeEnvFile(filePath: string, contents: string) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, contents, "utf8");
|
|
}
|
|
|
|
function clearEnv(keys: readonly string[]) {
|
|
for (const key of keys) {
|
|
delete process.env[key];
|
|
}
|
|
}
|
|
|
|
function expectEnvUndefined(keys: readonly string[]) {
|
|
for (const key of keys) {
|
|
expect(process.env[key]).toBeUndefined();
|
|
}
|
|
}
|
|
|
|
async function withIsolatedEnvAndCwd(run: () => Promise<void>) {
|
|
const prevEnv = { ...process.env };
|
|
try {
|
|
await run();
|
|
} finally {
|
|
vi.restoreAllMocks();
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in prevEnv)) {
|
|
delete process.env[key];
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(prevEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type DotEnvFixture = {
|
|
base: string;
|
|
cwdDir: string;
|
|
stateDir: string;
|
|
};
|
|
|
|
async function withDotEnvFixture(run: (fixture: DotEnvFixture) => Promise<void>) {
|
|
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
|
|
const cwdDir = path.join(base, "cwd");
|
|
const stateDir = path.join(base, "state");
|
|
process.env.OPENCLAW_STATE_DIR = stateDir;
|
|
await fs.mkdir(cwdDir, { recursive: true });
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
await run({ base, cwdDir, stateDir });
|
|
}
|
|
|
|
describe("loadDotEnv", () => {
|
|
it("loads ~/.openclaw/.env as fallback without overriding CWD .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
|
|
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.FOO;
|
|
delete process.env.BAR;
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-cwd");
|
|
expect(process.env.BAR).toBe("1");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not override an already-set env var from the shell", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
process.env.FOO = "from-shell";
|
|
|
|
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
|
|
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-shell");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("loads fallback state .env when CWD .env is missing", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.FOO;
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-global");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("loads the Ubuntu gateway.env compatibility fallback after ~/.openclaw/.env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir }) => {
|
|
process.env.HOME = base;
|
|
const defaultStateDir = path.join(base, ".openclaw");
|
|
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
|
|
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
|
|
await writeEnvFile(
|
|
path.join(base, ".config", "openclaw", "gateway.env"),
|
|
["FOO=from-gateway", "BAR=from-gateway"].join("\n"),
|
|
);
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.FOO;
|
|
delete process.env.BAR;
|
|
loggerMocks.warn.mockClear();
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-global");
|
|
expect(process.env.BAR).toBe("from-gateway");
|
|
expect(loggerMocks.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("Conflicting values in"),
|
|
expect.objectContaining({
|
|
ignoredPath: expect.stringContaining("gateway.env"),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not warn about dotenv conflicts when the key is already set", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir, stateDir }) => {
|
|
process.env.HOME = base;
|
|
process.env.FOO = "from-shell";
|
|
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
|
|
await writeEnvFile(
|
|
path.join(base, ".config", "openclaw", "gateway.env"),
|
|
"FOO=from-gateway\n",
|
|
);
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
loggerMocks.warn.mockClear();
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-shell");
|
|
expect(loggerMocks.warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks dangerous and workspace-control vars from CWD .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"SAFE_KEY=from-cwd",
|
|
"NODE_OPTIONS=--require ./evil.js",
|
|
"OPENCLAW_STATE_DIR=./evil-state",
|
|
"OPENCLAW_CONFIG_PATH=./evil-config.json",
|
|
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
|
|
"CLOUDSDK_PYTHON=./attacker-python",
|
|
"EXAMPLE_API_HOST=https://evil-api.example.com",
|
|
"MINIMAX_API_HOST=https://evil.example.com",
|
|
"HTTP_PROXY=http://evil-proxy:8080",
|
|
"HOMEBREW_BREW_FILE=./evil-brew/bin/brew",
|
|
"HOMEBREW_PREFIX=./evil-brew",
|
|
"SystemRoot=.\\fake-root",
|
|
"UV_PYTHON=./attacker-python",
|
|
"uv_python=./attacker-python-lower",
|
|
"WINDIR=.\\fake-windir",
|
|
].join("\n"),
|
|
);
|
|
await writeEnvFile(path.join(stateDir, ".env"), "BAR=from-global\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.SAFE_KEY;
|
|
delete process.env.NODE_OPTIONS;
|
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
delete process.env.ANTHROPIC_BASE_URL;
|
|
delete process.env.CLOUDSDK_PYTHON;
|
|
delete process.env.EXAMPLE_API_HOST;
|
|
delete process.env.MINIMAX_API_HOST;
|
|
delete process.env.HTTP_PROXY;
|
|
delete process.env.HOMEBREW_BREW_FILE;
|
|
delete process.env.HOMEBREW_PREFIX;
|
|
delete process.env.SystemRoot;
|
|
delete process.env.UV_PYTHON;
|
|
delete process.env.uv_python;
|
|
delete process.env.WINDIR;
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.SAFE_KEY).toBe("from-cwd");
|
|
expect(process.env.BAR).toBe("from-global");
|
|
expect(process.env.NODE_OPTIONS).toBeUndefined();
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
|
|
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
|
|
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
|
expect(process.env.CLOUDSDK_PYTHON).toBeUndefined();
|
|
expect(process.env.EXAMPLE_API_HOST).toBeUndefined();
|
|
expect(process.env.MINIMAX_API_HOST).toBeUndefined();
|
|
expect(process.env.HTTP_PROXY).toBeUndefined();
|
|
expect(process.env.HOMEBREW_BREW_FILE).toBeUndefined();
|
|
expect(process.env.HOMEBREW_PREFIX).toBeUndefined();
|
|
expect(process.env.SystemRoot).toBeUndefined();
|
|
expect(process.env.UV_PYTHON).toBeUndefined();
|
|
expect(process.env.uv_python).toBeUndefined();
|
|
expect(process.env.WINDIR).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks credential and gateway auth vars from CWD .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"ANTHROPIC_API_KEY=sk-ant-attacker-key",
|
|
"ANTHROPIC_API_KEY_SECONDARY=sk-ant-secondary",
|
|
"ANTHROPIC_OAUTH_TOKEN=attacker-oauth",
|
|
"OPENAI_API_KEY=sk-openai-attacker-key",
|
|
"OPENAI_API_KEYS=sk-openai-a,sk-openai-b",
|
|
"OPENAI_API_KEY_SECONDARY=sk-openai-secondary",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-live",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEYS=sk-ant-live-a,sk-ant-live-b",
|
|
"OPENCLAW_LIVE_GEMINI_KEY=sk-gemini-live",
|
|
"OPENCLAW_LIVE_OPENAI_KEY=sk-openai-live",
|
|
"OPENCLAW_GATEWAY_TOKEN=attacker-token",
|
|
"OPENCLAW_GATEWAY_PASSWORD=attacker-password",
|
|
"OPENCLAW_GATEWAY_SECRET=attacker-secret",
|
|
].join("\n"),
|
|
);
|
|
|
|
clearEnv(CREDENTIAL_AND_GATEWAY_ENV_KEYS);
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expectEnvUndefined(CREDENTIAL_AND_GATEWAY_ENV_KEYS);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks state-directory controls from workspace .env even when unset in process env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"OPENCLAW_STATE_DIR=./evil-state",
|
|
"STATE_DIRECTORY=./evil-systemd-state",
|
|
"OPENCLAW_CONFIG_PATH=./evil-config.json",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
delete process.env.STATE_DIRECTORY;
|
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBeUndefined();
|
|
expect(process.env.STATE_DIRECTORY).toBeUndefined();
|
|
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks Windows shell trust-root vars from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"ComSpec=.\\evil-comspec",
|
|
"COMSPEC=.\\evil-comspec-upper",
|
|
"LocalAppData=.\\evil-local-app-data",
|
|
"LOCALAPPDATA=.\\evil-local-app-data-upper",
|
|
"ProgramFiles=.\\evil-pfiles",
|
|
"PROGRAMFILES=.\\evil-pfiles-upper",
|
|
"ProgramW6432=.\\evil-pw6432",
|
|
"PROGRAMW6432=.\\evil-pw6432-upper",
|
|
"SystemRoot=.\\fake-root",
|
|
"SYSTEMROOT=.\\fake-root-upper",
|
|
"windir=.\\fake-windir",
|
|
"WINDIR=.\\fake-windir-upper",
|
|
].join("\n"),
|
|
);
|
|
|
|
clearEnv(WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS);
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expectEnvUndefined(WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks path-override vars (OPENCLAW_AGENT_DIR, OPENCLAW_BUNDLED_PLUGINS_DIR, PI_CODING_AGENT_DIR, OPENCLAW_OAUTH_DIR) from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir }) => {
|
|
const bundledPluginsDir = path.join(base, "attacker-bundled");
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"OPENCLAW_AGENT_DIR=./evil-agent",
|
|
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
|
|
"PI_CODING_AGENT_DIR=./evil-coding",
|
|
"OPENCLAW_OAUTH_DIR=./evil-oauth",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.OPENCLAW_AGENT_DIR;
|
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
delete process.env.OPENCLAW_OAUTH_DIR;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_AGENT_DIR).toBeUndefined();
|
|
expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
|
|
expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined();
|
|
expect(process.env.OPENCLAW_OAUTH_DIR).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks OPENCLAW_TEST_TAILSCALE_BINARY from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
"OPENCLAW_TEST_TAILSCALE_BINARY=/tmp/attacker-tailscale\n",
|
|
);
|
|
|
|
delete process.env.OPENCLAW_TEST_TAILSCALE_BINARY;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_TEST_TAILSCALE_BINARY).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks pinned helper interpreter vars from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"OPENCLAW_PINNED_PYTHON=./attacker-python",
|
|
"OPENCLAW_PINNED_WRITE_PYTHON=./attacker-write-python",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.OPENCLAW_PINNED_PYTHON;
|
|
delete process.env.OPENCLAW_PINNED_WRITE_PYTHON;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_PINNED_PYTHON).toBeUndefined();
|
|
expect(process.env.OPENCLAW_PINNED_WRITE_PYTHON).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks bundled trust-root vars from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(path.join(cwdDir, ".env"), [...BUNDLED_TRUST_ROOT_ENV_LINES].join("\n"));
|
|
|
|
clearEnv(BUNDLED_TRUST_ROOT_ENV_KEYS);
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expectEnvUndefined(BUNDLED_TRUST_ROOT_ENV_KEYS);
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each(["npm_execpath", "NPM_EXECPATH"])("blocks %s from workspace .env", async (key) => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(path.join(cwdDir, ".env"), `${key}=./evil/npm-cli.js\n`);
|
|
|
|
delete process.env[key];
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env[key]).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("still allows trusted global .env to set non-workspace runtime vars", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
await writeEnvFile(
|
|
path.join(stateDir, ".env"),
|
|
[
|
|
"ANTHROPIC_BASE_URL=https://trusted.example.com/v1",
|
|
"HTTP_PROXY=http://proxy.test:8080",
|
|
"OPENCLAW_PINNED_PYTHON=/trusted/python",
|
|
"OPENCLAW_PINNED_WRITE_PYTHON=/trusted/write-python",
|
|
].join("\n"),
|
|
);
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.ANTHROPIC_BASE_URL;
|
|
delete process.env.HTTP_PROXY;
|
|
delete process.env.OPENCLAW_PINNED_PYTHON;
|
|
delete process.env.OPENCLAW_PINNED_WRITE_PYTHON;
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.ANTHROPIC_BASE_URL).toBe("https://trusted.example.com/v1");
|
|
expect(process.env.HTTP_PROXY).toBe("http://proxy.test:8080");
|
|
expect(process.env.OPENCLAW_PINNED_PYTHON).toBe("/trusted/python");
|
|
expect(process.env.OPENCLAW_PINNED_WRITE_PYTHON).toBe("/trusted/write-python");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("still allows trusted global .env to set credential and gateway auth vars", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
|
|
await writeEnvFile(
|
|
path.join(stateDir, ".env"),
|
|
[
|
|
"ANTHROPIC_API_KEY=sk-ant-trusted-key",
|
|
"ANTHROPIC_API_KEY_SECONDARY=sk-ant-secondary",
|
|
"ANTHROPIC_OAUTH_TOKEN=trusted-oauth",
|
|
"OPENAI_API_KEY=sk-openai-trusted-key",
|
|
"OPENAI_API_KEYS=sk-openai-a,sk-openai-b",
|
|
"OPENAI_API_KEY_SECONDARY=sk-openai-secondary",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-live",
|
|
"OPENCLAW_LIVE_ANTHROPIC_KEYS=sk-ant-live-a,sk-ant-live-b",
|
|
"OPENCLAW_LIVE_GEMINI_KEY=sk-gemini-live",
|
|
"OPENCLAW_LIVE_OPENAI_KEY=sk-openai-live",
|
|
"OPENCLAW_GATEWAY_TOKEN=trusted-token",
|
|
"OPENCLAW_GATEWAY_PASSWORD=trusted-password",
|
|
"OPENCLAW_GATEWAY_SECRET=trusted-secret",
|
|
].join("\n"),
|
|
);
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
clearEnv(CREDENTIAL_AND_GATEWAY_ENV_KEYS);
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-trusted-key");
|
|
expect(process.env.ANTHROPIC_API_KEY_SECONDARY).toBe("sk-ant-secondary");
|
|
expect(process.env.ANTHROPIC_OAUTH_TOKEN).toBe("trusted-oauth");
|
|
expect(process.env.OPENAI_API_KEY).toBe("sk-openai-trusted-key");
|
|
expect(process.env.OPENAI_API_KEYS).toBe("sk-openai-a,sk-openai-b");
|
|
expect(process.env.OPENAI_API_KEY_SECONDARY).toBe("sk-openai-secondary");
|
|
expect(process.env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBe("sk-ant-live");
|
|
expect(process.env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBe("sk-ant-live-a,sk-ant-live-b");
|
|
expect(process.env.OPENCLAW_LIVE_GEMINI_KEY).toBe("sk-gemini-live");
|
|
expect(process.env.OPENCLAW_LIVE_OPENAI_KEY).toBe("sk-openai-live");
|
|
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("trusted-token");
|
|
expect(process.env.OPENCLAW_GATEWAY_PASSWORD).toBe("trusted-password");
|
|
expect(process.env.OPENCLAW_GATEWAY_SECRET).toBe("trusted-secret");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not let CWD .env redirect which global .env is loaded", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir, stateDir }) => {
|
|
const evilStateDir = path.join(base, "evil-state");
|
|
await writeEnvFile(path.join(cwdDir, ".env"), "OPENCLAW_STATE_DIR=./evil-state\n");
|
|
await writeEnvFile(path.join(stateDir, ".env"), "SAFE_KEY=trusted-global\n");
|
|
await writeEnvFile(path.join(evilStateDir, ".env"), "SAFE_KEY=evil-global\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.SAFE_KEY;
|
|
|
|
loadDotEnv({ quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
|
|
expect(process.env.SAFE_KEY).toBe("trusted-global");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("loadCliDotEnv", () => {
|
|
it("blocks OPENCLAW_STATE_DIR from workspace .env even when unset in process env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(path.join(cwdDir, ".env"), "OPENCLAW_STATE_DIR=./evil-state\n");
|
|
|
|
// Delete the fixture-provided value so the blocking must come from
|
|
// the workspace blocklist, not the "already set" skip.
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("loads the gateway.env compatibility fallback during CLI startup", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir }) => {
|
|
process.env.HOME = base;
|
|
const defaultStateDir = path.join(base, ".openclaw");
|
|
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
|
|
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
|
|
await writeEnvFile(
|
|
path.join(base, ".config", "openclaw", "gateway.env"),
|
|
"BAR=from-gateway\n",
|
|
);
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.FOO;
|
|
delete process.env.BAR;
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBe("from-global");
|
|
expect(process.env.BAR).toBe("from-gateway");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not load gateway.env when OPENCLAW_STATE_DIR is explicitly set", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir }) => {
|
|
const customStateDir = path.join(base, "custom-state");
|
|
process.env.HOME = base;
|
|
process.env.OPENCLAW_STATE_DIR = customStateDir;
|
|
await writeEnvFile(
|
|
path.join(base, ".config", "openclaw", "gateway.env"),
|
|
"FOO=from-gateway\n",
|
|
);
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.FOO;
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expect(process.env.FOO).toBeUndefined();
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBe(customStateDir);
|
|
expect(process.env.BAR).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("keeps the legacy state-dir fallback for CLI dotenv loading", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-legacy-"));
|
|
const cwdDir = path.join(base, "cwd");
|
|
const legacyStateDir = path.join(base, ".clawdbot");
|
|
process.env.HOME = base;
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
delete process.env.OPENCLAW_TEST_FAST;
|
|
await fs.mkdir(cwdDir, { recursive: true });
|
|
await writeEnvFile(path.join(legacyStateDir, ".env"), "LEGACY_ONLY=from-legacy\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.LEGACY_ONLY;
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expect(process.env.LEGACY_ONLY).toBe("from-legacy");
|
|
});
|
|
});
|
|
|
|
it("blocks bundled trust-root vars from workspace .env during CLI startup", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(path.join(cwdDir, ".env"), [...BUNDLED_TRUST_ROOT_ENV_LINES].join("\n"));
|
|
|
|
clearEnv(BUNDLED_TRUST_ROOT_ENV_KEYS);
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expectEnvUndefined(BUNDLED_TRUST_ROOT_ENV_KEYS);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks workspace .env takeover vars before loading the global fallback", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ base, cwdDir, stateDir }) => {
|
|
const bundledPluginsDir = path.join(base, "attacker-bundled");
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"SAFE_KEY=from-cwd",
|
|
"OPENCLAW_STATE_DIR=./evil-state",
|
|
"OPENCLAW_CONFIG_PATH=./evil-config.json",
|
|
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
|
|
"NODE_OPTIONS=--require ./evil.js",
|
|
"ANTHROPIC_BASE_URL=https://evil.example.com/v1",
|
|
"UV_PYTHON=./attacker-python",
|
|
"uv_python=./attacker-python-lower",
|
|
].join("\n"),
|
|
);
|
|
await writeEnvFile(path.join(stateDir, ".env"), "BAR=from-global\n");
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
|
|
delete process.env.SAFE_KEY;
|
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
delete process.env.NODE_OPTIONS;
|
|
delete process.env.ANTHROPIC_BASE_URL;
|
|
delete process.env.UV_PYTHON;
|
|
delete process.env.uv_python;
|
|
delete process.env.BAR;
|
|
|
|
loadCliDotEnv({ quiet: true });
|
|
|
|
expect(process.env.SAFE_KEY).toBe("from-cwd");
|
|
expect(process.env.BAR).toBe("from-global");
|
|
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
|
|
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
|
|
expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined();
|
|
expect(process.env.NODE_OPTIONS).toBeUndefined();
|
|
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
|
expect(process.env.UV_PYTHON).toBeUndefined();
|
|
expect(process.env.uv_python).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("workspace .env blocklist completeness", () => {
|
|
it("blocks runtime-control variables from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
const runtimeControlKeys = [
|
|
"OPENCLAW_GIT_DIR",
|
|
"OPENCLAW_WORKSPACE_DIR",
|
|
"OPENCLAW_MDNS_HOSTNAME",
|
|
"OPENCLAW_SESSION_CACHE_TTL_MS",
|
|
"OPENCLAW_UPDATE_PACKAGE_SPEC",
|
|
"OPENCLAW_GATEWAY_PORT",
|
|
"OPENCLAW_GATEWAY_URL",
|
|
"OPENCLAW_CLAWHUB_URL",
|
|
"CLAWHUB_URL",
|
|
"OPENCLAW_CLAWHUB_TOKEN",
|
|
"CLAWHUB_TOKEN",
|
|
"CLAWHUB_AUTH_TOKEN",
|
|
"CLAWHUB_CONFIG_PATH",
|
|
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
|
|
"OPENCLAW_ALLOW_INSECURE_PRIVATE_WS",
|
|
"OPENCLAW_BROWSER_EXECUTABLE_PATH",
|
|
"EXAMPLE_API_HOST",
|
|
"HOMEBREW_BREW_FILE",
|
|
"HOMEBREW_PREFIX",
|
|
"IRC_HOST",
|
|
"LOCALAPPDATA",
|
|
"MATTERMOST_URL",
|
|
"MATRIX_HOMESERVER",
|
|
"MINIMAX_API_HOST",
|
|
"BROWSER_EXECUTABLE_PATH",
|
|
"PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH",
|
|
"OPENCLAW_SKIP_CHANNELS",
|
|
"OPENCLAW_SKIP_PROVIDERS",
|
|
"OPENCLAW_SKIP_CRON",
|
|
"OPENCLAW_RAW_STREAM",
|
|
"OPENCLAW_RAW_STREAM_PATH",
|
|
"OPENCLAW_CACHE_TRACE",
|
|
"OPENCLAW_CACHE_TRACE_FILE",
|
|
"OPENCLAW_CACHE_TRACE_MESSAGES",
|
|
"OPENCLAW_CACHE_TRACE_PROMPT",
|
|
"OPENCLAW_CACHE_TRACE_SYSTEM",
|
|
"OPENCLAW_SHOW_SECRETS",
|
|
"OPENCLAW_PLUGIN_CATALOG_PATHS",
|
|
"OPENCLAW_MPM_CATALOG_PATHS",
|
|
"OPENCLAW_NODE_EXEC_HOST",
|
|
"OPENCLAW_NODE_EXEC_FALLBACK",
|
|
"OPENCLAW_ALLOW_PROJECT_LOCAL_BIN",
|
|
"PATH",
|
|
"HOMEBREW_BREW_FILE",
|
|
"HOMEBREW_PREFIX",
|
|
"SystemRoot",
|
|
"WINDIR",
|
|
"ProgramFiles",
|
|
"ProgramFiles(x86)",
|
|
"ProgramW6432",
|
|
"STATE_DIRECTORY",
|
|
"SYNOLOGY_CHAT_INCOMING_URL",
|
|
"SYNOLOGY_NAS_HOST",
|
|
];
|
|
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
`${runtimeControlKeys.map((key) => `${key}=INJECTED_${key}`).join("\n")}\n`,
|
|
);
|
|
|
|
for (const key of runtimeControlKeys) {
|
|
delete process.env[key];
|
|
}
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
for (const key of runtimeControlKeys) {
|
|
expect(process.env[key], `${key} should be blocked by workspace .env`).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
it("still allows user-defined non-control vars through workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
"MY_APP_KEY=user-value\nAPP_GITHUB_REPO=openclaw/openclaw\nDATABASE_URL_CUSTOM=pg://localhost\n",
|
|
);
|
|
|
|
delete process.env.MY_APP_KEY;
|
|
delete process.env.APP_GITHUB_REPO;
|
|
delete process.env.DATABASE_URL_CUSTOM;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.MY_APP_KEY).toBe("user-value");
|
|
expect(process.env.APP_GITHUB_REPO).toBe("openclaw/openclaw");
|
|
expect(process.env.DATABASE_URL_CUSTOM).toBe("pg://localhost");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks bundled connector endpoint vars from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"MATRIX_HOMESERVER=https://evil-matrix.example.com",
|
|
"MATTERMOST_URL=https://evil-mattermost.example.com",
|
|
"IRC_HOST=evil-irc.example.com",
|
|
"SYNOLOGY_CHAT_INCOMING_URL=https://evil-synology.example.com/incoming",
|
|
"SYNOLOGY_NAS_HOST=evil-synology.example.com",
|
|
"SAFE_PROVIDER_URL=https://allowed.example.com",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.MATRIX_HOMESERVER;
|
|
delete process.env.MATTERMOST_URL;
|
|
delete process.env.IRC_HOST;
|
|
delete process.env.SYNOLOGY_CHAT_INCOMING_URL;
|
|
delete process.env.SYNOLOGY_NAS_HOST;
|
|
delete process.env.SAFE_PROVIDER_URL;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.MATRIX_HOMESERVER).toBeUndefined();
|
|
expect(process.env.MATTERMOST_URL).toBeUndefined();
|
|
expect(process.env.IRC_HOST).toBeUndefined();
|
|
expect(process.env.SYNOLOGY_CHAT_INCOMING_URL).toBeUndefined();
|
|
expect(process.env.SYNOLOGY_NAS_HOST).toBeUndefined();
|
|
expect(process.env.SAFE_PROVIDER_URL).toBe("https://allowed.example.com");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks Matrix per-account scoped homeserver vars from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"MATRIX_DEFAULT_HOMESERVER=https://evil-default.example.com",
|
|
"MATRIX_OPS_HOMESERVER=https://evil-ops.example.com",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.MATRIX_DEFAULT_HOMESERVER;
|
|
delete process.env.MATRIX_OPS_HOMESERVER;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.MATRIX_DEFAULT_HOMESERVER).toBeUndefined();
|
|
expect(process.env.MATRIX_OPS_HOMESERVER).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("blocks generic endpoint-routing suffixes from workspace .env", async () => {
|
|
await withIsolatedEnvAndCwd(async () => {
|
|
await withDotEnvFixture(async ({ cwdDir }) => {
|
|
await writeEnvFile(
|
|
path.join(cwdDir, ".env"),
|
|
[
|
|
"FUTURE_PROVIDER_API_HOST=https://evil.example.com",
|
|
"FUTURE_PROVIDER_BASE_URL=https://evil.example.com/v1",
|
|
"SAFE_PROVIDER_URL=https://allowed.example.com",
|
|
].join("\n"),
|
|
);
|
|
|
|
delete process.env.FUTURE_PROVIDER_API_HOST;
|
|
delete process.env.FUTURE_PROVIDER_BASE_URL;
|
|
delete process.env.SAFE_PROVIDER_URL;
|
|
|
|
loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true });
|
|
|
|
expect(process.env.FUTURE_PROVIDER_API_HOST).toBeUndefined();
|
|
expect(process.env.FUTURE_PROVIDER_BASE_URL).toBeUndefined();
|
|
expect(process.env.SAFE_PROVIDER_URL).toBe("https://allowed.example.com");
|
|
});
|
|
});
|
|
});
|
|
});
|