fix: isolate live test home from real config

This commit is contained in:
Peter Steinberger 2026-03-28 19:06:59 +00:00
parent 8ea4c4a6ba
commit 914becee52
3 changed files with 296 additions and 17 deletions

View File

@ -151,7 +151,9 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Not CI-stable by design (real networks, real provider policies, quotas, outages)
- Costs money / uses rate limits
- Prefer running narrowed subsets instead of “everything”
- Live runs will source `~/.profile` to pick up missing API keys
- Live runs source `~/.profile` to pick up missing API keys.
- By default, live runs still isolate `HOME` and copy config/auth material into a temp test home so unit fixtures cannot mutate your real `~/.openclaw`.
- Set `OPENCLAW_LIVE_USE_REAL_HOME=1` only when you intentionally need live tests to use your real home directory.
- `pnpm test:live` now defaults to a quieter mode: it keeps `[live] ...` progress output, but suppresses the extra `~/.profile` notice and mutes gateway bootstrap logs/Bonjour chatter. Set `OPENCLAW_LIVE_TEST_QUIET=0` if you want the full startup logs back.
- API key rotation (provider-specific): set `*_API_KEYS` with comma/semicolon format or `*_API_KEY_1`, `*_API_KEY_2` (for example `OPENAI_API_KEYS`, `ANTHROPIC_API_KEYS`, `GEMINI_API_KEYS`) or per-live override via `OPENCLAW_LIVE_*_KEY`; tests retry on rate limit responses.
- Progress/heartbeat output:
@ -452,6 +454,7 @@ Live tests discover credentials the same way the CLI does. Practical implication
- Profile store: `~/.openclaw/credentials/` (preferred; what “profile keys” means in the tests)
- Config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH`)
- Live local runs copy the active config plus auth stores into a temp test home by default; `agents.*.workspace` / `agentDir` path overrides are stripped in that staged copy so probes stay off your real host workspace.
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).

139
test/test-env.test.ts Normal file
View File

@ -0,0 +1,139 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { installTestEnv } from "./test-env.js";
const ORIGINAL_ENV = { ...process.env };
const tempDirs = new Set<string>();
const cleanupFns: Array<() => void> = [];
function restoreProcessEnv(): void {
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function writeFile(targetPath: string, content: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, content, "utf8");
}
function createTempHome(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-env-real-home-"));
tempDirs.add(tempDir);
return tempDir;
}
afterEach(() => {
while (cleanupFns.length > 0) {
cleanupFns.pop()?.();
}
restoreProcessEnv();
for (const tempDir of tempDirs) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("installTestEnv", () => {
it("keeps live tests on a temp HOME while copying config and auth state", () => {
const realHome = createTempHome();
writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n");
writeFile(
path.join(realHome, "custom-openclaw.json5"),
`{
// Preserve provider config, strip host-bound paths.
agents: {
defaults: {
workspace: "/Users/peter/Projects",
agentDir: "/Users/peter/.openclaw/agents/main/agent",
},
list: [
{
id: "dev",
workspace: "/Users/peter/dev-workspace",
agentDir: "/Users/peter/.openclaw/agents/dev/agent",
},
],
},
models: {
providers: {
custom: { baseUrl: "https://example.test/v1" },
},
},
}`,
);
writeFile(path.join(realHome, ".openclaw", "credentials", "token.txt"), "secret\n");
writeFile(
path.join(realHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"),
JSON.stringify({ version: 1, profiles: { default: { provider: "openai" } } }, null, 2),
);
writeFile(path.join(realHome, ".claude", ".credentials.json"), '{"accessToken":"token"}\n');
process.env.HOME = realHome;
process.env.USERPROFILE = realHome;
process.env.OPENCLAW_LIVE_TEST = "1";
process.env.OPENCLAW_LIVE_TEST_QUIET = "1";
process.env.OPENCLAW_CONFIG_PATH = "~/custom-openclaw.json5";
const testEnv = installTestEnv();
cleanupFns.push(testEnv.cleanup);
expect(testEnv.tempHome).not.toBe(realHome);
expect(process.env.HOME).toBe(testEnv.tempHome);
expect(process.env.OPENCLAW_TEST_HOME).toBe(testEnv.tempHome);
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
const copiedConfigPath = path.join(testEnv.tempHome, ".openclaw", "openclaw.json");
const copiedConfig = JSON.parse(fs.readFileSync(copiedConfigPath, "utf8")) as {
agents?: {
defaults?: Record<string, unknown>;
list?: Array<Record<string, unknown>>;
};
models?: { providers?: Record<string, unknown> };
};
expect(copiedConfig.models?.providers?.custom).toEqual({ baseUrl: "https://example.test/v1" });
expect(copiedConfig.agents?.defaults?.workspace).toBeUndefined();
expect(copiedConfig.agents?.defaults?.agentDir).toBeUndefined();
expect(copiedConfig.agents?.list?.[0]?.workspace).toBeUndefined();
expect(copiedConfig.agents?.list?.[0]?.agentDir).toBeUndefined();
expect(
fs.existsSync(path.join(testEnv.tempHome, ".openclaw", "credentials", "token.txt")),
).toBe(true);
expect(
fs.existsSync(
path.join(testEnv.tempHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"),
),
).toBe(true);
expect(fs.existsSync(path.join(testEnv.tempHome, ".claude", ".credentials.json"))).toBe(true);
});
it("allows explicit live runs against the real HOME", () => {
const realHome = createTempHome();
writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n");
process.env.HOME = realHome;
process.env.USERPROFILE = realHome;
process.env.OPENCLAW_LIVE_TEST = "1";
process.env.OPENCLAW_LIVE_USE_REAL_HOME = "1";
process.env.OPENCLAW_LIVE_TEST_QUIET = "1";
const testEnv = installTestEnv();
expect(testEnv.tempHome).toBe(realHome);
expect(process.env.HOME).toBe(realHome);
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
});
});

View File

@ -2,9 +2,12 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
type RestoreEntry = { key: string; value: string | undefined };
const LIVE_EXTERNAL_AUTH_DIRS = [".claude", ".codex", ".minimax"] as const;
function isTruthyEnvValue(value: string | undefined): boolean {
if (!value) {
return false;
@ -31,8 +34,19 @@ function restoreEnv(entries: RestoreEntry[]): void {
}
}
function loadProfileEnv(): void {
const profilePath = path.join(os.homedir(), ".profile");
function resolveHomeRelativePath(input: string, homeDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") {
return homeDir;
}
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(homeDir, trimmed.slice(2));
}
return path.resolve(trimmed);
}
function loadProfileEnv(homeDir = os.homedir()): void {
const profilePath = path.join(homeDir, ".profile");
if (!fs.existsSync(profilePath)) {
return;
}
@ -67,20 +81,8 @@ function loadProfileEnv(): void {
}
}
export function installTestEnv(): { cleanup: () => void; tempHome: string } {
const live =
process.env.LIVE === "1" ||
process.env.OPENCLAW_LIVE_TEST === "1" ||
process.env.OPENCLAW_LIVE_GATEWAY === "1";
// Live tests must use the real user environment (keys, profiles, config).
// The default test env isolates HOME to avoid touching real state.
if (live) {
loadProfileEnv();
return { cleanup: () => {}, tempHome: process.env.HOME ?? "" };
}
const restore: RestoreEntry[] = [
function resolveRestoreEntries(): RestoreEntry[] {
return [
{ key: "OPENCLAW_TEST_FAST", value: process.env.OPENCLAW_TEST_FAST },
{ key: "HOME", value: process.env.HOME },
{ key: "USERPROFILE", value: process.env.USERPROFILE },
@ -96,6 +98,8 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
{ key: "OPENCLAW_BRIDGE_PORT", value: process.env.OPENCLAW_BRIDGE_PORT },
{ key: "OPENCLAW_CANVAS_HOST_PORT", value: process.env.OPENCLAW_CANVAS_HOST_PORT },
{ key: "OPENCLAW_TEST_HOME", value: process.env.OPENCLAW_TEST_HOME },
{ key: "OPENCLAW_AGENT_DIR", value: process.env.OPENCLAW_AGENT_DIR },
{ key: "PI_CODING_AGENT_DIR", value: process.env.PI_CODING_AGENT_DIR },
{ key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN },
{ key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN },
{ key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN },
@ -106,7 +110,12 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
{ key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS },
];
}
function createIsolatedTestHome(restore: RestoreEntry[]): {
cleanup: () => void;
tempHome: string;
} {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-"));
process.env.HOME = tempHome;
@ -118,6 +127,8 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
delete process.env.OPENCLAW_CONFIG_PATH;
// Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly.
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
// Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers).
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_BRIDGE_ENABLED;
@ -158,6 +169,132 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
return { cleanup, tempHome };
}
function ensureParentDir(targetPath: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
}
function copyDirIfExists(sourcePath: string, targetPath: string): void {
if (!fs.existsSync(sourcePath)) {
return;
}
fs.mkdirSync(targetPath, { recursive: true });
fs.cpSync(sourcePath, targetPath, {
recursive: true,
force: true,
});
}
function copyFileIfExists(sourcePath: string, targetPath: string): void {
if (!fs.existsSync(sourcePath)) {
return;
}
ensureParentDir(targetPath);
fs.copyFileSync(sourcePath, targetPath);
}
function sanitizeLiveConfig(raw: string): string {
try {
const parsed = JSON5.parse(raw) as {
agents?: {
defaults?: Record<string, unknown>;
list?: Array<Record<string, unknown>>;
};
};
if (!parsed || typeof parsed !== "object") {
return raw;
}
if (parsed.agents?.defaults && typeof parsed.agents.defaults === "object") {
delete parsed.agents.defaults.workspace;
delete parsed.agents.defaults.agentDir;
}
if (Array.isArray(parsed.agents?.list)) {
parsed.agents.list = parsed.agents.list.map((entry) => {
if (!entry || typeof entry !== "object") {
return entry;
}
const nextEntry = { ...entry };
delete nextEntry.workspace;
delete nextEntry.agentDir;
return nextEntry;
});
}
return `${JSON.stringify(parsed, null, 2)}\n`;
} catch {
return raw;
}
}
function copyLiveAuthProfiles(realStateDir: string, tempStateDir: string): void {
const agentsDir = path.join(realStateDir, "agents");
if (!fs.existsSync(agentsDir)) {
return;
}
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const sourcePath = path.join(agentsDir, entry.name, "agent", "auth-profiles.json");
const targetPath = path.join(tempStateDir, "agents", entry.name, "agent", "auth-profiles.json");
copyFileIfExists(sourcePath, targetPath);
}
}
function stageLiveTestState(params: {
env: NodeJS.ProcessEnv;
realHome: string;
tempHome: string;
}): void {
const realStateDir = params.env.OPENCLAW_STATE_DIR?.trim()
? resolveHomeRelativePath(params.env.OPENCLAW_STATE_DIR, params.realHome)
: path.join(params.realHome, ".openclaw");
const tempStateDir = path.join(params.tempHome, ".openclaw");
fs.mkdirSync(tempStateDir, { recursive: true });
const realConfigPath = params.env.OPENCLAW_CONFIG_PATH?.trim()
? resolveHomeRelativePath(params.env.OPENCLAW_CONFIG_PATH, params.realHome)
: path.join(realStateDir, "openclaw.json");
if (fs.existsSync(realConfigPath)) {
const rawConfig = fs.readFileSync(realConfigPath, "utf8");
fs.writeFileSync(
path.join(tempStateDir, "openclaw.json"),
sanitizeLiveConfig(rawConfig),
"utf8",
);
}
copyDirIfExists(path.join(realStateDir, "credentials"), path.join(tempStateDir, "credentials"));
copyLiveAuthProfiles(realStateDir, tempStateDir);
for (const authDir of LIVE_EXTERNAL_AUTH_DIRS) {
copyDirIfExists(path.join(params.realHome, authDir), path.join(params.tempHome, authDir));
}
}
export function installTestEnv(): { cleanup: () => void; tempHome: string } {
const live =
process.env.LIVE === "1" ||
process.env.OPENCLAW_LIVE_TEST === "1" ||
process.env.OPENCLAW_LIVE_GATEWAY === "1";
const allowRealHome = isTruthyEnvValue(process.env.OPENCLAW_LIVE_USE_REAL_HOME);
const realHome = process.env.HOME ?? os.homedir();
const liveEnvSnapshot = { ...process.env };
loadProfileEnv(realHome);
if (live && allowRealHome) {
return { cleanup: () => {}, tempHome: realHome };
}
const restore = resolveRestoreEntries();
const testEnv = createIsolatedTestHome(restore);
if (live) {
stageLiveTestState({ env: liveEnvSnapshot, realHome, tempHome: testEnv.tempHome });
}
return testEnv;
}
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
return installTestEnv();
}