diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75512053c82d..6539703cb8fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
+- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
diff --git a/docs/cli/update.md b/docs/cli/update.md
index 8a8c4b7f0f66..4783cd883c21 100644
--- a/docs/cli/update.md
+++ b/docs/cli/update.md
@@ -91,7 +91,8 @@ Options:
When you switch channels explicitly (`--channel ...`), OpenClaw also keeps the
install method aligned:
-- `dev` → ensures a git checkout (default: `~/openclaw`, override with `OPENCLAW_GIT_DIR`),
+- `dev` → ensures a git checkout (default: `~/openclaw`, or `$OPENCLAW_HOME/openclaw` when
+ `OPENCLAW_HOME` is set; override with `OPENCLAW_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable` → installs from npm using `latest`.
- `beta` → prefers npm dist-tag `beta`, but falls back to `latest` when beta is
diff --git a/docs/help/environment.md b/docs/help/environment.md
index 50dcd56e3cec..2158f4e7d6b0 100644
--- a/docs/help/environment.md
+++ b/docs/help/environment.md
@@ -138,12 +138,12 @@ shorthand values.
## Path-related env vars
-| Variable | Purpose |
-| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `OPENCLAW_HOME` | Override the home directory used for all internal path resolution (`~/.openclaw/`, agent dirs, sessions, credentials). Useful when running OpenClaw as a dedicated service user. |
-| `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). |
-| `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). |
-| `OPENCLAW_INCLUDE_ROOTS` | Path-list of directories where `$include` directives may resolve files outside the config directory (default: none — `$include` is confined to the config dir). Tilde-expanded. |
+| Variable | Purpose |
+| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `OPENCLAW_HOME` | Override the home directory used for internal OpenClaw path defaults (`~/.openclaw/`, agent dirs, sessions, credentials, installer onboarding, and the default dev checkout). Useful when running OpenClaw as a dedicated service user. |
+| `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). |
+| `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). |
+| `OPENCLAW_INCLUDE_ROOTS` | Path-list of directories where `$include` directives may resolve files outside the config directory (default: none — `$include` is confined to the config dir). Tilde-expanded. |
## Logging
@@ -157,7 +157,7 @@ shorthand values.
### `OPENCLAW_HOME`
-When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for all internal path resolution. This enables full filesystem isolation for headless service accounts.
+When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`.
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
@@ -173,6 +173,8 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
`OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using the same OS home fallback chain before use.
+Explicit path variables such as `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, and `OPENCLAW_GIT_DIR` still take precedence. OS-account tasks such as shell startup file detection, package-manager setup, and host `~` expansion may still use the real system home.
+
## nvm users: web_fetch TLS failures
If Node.js was installed via **nvm** (not the system package manager), the built-in `fetch()` uses
diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md
index 13237e5b1554..2415c2198713 100644
--- a/docs/install/development-channels.md
+++ b/docs/install/development-channels.md
@@ -40,7 +40,8 @@ install method:
- **`stable`** (git installs): checks out the latest stable git tag.
- **`beta`** (git installs): prefers the latest beta git tag, but falls back to
the latest stable git tag when beta is missing or older.
-- **`dev`**: ensures a git checkout (default `~/openclaw`, override with
+- **`dev`**: ensures a git checkout (default `~/openclaw`, or
+ `$OPENCLAW_HOME/openclaw` when `OPENCLAW_HOME` is set; override with
`OPENCLAW_GIT_DIR`), switches to `main`, rebases on upstream, builds, and
installs the global CLI from that checkout.
diff --git a/docs/install/installer.md b/docs/install/installer.md
index 96254dfaf734..8b85427c961a 100644
--- a/docs/install/installer.md
+++ b/docs/install/installer.md
@@ -154,19 +154,20 @@ The script exits with code `2` for invalid method selection or invalid `--instal
-| Variable | Description |
-| ------------------------------------------------- | --------------------------------------------- |
-| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
-| `OPENCLAW_VERSION=latest\|next\|\|` | npm version, dist-tag, or package spec |
-| `OPENCLAW_BETA=0\|1` | Use beta if available |
-| `OPENCLAW_GIT_DIR=` | Checkout directory |
-| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
-| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
-| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
-| `OPENCLAW_DRY_RUN=1` | Dry run mode |
-| `OPENCLAW_VERBOSE=1` | Debug mode |
-| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
-| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
+| Variable | Description |
+| ------------------------------------------------- | ------------------------------------------------------------------ |
+| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
+| `OPENCLAW_VERSION=latest\|next\|\|` | npm version, dist-tag, or package spec |
+| `OPENCLAW_BETA=0\|1` | Use beta if available |
+| `OPENCLAW_HOME=` | Base directory for OpenClaw state and default git/onboarding paths |
+| `OPENCLAW_GIT_DIR=` | Checkout directory |
+| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
+| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
+| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
+| `OPENCLAW_DRY_RUN=1` | Dry run mode |
+| `OPENCLAW_VERBOSE=1` | Debug mode |
+| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
+| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
@@ -256,17 +257,18 @@ by default, plus git-checkout installs under the same prefix flow.
-| Variable | Description |
-| ------------------------------------------- | --------------------------------------------- |
-| `OPENCLAW_PREFIX=` | Install prefix |
-| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
-| `OPENCLAW_VERSION=` | OpenClaw version or dist-tag |
-| `OPENCLAW_NODE_VERSION=` | Node version |
-| `OPENCLAW_GIT_DIR=` | Git checkout directory for git installs |
-| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates for existing checkouts |
-| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
-| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
-| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
+| Variable | Description |
+| ------------------------------------------- | ------------------------------------------------------------------ |
+| `OPENCLAW_PREFIX=` | Install prefix |
+| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
+| `OPENCLAW_VERSION=` | OpenClaw version or dist-tag |
+| `OPENCLAW_NODE_VERSION=` | Node version |
+| `OPENCLAW_HOME=` | Base directory for OpenClaw state and default git/onboarding paths |
+| `OPENCLAW_GIT_DIR=` | Git checkout directory for git installs |
+| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates for existing checkouts |
+| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
+| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
+| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh
index a16a6e80731a..e72766e0d8d2 100755
--- a/scripts/install-cli.sh
+++ b/scripts/install-cli.sh
@@ -29,13 +29,34 @@ ensure_home_env() {
ensure_home_env
+resolve_openclaw_effective_home() {
+ local openclaw_home="${OPENCLAW_HOME:-}"
+ if [[ -z "$openclaw_home" ]]; then
+ echo "$HOME"
+ return 0
+ fi
+
+ case "$openclaw_home" in
+ \~)
+ echo "$HOME"
+ ;;
+ \~/*)
+ echo "${HOME}/${openclaw_home#~/}"
+ ;;
+ *)
+ echo "$openclaw_home"
+ ;;
+ esac
+}
+
+OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-22.22.0}"
SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
-GIT_DIR="${OPENCLAW_GIT_DIR:-${HOME}/openclaw}"
+GIT_DIR="${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}"
GIT_UPDATE="${OPENCLAW_GIT_UPDATE:-1}"
JSON=0
RUN_ONBOARD=0
@@ -46,11 +67,11 @@ print_usage() {
cat < Install prefix (default: ~/.openclaw)
+ --prefix Install prefix (default: ~/.openclaw; use \$OPENCLAW_PREFIX to override)
--install-method, --method npm|git Install via npm (default) or from a git checkout
--npm Shortcut for --install-method npm
--git, --github Shortcut for --install-method git
- --git-dir, --dir Checkout directory (default: ~/openclaw)
+ --git-dir, --dir Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version OpenClaw version (default: latest)
--node-version Node version (default: 22.22.0)
--onboard Run "openclaw onboard" after install
@@ -61,6 +82,8 @@ Environment variables:
SHARP_IGNORE_GLOBAL_LIBVIPS=0|1 Default: 1 (avoid sharp building against global libvips)
OPENCLAW_NPM_LOGLEVEL=error|warn|notice Default: error (hide npm deprecation noise)
OPENCLAW_INSTALL_METHOD=git|npm
+ OPENCLAW_HOME=...
+ OPENCLAW_PREFIX=...
OPENCLAW_VERSION=latest|next|
OPENCLAW_GIT_DIR=...
OPENCLAW_GIT_UPDATE=0|1
@@ -100,7 +123,7 @@ download_file() {
}
cleanup_legacy_submodules() {
- local repo_dir="${1:-${OPENCLAW_GIT_DIR:-${HOME}/openclaw}}"
+ local repo_dir="${1:-${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}}"
local legacy_dir="${repo_dir}/Peekaboo"
if [[ -d "$legacy_dir" ]]; then
emit_json "{\"event\":\"step\",\"name\":\"legacy-submodule\",\"status\":\"start\",\"path\":\"${legacy_dir//\"/\\\"}\"}"
diff --git a/scripts/install.sh b/scripts/install.sh
index 4ddd69c97bbc..3d4f3a99b9d6 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -39,6 +39,23 @@ mktempfile() {
echo "$f"
}
+resolve_openclaw_effective_home() {
+ local openclaw_home="${OPENCLAW_HOME:-}"
+ if [[ -z "$openclaw_home" ]]; then
+ echo "$HOME"
+ return
+ fi
+ if [[ "$openclaw_home" == "~" ]]; then
+ echo "$HOME"
+ return
+ fi
+ if [[ "$openclaw_home" == \~/* ]]; then
+ echo "${HOME}${openclaw_home:1}"
+ return
+ fi
+ echo "$openclaw_home"
+}
+
DOWNLOADER=""
detect_downloader() {
if command -v curl &> /dev/null; then
@@ -1024,7 +1041,7 @@ DRY_RUN=${OPENCLAW_DRY_RUN:-0}
INSTALL_METHOD=${OPENCLAW_INSTALL_METHOD:-}
OPENCLAW_VERSION=${OPENCLAW_VERSION:-latest}
USE_BETA=${OPENCLAW_BETA:-0}
-GIT_DIR_DEFAULT="${HOME}/openclaw"
+GIT_DIR_DEFAULT="$(resolve_openclaw_effective_home)/openclaw"
GIT_DIR=${OPENCLAW_GIT_DIR:-$GIT_DIR_DEFAULT}
GIT_UPDATE=${OPENCLAW_GIT_UPDATE:-1}
SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}"
@@ -2388,6 +2405,7 @@ install_openclaw_from_git() {
ensure_pnpm_binary_for_scripts
if [[ ! -d "$repo_dir" ]]; then
+ mkdir -p "$(dirname "$repo_dir")"
run_quiet_step "Cloning OpenClaw" git clone "$repo_url" "$repo_dir"
fi
@@ -2585,10 +2603,12 @@ maybe_open_dashboard() {
resolve_workspace_dir() {
local profile="${OPENCLAW_PROFILE:-default}"
+ local effective_home
+ effective_home="$(resolve_openclaw_effective_home)"
if [[ "${profile}" != "default" ]]; then
- echo "${HOME}/.openclaw/workspace-${profile}"
+ echo "${effective_home}/.openclaw/workspace-${profile}"
else
- echo "${HOME}/.openclaw/workspace"
+ echo "${effective_home}/.openclaw/workspace"
fi
}
@@ -2597,10 +2617,19 @@ run_bootstrap_onboarding_if_needed() {
return
fi
- local config_path="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"
- if [[ -f "${config_path}" || -f "$HOME/.clawdbot/clawdbot.json" ]]; then
+ local effective_home
+ effective_home="$(resolve_openclaw_effective_home)"
+ local config_path="${OPENCLAW_CONFIG_PATH:-$effective_home/.openclaw/openclaw.json}"
+ local legacy_config_path="${HOME}/.openclaw/openclaw.json"
+ local legacy_clawdbot_path="${HOME}/.clawdbot/clawdbot.json"
+ if [[ -f "${config_path}" || -f "$effective_home/.clawdbot/clawdbot.json" ]]; then
return
fi
+ if [[ -z "${OPENCLAW_CONFIG_PATH:-}" && "${effective_home}" != "${HOME}" ]]; then
+ if [[ -f "$legacy_config_path" || -f "$legacy_clawdbot_path" ]]; then
+ return
+ fi
+ fi
local workspace
workspace="$(resolve_workspace_dir)"
@@ -3033,8 +3062,10 @@ main() {
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "Skipping onboard (requested); run ${user_claw} onboard later"
else
- local config_path="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"
- if [[ -f "${config_path}" || -f "$HOME/.clawdbot/clawdbot.json" ]]; then
+ local effective_home
+ effective_home="$(resolve_openclaw_effective_home)"
+ local config_path="${OPENCLAW_CONFIG_PATH:-$effective_home/.openclaw/openclaw.json}"
+ if [[ -f "${config_path}" || -f "$effective_home/.clawdbot/clawdbot.json" ]]; then
ui_info "Config already present; running doctor"
run_doctor
should_open_dashboard=true
diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts
index f65aac577039..4fc55affdcf8 100644
--- a/src/cli/update-cli.test.ts
+++ b/src/cli/update-cli.test.ts
@@ -310,7 +310,7 @@ const { defaultRuntime } = await import("../runtime.js");
const { updateCommand, updateFinalizeCommand, updateStatusCommand, updateWizardCommand } =
await import("./update-cli.js");
const updateCliShared = await import("./update-cli/shared.js");
-const { resolveGitInstallDir } = updateCliShared;
+const { ensureGitCheckout, resolveGitInstallDir } = updateCliShared;
const { spawnSync } = await import("node:child_process");
function requireValue(value: T | undefined, label: string): T {
@@ -5486,9 +5486,57 @@ describe("update-cli", () => {
it("uses ~/openclaw as the default dev checkout directory", async () => {
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
- await withEnvAsync({ OPENCLAW_GIT_DIR: undefined }, async () => {
- expect(resolveGitInstallDir()).toBe(path.posix.join("/tmp/oc-home", "openclaw"));
+ try {
+ await withEnvAsync(
+ {
+ HOME: undefined,
+ OPENCLAW_GIT_DIR: undefined,
+ OPENCLAW_HOME: undefined,
+ USERPROFILE: undefined,
+ },
+ async () => {
+ expect(resolveGitInstallDir()).toBe(path.posix.join("/tmp/oc-home", "openclaw"));
+ },
+ );
+ } finally {
+ homedirSpy.mockRestore();
+ }
+ });
+
+ it("uses OPENCLAW_HOME for the default dev checkout directory", async () => {
+ const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
+ try {
+ await withEnvAsync(
+ { OPENCLAW_GIT_DIR: undefined, OPENCLAW_HOME: "/srv/openclaw-home" },
+ async () => {
+ expect(resolveGitInstallDir()).toBe(path.posix.join("/srv/openclaw-home", "openclaw"));
+ },
+ );
+ } finally {
+ homedirSpy.mockRestore();
+ }
+ });
+
+ it("creates the parent directory before cloning the default dev checkout", async () => {
+ const root = await createTrackedTempDir("openclaw-update-home-");
+ const home = path.join(root, "custom-openclaw-home");
+ const checkoutDir = path.join(home, "openclaw");
+
+ await withEnvAsync({ OPENCLAW_GIT_DIR: undefined, OPENCLAW_HOME: home }, async () => {
+ const dir = resolveGitInstallDir();
+ expect(dir).toBe(checkoutDir);
+ await ensureGitCheckout({ dir, timeoutMs: 1_000, env: process.env });
});
- homedirSpy.mockRestore();
+
+ expect((await fs.stat(home)).isDirectory()).toBe(true);
+ const cloneCall = vi
+ .mocked(runCommandWithTimeout)
+ .mock.calls.find((call) => call[0][0] === "git" && call[0][1] === "clone");
+ expect(cloneCall?.[0]).toEqual([
+ "git",
+ "clone",
+ "https://github.com/openclaw/openclaw.git",
+ checkoutDir,
+ ]);
});
});
diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts
index 2ac30f6b7c99..9536513595a1 100644
--- a/src/cli/update-cli/shared.ts
+++ b/src/cli/update-cli/shared.ts
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
+import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import { readPackageName, readPackageVersion } from "../../infra/package-json.js";
import { normalizePackageTagInput } from "../../infra/package-tag.js";
@@ -141,7 +142,7 @@ export function resolveGitInstallDir(): string {
}
function resolveDefaultGitDir(): string {
- const home = os.homedir();
+ const home = resolveRequiredHomeDir(process.env, os.homedir);
if (home.startsWith("/")) {
return path.posix.join(home, "openclaw");
}
@@ -221,6 +222,7 @@ export async function ensureGitCheckout(params: {
const gitEnv = params.env ?? (await createGlobalInstallEnv());
const dirExists = await pathExists(params.dir);
if (!dirExists) {
+ await fs.mkdir(path.dirname(params.dir), { recursive: true });
return await runUpdateStep({
name: "git clone",
argv: ["git", "clone", OPENCLAW_REPO_URL, params.dir],
diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts
index 7e3eb4c1dc6f..5bfff10a9a80 100644
--- a/test/scripts/install-cli.test.ts
+++ b/test/scripts/install-cli.test.ts
@@ -1,5 +1,7 @@
import { spawnSync } from "node:child_process";
-import { readFileSync } from "node:fs";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT_PATH = "scripts/install-cli.sh";
@@ -18,6 +20,38 @@ function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) {
describe("install-cli.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
+ it("keeps HOME for default prefix while OPENCLAW_HOME controls git checkout paths", () => {
+ const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-home-"));
+ const osHome = join(tmp, "os-home");
+ const openclawHome = join(tmp, "openclaw-home");
+ mkdirSync(osHome, { recursive: true });
+ mkdirSync(openclawHome, { recursive: true });
+
+ let result: ReturnType | undefined;
+ try {
+ result = runInstallCliShell(
+ [
+ `cd ${JSON.stringify(process.cwd())}`,
+ `source ${JSON.stringify(SCRIPT_PATH)}`,
+ 'printf "prefix=%s\\ngit=%s\\n" "$PREFIX" "$GIT_DIR"',
+ ].join("\n"),
+ {
+ HOME: osHome,
+ OPENCLAW_HOME: openclawHome,
+ OPENCLAW_GIT_DIR: undefined,
+ OPENCLAW_PREFIX: undefined,
+ },
+ );
+ } finally {
+ rmSync(tmp, { force: true, recursive: true });
+ }
+
+ expect(result?.status).toBe(0);
+ const output = result?.stdout ?? "";
+ expect(output).toContain(`prefix=${join(osHome, ".openclaw")}`);
+ expect(output).toContain(`git=${join(openclawHome, "openclaw")}`);
+ });
+
it("resolves requested git install versions to checkout refs", () => {
const result = runInstallCliShell(`
set -euo pipefail
diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts
index 50d3eadf540c..a75c5d74cc44 100644
--- a/test/scripts/install-sh.test.ts
+++ b/test/scripts/install-sh.test.ts
@@ -41,6 +41,86 @@ describe("install.sh", () => {
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")');
});
+ it("uses OPENCLAW_HOME for git and onboarding defaults", () => {
+ const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-home-"));
+ const osHome = join(tmp, "os-home");
+ const openclawHome = join(tmp, "openclaw-home");
+ mkdirSync(osHome, { recursive: true });
+ mkdirSync(openclawHome, { recursive: true });
+
+ let result: ReturnType | undefined;
+ try {
+ result = runInstallShell(
+ [
+ `cd ${JSON.stringify(process.cwd())}`,
+ `source ${JSON.stringify(SCRIPT_PATH)}`,
+ 'printf "git=%s\\nworkspace=%s\\n" "$GIT_DIR" "$(resolve_workspace_dir)"',
+ "OPENCLAW_PROFILE=work",
+ 'printf "workspaceProfile=%s\\n" "$(resolve_workspace_dir)"',
+ ].join("\n"),
+ {
+ HOME: osHome,
+ OPENCLAW_HOME: openclawHome,
+ OPENCLAW_GIT_DIR: undefined,
+ TERM: "dumb",
+ },
+ );
+ } finally {
+ rmSync(tmp, { force: true, recursive: true });
+ }
+
+ expect(result?.status).toBe(0);
+ const output = result?.stdout ?? "";
+ expect(output).toContain(`git=${join(openclawHome, "openclaw")}`);
+ expect(output).toContain(`workspace=${join(openclawHome, ".openclaw", "workspace")}`);
+ expect(output).toContain(
+ `workspaceProfile=${join(openclawHome, ".openclaw", "workspace-work")}`,
+ );
+ const mkdirParentIndex = script.indexOf('mkdir -p "$(dirname "$repo_dir")"');
+ const cloneIndex = script.indexOf(
+ 'run_quiet_step "Cloning OpenClaw" git clone "$repo_url" "$repo_dir"',
+ );
+ expect(mkdirParentIndex).toBeGreaterThan(-1);
+ expect(cloneIndex).toBeGreaterThan(-1);
+ expect(mkdirParentIndex).toBeLessThan(cloneIndex);
+ });
+
+ it("skips bootstrap onboarding when legacy HOME config exists with OPENCLAW_HOME", () => {
+ const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-legacy-config-"));
+ const osHome = join(tmp, "os-home");
+ const openclawHome = join(tmp, "openclaw-home");
+ const legacyConfigDir = join(osHome, ".openclaw");
+ const bootstrapDir = join(openclawHome, ".openclaw", "workspace");
+ mkdirSync(legacyConfigDir, { recursive: true });
+ mkdirSync(bootstrapDir, { recursive: true });
+ writeFileSync(join(legacyConfigDir, "openclaw.json"), "{}\n");
+ writeFileSync(join(bootstrapDir, "BOOTSTRAP.md"), "# bootstrap\n");
+
+ let result: ReturnType | undefined;
+ try {
+ result = runInstallShell(
+ [
+ `cd ${JSON.stringify(process.cwd())}`,
+ `source ${JSON.stringify(SCRIPT_PATH)}`,
+ "NO_ONBOARD=0",
+ "run_bootstrap_onboarding_if_needed",
+ ].join("\n"),
+ {
+ HOME: osHome,
+ OPENCLAW_HOME: openclawHome,
+ OPENCLAW_CONFIG_PATH: undefined,
+ TERM: "dumb",
+ },
+ );
+ } finally {
+ rmSync(tmp, { force: true, recursive: true });
+ }
+
+ expect(result?.status).toBe(0);
+ expect(result?.stdout ?? "").not.toContain("BOOTSTRAP.md found");
+ expect(result?.stderr ?? "").toBe("");
+ });
+
it("rejects OpenClaw GitHub source targets for npm installs", () => {
const result = runInstallShell(`
set -euo pipefail