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; } switch (value.trim().toLowerCase()) { case "": case "0": case "false": case "no": case "off": return false; default: return true; } } function restoreEnv(entries: RestoreEntry[]): void { for (const { key, value } of entries) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } } 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; } const applyEntry = (entry: string) => { const idx = entry.indexOf("="); if (idx <= 0) { return false; } const key = entry.slice(0, idx).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || (process.env[key] ?? "") !== "") { return false; } process.env[key] = entry.slice(idx + 1); return true; }; const countAppliedEntries = (entries: Iterable) => { let applied = 0; for (const entry of entries) { if (applyEntry(entry)) { applied += 1; } } return applied; }; try { const output = execFileSync( "/bin/bash", ["-lc", `set -a; source "${profilePath}" >/dev/null 2>&1; env -0`], { encoding: "utf8" }, ); const applied = countAppliedEntries(output.split("\0").filter(Boolean)); if (applied > 0 && !isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST_QUIET)) { console.log(`[live] loaded ${applied} env vars from ~/.profile`); } } catch { try { const fallbackEntries = fs .readFileSync(profilePath, "utf8") .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")) .map((line) => line.replace(/^export\s+/u, "")) .map((line) => { const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); if (!match) { return ""; } let value = match[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } return `${match[1]}=${value}`; }) .filter(Boolean); const applied = countAppliedEntries(fallbackEntries); if (applied > 0 && !isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST_QUIET)) { console.log(`[live] loaded ${applied} env vars from ~/.profile`); } } catch { // ignore profile load failures } } } 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 }, { key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME }, { key: "XDG_DATA_HOME", value: process.env.XDG_DATA_HOME }, { key: "XDG_STATE_HOME", value: process.env.XDG_STATE_HOME }, { key: "XDG_CACHE_HOME", value: process.env.XDG_CACHE_HOME }, { key: "OPENCLAW_STATE_DIR", value: process.env.OPENCLAW_STATE_DIR }, { key: "OPENCLAW_CONFIG_PATH", value: process.env.OPENCLAW_CONFIG_PATH }, { key: "OPENCLAW_GATEWAY_PORT", value: process.env.OPENCLAW_GATEWAY_PORT }, { key: "OPENCLAW_BRIDGE_ENABLED", value: process.env.OPENCLAW_BRIDGE_ENABLED }, { key: "OPENCLAW_BRIDGE_HOST", value: process.env.OPENCLAW_BRIDGE_HOST }, { 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 }, { key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN }, { key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN }, { key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN }, { key: "GH_TOKEN", value: process.env.GH_TOKEN }, { 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; process.env.USERPROFILE = tempHome; process.env.OPENCLAW_TEST_HOME = tempHome; process.env.OPENCLAW_TEST_FAST = "1"; // Ensure test runs never touch the developer's real config/state, even if they have overrides set. 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; delete process.env.OPENCLAW_BRIDGE_HOST; delete process.env.OPENCLAW_BRIDGE_PORT; delete process.env.OPENCLAW_CANVAS_HOST_PORT; // Avoid leaking real GitHub/Copilot tokens into non-live test runs. delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN; delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; delete process.env.SLACK_USER_TOKEN; delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; // Avoid leaking local dev tooling flags into tests (e.g. --inspect). delete process.env.NODE_OPTIONS; // Windows: prefer the default state dir so auth/profile tests match real paths. if (process.platform === "win32") { process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); } process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config"); process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share"); process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state"); process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache"); const cleanup = () => { restoreEnv(restore); try { fs.rmSync(tempHome, { recursive: true, force: true }); } catch { // ignore cleanup errors } }; 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 rawStateDir = params.env.OPENCLAW_STATE_DIR?.trim(); let realStateDir = rawStateDir ? resolveHomeRelativePath(rawStateDir, params.realHome) : path.join(params.realHome, ".openclaw"); const priorIsolatedHome = params.env.OPENCLAW_TEST_HOME?.trim(); const snapshotHome = params.env.HOME?.trim(); if ( priorIsolatedHome && snapshotHome && snapshotHome !== priorIsolatedHome && realStateDir === path.join(priorIsolatedHome, ".openclaw") ) { realStateDir = 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(); }