openclaw/test/test-env.ts
Peter Steinberger ccd3fbb918
fix(gateway): install despite stale Linux session bus addresses (#146569)
## What Problem This Solves

Fresh Linux Gateway installs can report `SERVICE_DEFINITION_UNKNOWN` when the shell carries a stale session-bus address, even though status reaches the running user manager. Replacing every explicit address with the runtime bus also breaks updates for a working custom bus when an unrelated runtime socket exists.

## User Impact

Gateway installation works with healthy, stale, or missing shell bus settings when the user manager is reachable. Working custom buses remain usable for installation and update admission. Install and status share the service inspection verdict; unavailable managers receive actionable session-repair guidance, while unreadable or foreign definitions identify the affected file.

## Why This Change Was Made

One transport owner probes `Manager.Version` without activation, preserving a working explicit address before trying the runtime bus and private socket. Existing machine/sudo routing remains at that owner. Child commands, effective-command inspection, and update admission consume the selected route; deep status exposes it. A socket's existence does not establish that another address is stale.

The owner records whether an earlier probe timed out. Later admission rechecks that unresolved preference within its own remaining deadline, including when it joined another caller's short discovery. Ordinary commands reuse the positive route. Native locality checks apply to the selected transport, and admitted peer connections retain their unique-owner, UID/PID/start-time, deadline, and authority checks.

The filesystem override, per-caller private-first decision, and duplicate command-time machine fallback were removed. Payload bus/account variables cannot redirect supervisor inspection. Private-socket reopen failures and discovery deadlines retain manager diagnostics. Existing publication backups and rollback remain unchanged; no configuration options, dependencies, protocol changes, or storage migrations were added.

Refs #146022, #145023. Reported in Discord support; no contributor code adopted.

## Evidence

The Debian 12 rig uses systemd `252.39-1~deb12u2`, a synthetic non-root account, and the built `dist/entry.js` from `/tmp`. The custom variant runs the manager on `/tmp/dbus-custom` with an unrelated broker at the runtime bus path.

```text
Before round three, built head 25e675a6d79:
  healthy:     install succeeds; runtime running; RPC true
  stale:       install succeeds; runtime running; RPC true
  private-only: install succeeds; runtime running; RPC true
  unavailable: actionable manager diagnosis
  custom bus:  Manager.Version succeeds; install/status succeed
               update admission fails
  Move only the unrelated runtime socket aside: admission succeeds
  Restore it: admission fails again

After, built head baca81d53f86297e917cebaf2b0032fca807db42:
  healthy:      install 0; running; RPC true; runtime-bus
  stale:        install 0; running; RPC true; runtime-bus
  private-only: install 0; running; RPC true; private socket
  custom:       install 0; running; RPC true; custom session-bus
  unavailable:  install 1; actionable diagnosis; no definition error
  custom update admission: loaded; command present; running;
                           selected unix:path=/tmp/dbus-custom
```

Regression tests failed before their fixes for custom-bus admission, private-socket reopen, payload route drift, timeout-dependent fallback, and nonlocal-to-local fallback. The tests preserve successful discovery sharing, per-caller deadlines, custody retirement, and both positive-fallback and wholly failed short-discovery cases. No stash was used.

The 17 initial daemon failures were outdated fixture boundaries: service absence (4), definition mutation (7), private command-query cleanup (2), availability routing (3), and child environment routing (1). Fixtures now answer the exact nonactivating Version probe while retaining unit ownership, mount, absence, cleanup, and environment-isolation assertions. No baselines, suppressions, or assertions were weakened.

Focused commands:

```sh
node scripts/run-vitest.mjs src/daemon/systemd.test.ts src/daemon/systemd-command-query.test.ts src/daemon/systemd-peer.test.ts src/daemon/systemd-peer-native.test.ts src/daemon/systemd-user-transport.test.ts src/daemon/service-process-env.integration.test.ts src/daemon/systemd-unavailable.test.ts src/daemon/service-absence.test.ts src/daemon/systemd-definition-mutation.test.ts src/daemon/systemd-user-command-deadline.test.ts src/daemon/systemd-loaded-runtime.test.ts src/daemon/service.test.ts src/daemon/service-operation-lock.test.ts src/daemon/service-env.test.ts
node scripts/run-vitest.mjs src/commands/doctor-maintenance.service-inspection.test.ts src/cli/daemon-cli/install.test.ts src/cli/daemon-cli/install.integration.test.ts src/cli/daemon-cli/install.reinstall.test.ts
node scripts/check-changed.mjs
node scripts/check-changed.mjs -- src/daemon/systemd-user-transport.ts src/daemon/systemd-user-transport.test.ts
pnpm build
```

The custom update proof invokes built `readGatewayServiceState` with effective, loaded-only admission; it does not mutate packages. A full published-driver update and native macOS/Windows execution were not rerun. Bound update and launchd diagnostic suites passed.


Validation passed: 759 focused tests across the suites and final regression runs; the full changed-file check and final delta check; `pnpm build` on the committed head; all five Debian rig variants. Autoreview was scoped clean with no accepted P0–P2 findings. The final cache regression was run with its fix hunk removed: it failed because admission retained `private` instead of selecting the working `session-bus`; after restoring the exact reviewed bytes, all 12 transport tests passed.

Final round-three numstat: production +370/-210, tests/support +955/-222, docs +7/-3. The initial consolidation removed duplicated routing decisions: production changed from +278/-136 (net +142) to +321/-200 (net +121); subsequent deadline, custody, diagnostic, and route-consistency fixes account for the final +160. No baseline or lint exception was added.

## Final validation

Fresh [CI run 34759398349](https://github.com/openclaw/openclaw/actions/runs/34759398349) completed successfully on approved head `012ea719b454c46812d8c0ffdaa1a81367eafe9f`. The native CI watcher returned `GREEN` with zero pending checks. Its preflight checkout log confirms merge `fe948e7724bdb2be507d48035ab023ecaa08105c`, with main `0a7b7fecfd` as first parent and the approved head as second. This merge includes the main-side reclamation-memory harness correction `d4d3d6cda5`; core-2 typechecking, compact-small-1, build, Docker proof, and Windows passed.

The first reopening event captured the previous merge commit despite the refreshed pull-request metadata. Its checkout log proved it had not tested the main correction. After confirming the recomputed merge through both GitHub's API and Git refs, a second close/reopen produced the successful run above. No source changes, empty commits, assertion retries, or gate bypasses were used.

## ClawSweeper review

Skipped the P1 proposal to label typed manager failures as `SERVICE_DEFINITION_UNKNOWN`. The v2026.9.4 updater does choose ordinary restart for these diagnostics, but the candidate still rejects unknown manager state and independently requires effective-command inspection, writable definition capability, and a matching Gateway target before repair. Publication repeats those checks. Actual unreadable/sealed definition failures retain the shipped denial marker. Source review did not establish an unsafe rewrite from a manager-only failure; a published-driver fault-injection run for failure followed by recovery remains a proof gap. The Linux transport expectation finding was already addressed in this head. No code changed during landing review.
2026-09-13 06:45:35 -07:00

567 lines
19 KiB
TypeScript

// Test environment helpers install process env defaults for tests.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import JSON5 from "json5";
import { resolveEffectiveHomeDir } from "../src/infra/home-dir.js";
import { SUPERVISOR_HINT_ENV_VARS } from "../src/infra/supervisor-markers.js";
import { captureFullEnv, deleteTestEnvValue, setTestEnvValue } from "../src/test-utils/env.js";
import { readTestHomeSource, resolveTestCorepackHome } from "./test-home-context.mts";
import {
isTruthyTestEnvValue as isTruthyEnvValue,
LIVE_TEST_TRIGGER_ENV_KEYS,
resolveTestHomePolicy,
} from "./test-home-policy.mts";
type RestoreEntry = { key: string; value: string | undefined };
type InstallTestEnvOptions =
| { mode?: "live-aware"; loadProfileEnv?: boolean }
| { mode: "hermetic" };
const ISOLATED_TEST_CREDENTIAL_ENV_KEYS = [
"TELEGRAM_BOT_TOKEN",
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
"SLACK_USER_TOKEN",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"TWILIO_SMS_FROM",
"TWILIO_MESSAGING_SERVICE_SID",
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
] as const;
const ISOLATED_TEST_SERVICE_ENV_KEYS = [
...SUPERVISOR_HINT_ENV_VARS,
"OPENCLAW_WRAPPER",
"OPENCLAW_GATEWAY_SERVICE_PID",
"OPENCLAW_SERVICE_MANAGED_ENV_KEYS",
"OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER",
] as const;
const HERMETIC_TEST_ENV_KEYS = [
...LIVE_TEST_TRIGGER_ENV_KEYS,
"OPENCLAW_LIVE_USE_REAL_HOME",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_HOME",
] as const;
const LIVE_EXTERNAL_AUTH_DIRS = [".claude/backups", ".gemini", ".minimax"] as const;
const LIVE_EXTERNAL_AUTH_FILES = [
".claude.json",
".claude/.credentials.json",
".claude/settings.json",
".claude/settings.local.json",
".codex/auth.json",
".codex/config.toml",
] as const;
// Keep Gemini credentials and user configuration; only generated browser data can be dropped.
const LIVE_GEMINI_EXCLUDED_PATHS = [
"antigravity-browser-profile",
"antigravity/browser_recordings",
"cli-browser-profile",
"GPUCache",
"Service Worker/CacheStorage",
] as const;
const requireFromHere = createRequire(import.meta.url);
type LegacyConfigCompatApi = typeof import("../src/commands/doctor/shared/legacy-config-compat.js");
type ConfigValidationApi = typeof import("../src/config/validation.js");
let cachedLegacyConfigCompatApi: LegacyConfigCompatApi | undefined;
let cachedConfigValidationApi: ConfigValidationApi | undefined;
function restoreEnv(entries: RestoreEntry[]): void {
for (const { key, value } of entries) {
if (value === undefined) {
deleteTestEnvValue(key);
} else {
setTestEnvValue(key, value);
}
}
}
function loadLegacyConfigCompatApi(): LegacyConfigCompatApi {
cachedLegacyConfigCompatApi ??= requireFromHere(
"../src/commands/doctor/shared/legacy-config-compat.js",
) as LegacyConfigCompatApi;
return cachedLegacyConfigCompatApi;
}
function loadConfigValidationApi(): ConfigValidationApi {
cachedConfigValidationApi ??= requireFromHere(
"../src/config/validation.js",
) as ConfigValidationApi;
return cachedConfigValidationApi;
}
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;
}
setTestEnvValue(key, entry.slice(idx + 1));
return true;
};
const countAppliedEntries = (entries: Iterable<string>) => {
let applied = 0;
for (const entry of entries) {
if (applyEntry(entry)) {
applied += 1;
}
}
return applied;
};
try {
// Skip ambient startup files, which can reset HOME or load an unselected profile.
// Only this reader gets the source HOME; the test process stays isolated.
const output = execFileSync(
"/bin/bash",
[
"--norc",
"-c",
'set -a; source "$1" >/dev/null 2>&1; env -0',
"openclaw-test-profile",
profilePath,
],
{
encoding: "utf8",
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, BASH_ENV: undefined },
},
);
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 "";
}
const name = match[1];
const rawValue = match[2];
if (name === undefined || rawValue === undefined) {
return "";
}
let value = rawValue.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return `${name}=${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 [
...HERMETIC_TEST_ENV_KEYS.map((key) => ({ key, value: process.env[key] })),
{ key: "OPENCLAW_TEST_FAST", value: process.env.OPENCLAW_TEST_FAST },
{
key: "OPENCLAW_STRICT_FAST_REPLY_CONFIG",
value: process.env.OPENCLAW_STRICT_FAST_REPLY_CONFIG,
},
{
key: "OPENCLAW_ALLOW_SLOW_REPLY_TESTS",
value: process.env.OPENCLAW_ALLOW_SLOW_REPLY_TESTS,
},
{
key: "OPENCLAW_LIVE_TEST_NORMALIZE_CONFIG",
value: process.env.OPENCLAW_LIVE_TEST_NORMALIZE_CONFIG,
},
{ 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: "XDG_RUNTIME_DIR", value: process.env.XDG_RUNTIME_DIR },
{ key: "COREPACK_HOME", value: process.env.COREPACK_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 },
...ISOLATED_TEST_CREDENTIAL_ENV_KEYS.map((key) => ({ key, value: process.env[key] })),
...ISOLATED_TEST_SERVICE_ENV_KEYS.map((key) => ({ key, value: process.env[key] })),
{ key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS },
];
}
function initializeIsolatedTestEnv(tempHome: string): void {
// Corepack's installed toolchain is independent of application state. Bind its
// upstream cache default before isolating HOME/XDG so nested pnpm stays offline.
setTestEnvValue("COREPACK_HOME", resolveTestCorepackHome(process.env));
setTestEnvValue("HOME", tempHome);
setTestEnvValue("USERPROFILE", tempHome);
setTestEnvValue("OPENCLAW_TEST_HOME", tempHome);
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
setTestEnvValue("OPENCLAW_STRICT_FAST_REPLY_CONFIG", "1");
deleteTestEnvValue("OPENCLAW_ALLOW_SLOW_REPLY_TESTS");
// OPENCLAW_HOME takes precedence over HOME, so both must be isolated together.
deleteTestEnvValue("OPENCLAW_HOME");
// Ensure test runs never touch the developer's real config/state, even if they have overrides set.
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
// Derive all state, including SQLite, from this unique HOME so cleanup owns it.
// Leave the override unset so nested HOME scopes also isolate their state.
deleteTestEnvValue("OPENCLAW_STATE_DIR");
// Model status still honors the shipped legacy selector; isolate both agent-dir keys.
deleteTestEnvValue("OPENCLAW_AGENT_DIR");
deleteTestEnvValue("PI_CODING_AGENT_DIR");
// Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers).
deleteTestEnvValue("OPENCLAW_GATEWAY_PORT");
deleteTestEnvValue("OPENCLAW_BRIDGE_ENABLED");
deleteTestEnvValue("OPENCLAW_BRIDGE_HOST");
deleteTestEnvValue("OPENCLAW_BRIDGE_PORT");
deleteTestEnvValue("OPENCLAW_CANVAS_HOST_PORT");
// Ambient channel credentials can activate real plugins even with an isolated HOME.
for (const key of ISOLATED_TEST_CREDENTIAL_ENV_KEYS) {
deleteTestEnvValue(key);
}
// A test worker is not the parent Gateway's service. Retaining its identity
// selects in-band lifecycle guards and detached restart handoffs in fixtures.
for (const key of ISOLATED_TEST_SERVICE_ENV_KEYS) {
deleteTestEnvValue(key);
}
// Avoid leaking local dev tooling flags into tests (e.g. --inspect).
deleteTestEnvValue("NODE_OPTIONS");
// Windows: prefer the default state dir so auth/profile tests match real paths.
if (process.platform === "win32") {
setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempHome, ".openclaw"));
}
setTestEnvValue("XDG_CONFIG_HOME", path.join(tempHome, ".config"));
setTestEnvValue("XDG_DATA_HOME", path.join(tempHome, ".local", "share"));
setTestEnvValue("XDG_STATE_HOME", path.join(tempHome, ".local", "state"));
setTestEnvValue("XDG_CACHE_HOME", path.join(tempHome, ".cache"));
setTestEnvValue("XDG_RUNTIME_DIR", path.join(tempHome, ".runtime"));
}
function ensureParentDir(targetPath: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
}
function shouldStageLiveGeminiPath(sourceRoot: string, sourcePath: string): boolean {
const relativePath = path.relative(sourceRoot, sourcePath);
if (!relativePath || relativePath.startsWith("..")) {
return true;
}
const normalizedPath = relativePath.split(path.sep).join("/");
return !LIVE_GEMINI_EXCLUDED_PATHS.some(
(excludedPath) =>
normalizedPath === excludedPath || normalizedPath.startsWith(`${excludedPath}/`),
);
}
function copyDirIfExists(
sourcePath: string,
targetPath: string,
options?: { filter?: (sourcePath: string) => boolean },
): void {
if (!fs.existsSync(sourcePath)) {
return;
}
fs.mkdirSync(targetPath, { recursive: true });
fs.cpSync(sourcePath, targetPath, {
recursive: true,
force: true,
filter: options?.filter,
});
}
function copyFileIfExists(sourcePath: string, targetPath: string): void {
if (!fs.existsSync(sourcePath)) {
return;
}
let stat: fs.Stats;
try {
stat = fs.statSync(sourcePath);
} catch {
return;
}
if (!stat.isFile()) {
return;
}
ensureParentDir(targetPath);
fs.copyFileSync(sourcePath, targetPath);
}
function restoreClaudeConfigFromBackupIfNeeded(tempHome: string): void {
const targetPath = path.join(tempHome, ".claude.json");
if (fs.existsSync(targetPath)) {
return;
}
const backupsDir = path.join(tempHome, ".claude", "backups");
if (!fs.existsSync(backupsDir)) {
return;
}
const latestBackup = fs
.readdirSync(backupsDir)
.filter((entry) => entry.startsWith(".claude.json.backup."))
.toSorted()
.at(-1);
if (!latestBackup) {
return;
}
copyFileIfExists(path.join(backupsDir, latestBackup), targetPath);
}
function sanitizeLiveConfig(raw: string): string {
try {
const parsed: {
agents?: {
defaults?: Record<string, unknown>;
list?: Array<Record<string, unknown>>;
};
diagnostics?: Record<string, unknown>;
} = JSON5.parse(raw);
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;
});
}
if (!isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST_NORMALIZE_CONFIG)) {
return `${JSON.stringify(parsed, null, 2)}\n`;
}
const { applyLegacyDoctorMigrations } = loadLegacyConfigCompatApi();
const migrated = applyLegacyDoctorMigrations(parsed);
if (!migrated.next) {
return `${JSON.stringify(parsed, null, 2)}\n`;
}
const { validateConfigObjectWithPlugins } = loadConfigValidationApi();
const validated = validateConfigObjectWithPlugins(migrated.next);
return `${JSON.stringify(validated.ok ? validated.config : migrated.next, null, 2)}\n`;
} catch {
return raw;
}
}
function copyLiveAuthProfiles(realStateDir: string, tempStateDir: string): void {
const liveAuthStageScript = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"helpers",
"stage-live-auth-profiles.ts",
);
// Live workers need canonical SQLite auth without loading the database stack
// into every hermetic Vitest worker.
execFileSync(
process.execPath,
["--import", "tsx", liveAuthStageScript, realStateDir, tempStateDir],
{
// Resolve repo-owned imports independently of an external fixture's cwd.
cwd: path.resolve(path.dirname(liveAuthStageScript), "../.."),
env: { ...process.env, NODE_OPTIONS: undefined },
stdio: "pipe",
},
);
}
function stageLiveTestState(params: {
env: NodeJS.ProcessEnv;
realHome: string;
tempHome: string;
}): void {
const realOpenClawHome =
resolveEffectiveHomeDir(params.env, () => params.realHome) ?? params.realHome;
const rawStateDir = params.env.OPENCLAW_STATE_DIR?.trim();
let realStateDir = rawStateDir
? resolveHomeRelativePath(rawStateDir, realOpenClawHome)
: path.join(realOpenClawHome, ".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(realOpenClawHome, ".openclaw");
}
const tempStateDir = path.join(params.tempHome, ".openclaw");
fs.mkdirSync(tempStateDir, { recursive: true });
fs.mkdirSync(path.join(params.tempHome, ".gemini"), { recursive: true });
const realConfigPath = params.env.OPENCLAW_CONFIG_PATH?.trim()
? resolveHomeRelativePath(params.env.OPENCLAW_CONFIG_PATH, realOpenClawHome)
: 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"));
copyDirIfExists(
path.join(realStateDir, "external-plugins"),
path.join(tempStateDir, "external-plugins"),
);
copyLiveAuthProfiles(realStateDir, tempStateDir);
for (const authDir of LIVE_EXTERNAL_AUTH_DIRS) {
const sourcePath = path.join(params.realHome, authDir);
const filter =
authDir === ".gemini"
? (entryPath: string) => shouldStageLiveGeminiPath(sourcePath, entryPath)
: undefined;
copyDirIfExists(sourcePath, path.join(params.tempHome, authDir), { filter });
}
for (const authFile of LIVE_EXTERNAL_AUTH_FILES) {
copyFileIfExists(path.join(params.realHome, authFile), path.join(params.tempHome, authFile));
}
restoreClaudeConfigFromBackupIfNeeded(params.tempHome);
}
export function installTestEnv(options?: InstallTestEnvOptions): {
cleanup: () => void;
tempHome: string;
} {
const {
hermetic,
live,
allowRealHome,
loadProfileEnv: shouldLoadProfileEnv,
} = resolveTestHomePolicy(
process.env,
options?.mode,
options?.mode === "hermetic" ? false : options?.loadProfileEnv,
);
const sourceHome = live || shouldLoadProfileEnv ? readTestHomeSource(process.env) : undefined;
const realHome = sourceHome ?? process.env.HOME ?? os.homedir();
const liveEnvSnapshot = {
...process.env,
...(sourceHome === undefined ? {} : { HOME: sourceHome, USERPROFILE: sourceHome }),
};
const rollback = captureFullEnv();
let tempHome: string | undefined;
const removeHome = () => {
if (!tempHome) {
return;
}
try {
fs.rmSync(tempHome, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
};
try {
if (shouldLoadProfileEnv) {
loadProfileEnv(realHome);
}
if (live && allowRealHome) {
return { cleanup: () => {}, tempHome: realHome };
}
const restore = resolveRestoreEntries();
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-"));
initializeIsolatedTestEnv(tempHome);
if (hermetic) {
for (const key of HERMETIC_TEST_ENV_KEYS) {
deleteTestEnvValue(key);
}
// Keep non-isolated workers on this checkout's manifests, never a caller's
// staged plugin tree or a sibling worktree resolved through shared node_modules.
setTestEnvValue("OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR", "1");
setTestEnvValue(
"OPENCLAW_BUNDLED_PLUGINS_DIR",
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "extensions"),
);
} else if (live) {
stageLiveTestState({ env: liveEnvSnapshot, realHome, tempHome });
}
return {
tempHome,
cleanup: () => {
restoreEnv(restore);
removeHome();
},
};
} catch (error) {
// Successful live setup keeps profile additions; failed setup restores the caller exactly.
rollback.restore();
removeHome();
throw error;
}
}
export function withIsolatedTestHome(options?: InstallTestEnvOptions): {
cleanup: () => void;
tempHome: string;
} {
return installTestEnv(options);
}