From 914becee52426127fe4e02b791ad131cbc2b29ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 19:06:59 +0000 Subject: [PATCH] fix: isolate live test home from real config --- docs/help/testing.md | 5 +- test/test-env.test.ts | 139 ++++++++++++++++++++++++++++++++++ test/test-env.ts | 169 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 test/test-env.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index 66cd8f91286..95134b20f17 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -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). diff --git a/test/test-env.test.ts b/test/test-env.test.ts new file mode 100644 index 00000000000..c57afa0c2c7 --- /dev/null +++ b/test/test-env.test.ts @@ -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(); +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; + list?: Array>; + }; + models?: { providers?: Record }; + }; + 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"); + }); +}); diff --git a/test/test-env.ts b/test/test-env.ts index 46dfecdfaf7..3c024fc9529 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -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; + list?: Array>; + }; + }; + 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(); }