fix: honor OPENCLAW_HOME defaults (#85802)

* fix: honor OPENCLAW_HOME defaults

* fix(install): preserve openclaw home upgrade defaults

* fix(install): satisfy shellcheck tilde patterns
This commit is contained in:
Gio Della-Libera
2026-05-23 20:39:59 -07:00
committed by GitHub
parent 2e8dee7f28
commit 82af6119fa
11 changed files with 275 additions and 50 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -154,19 +154,20 @@ The script exits with code `2` for invalid method selection or invalid `--instal
<Accordion title="Environment variables reference">
| Variable | Description |
| ------------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=latest\|next\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_GIT_DIR=<path>` | 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\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_HOME=<path>` | Base directory for OpenClaw state and default git/onboarding paths |
| `OPENCLAW_GIT_DIR=<path>` | 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`) |
</Accordion>
</AccordionGroup>
@@ -256,17 +257,18 @@ by default, plus git-checkout installs under the same prefix flow.
<Accordion title="Environment variables reference">
| Variable | Description |
| ------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_PREFIX=<path>` | Install prefix |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=<ver>` | OpenClaw version or dist-tag |
| `OPENCLAW_NODE_VERSION=<ver>` | Node version |
| `OPENCLAW_GIT_DIR=<path>` | 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=<path>` | Install prefix |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=<ver>` | OpenClaw version or dist-tag |
| `OPENCLAW_NODE_VERSION=<ver>` | Node version |
| `OPENCLAW_HOME=<path>` | Base directory for OpenClaw state and default git/onboarding paths |
| `OPENCLAW_GIT_DIR=<path>` | 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`) |
</Accordion>
</AccordionGroup>

View File

@@ -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 <<EOF
Usage: install-cli.sh [options]
--json Emit NDJSON events (no human output)
--prefix <path> Install prefix (default: ~/.openclaw)
--prefix <path> 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 <path> Checkout directory (default: ~/openclaw)
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version <ver> OpenClaw version (default: latest)
--node-version <ver> 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|<semver>
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//\"/\\\"}\"}"

View File

@@ -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

View File

@@ -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<T>(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,
]);
});
});

View File

@@ -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],

View File

@@ -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<typeof runInstallCliShell> | 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

View File

@@ -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<typeof runInstallShell> | 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<typeof runInstallShell> | 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