From be18b324f8e952523a0f94f8357b29e5c7eb5334 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 25 Sep 2026 23:48:46 -0500 Subject: [PATCH 01/13] Install OpenClaw as the self-updating copy under ~/.openclaw --- bin/omarchy-default-agent | 9 +- bin/omarchy-install-ai-openclaw | 6 +- bin/omarchy-install-openclaw-cli | 130 +++++++++++++++++++--- bin/omarchy-remove-ai-openclaw | 18 +++- manual/17-ai.md | 2 +- migrations/1790397381.sh | 12 +++ test/shell.d/default-agent-test.sh | 25 ++--- test/shell.d/openclaw-cli-test.sh | 168 +++++++++++++++++++++++++++++ test/shell.d/remove-ai-test.sh | 21 ++++ 9 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 migrations/1790397381.sh create mode 100755 test/shell.d/openclaw-cli-test.sh diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 4cf77f79..e844bd7b 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -50,10 +50,11 @@ esac agent_package=${agent_package:-$agent} -# Hermes and OpenClaw are not mise tools: Hermes is the desktop app's -# self-updating runtime and OpenClaw comes from its pacman package. Each has -# its own installer with the same --check/--now contract mise-backed agents -# get from mise; see omarchy-install-hermes-cli and omarchy-install-openclaw-cli. +# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in +# the user's home, Hermes the desktop app's and OpenClaw the one its Control +# UI updates. Each has its own installer with the same --check/--now contract +# mise-backed agents get from mise; see omarchy-install-hermes-cli and +# omarchy-install-openclaw-cli. if [[ -n ${agent_installer:-} ]]; then # Not omarchy-cmd-present: a command on PATH says nothing about whether the # install behind it finished or runs the sessions omarchy-agent starts. diff --git a/bin/omarchy-install-ai-openclaw b/bin/omarchy-install-ai-openclaw index cb79a9ad..978fab8c 100755 --- a/bin/omarchy-install-ai-openclaw +++ b/bin/omarchy-install-ai-openclaw @@ -5,8 +5,8 @@ set -e -echo "Installing OpenClaw..." -omarchy-pkg-add openclaw +# The same self-updating install the default agent gets. +omarchy-install-openclaw-cli --now # The desktop app is OpenClaw's Control UI: a web app served by its own # gateway. The launcher entry goes through omarchy-launch-openclaw, which @@ -14,7 +14,7 @@ omarchy-pkg-add openclaw # inside the package, so nothing is fetched here. echo "Installing the OpenClaw web app..." omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \ - /usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \ + /usr/share/openclaw/openclaw.png \ omarchy-launch-openclaw # A first install runs onboarding right here: launching the app instead would diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 18e12317..3a13c6c7 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -1,29 +1,127 @@ #!/bin/bash -# omarchy:summary=Ensure the OpenClaw CLI is installed for the default agent -# omarchy:args=[--check|--now] +# omarchy:summary=Install OpenClaw for the default agent as the self-updating copy under ~/.openclaw +# omarchy:args=<--check|--now> +# omarchy:examples=omarchy install openclaw cli --now | omarchy install openclaw cli --check # omarchy:requires-sudo=true -# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw -# installation on the machine — the CLI, the gateway, and the Install > AI web -# app all share it, and the fast ring keeps it current. The default-agent flow -# talks to that package through the same --check/--now contract mise-backed -# agents get from mise itself. +# There is one OpenClaw on a machine, and it is the one under ~/.openclaw that +# upstream's install-cli.sh makes: a private Node and the package in a prefix +# the user owns, which `openclaw update` and the Control UI's Update button +# replace in place. A copy in /usr belongs to pacman, and upstream's updater +# refuses to touch it. The openclaw package is therefore the seed rather than +# the runtime: the release Omarchy ships and the installer that came with it. +# The CLI, the gateway service and the Install > AI web app all run this copy. +# +# Each mode is named outright, like omarchy-install-hermes-cli: the default +# agent asks --check first and opens a terminal for --now only on a no. set -euo pipefail -case "${1:---now}" in +mode=${1:-} + +prefix="$HOME/.openclaw" +runtime_command="$prefix/bin/openclaw" +command_path="$HOME/.local/bin/openclaw" +seed=/usr/share/openclaw + +# Usable means the command answers, not that the file is there: upstream's +# wrapper execs the prefix's own Node, so a prefix missing either fails here. +runtime_runs() { + [[ -f $runtime_command && -x $runtime_command ]] && + timeout 30 "$runtime_command" --version >/dev/null 2>&1 +} + +# The name on PATH is a link to the runtime's command. A link already aimed +# there, even one left dangling by a removed runtime, is Omarchy's to rewrite; +# anything else at the path is the user's. +command_ours() { + [[ ! -e $command_path && ! -L $command_path ]] || [[ $(readlink -- "$command_path") == "$runtime_command" ]] +} + +# What omarchy-agent, the web app and a terminal run is whichever openclaw is +# first on PATH, and Omarchy puts /usr/bin and the mise shims ahead of +# ~/.local/bin, so the command has to resolve to the runtime from there. +on_path() { + local found + found=$(type -P openclaw) || return 1 + [[ $(realpath -m -- "$found") == "$(realpath -m -- "$runtime_command")" ]] +} + +installed() { + runtime_runs && on_path +} + +# A service the openclaw package's own copy installed runs from +# /usr/lib/node_modules/openclaw, which that package no longer ships. Installed +# again from the runtime, it runs code `openclaw update` can replace; upstream +# rewrites the unit and restarts it. A service running any other OpenClaw is +# the user's arrangement and stays. +rehome_services() { + local role unit + for role in gateway node; do + unit="$HOME/.config/systemd/user/openclaw-$role.service" + if [[ -f $unit ]] && grep -qF "/usr/lib/node_modules/openclaw/" "$unit"; then + echo "Moving the OpenClaw $role service to $prefix..." + if ! "$runtime_command" "$role" install --force; then + echo "Could not move the OpenClaw $role service. Finish with: openclaw $role install --force" >&2 + return 1 + fi + fi + done +} + +case "$mode" in --check) - omarchy-pkg-present openclaw - ;; ---now) - if ! omarchy-pkg-present openclaw; then - echo "Installing OpenClaw..." - omarchy-pkg-add openclaw - fi + if installed; then exit 0; else exit 1; fi ;; +--now) ;; *) - echo "Usage: omarchy-install-openclaw-cli [--check|--now]" >&2 + echo "Usage: omarchy-install-openclaw-cli <--check|--now>" >&2 exit 1 ;; esac + +if (( EUID == 0 )); then + echo "Run this command as your desktop user, without sudo." >&2 + exit 1 +fi + +if ! omarchy-pkg-present openclaw; then + echo "Installing OpenClaw..." + omarchy-pkg-add openclaw +fi + +# A runtime that already answers is never reinstalled: it may be past the +# packaged release by its own updates, and seeding would take it back. +if ! runtime_runs; then + if [[ ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ]]; then + echo "The installed OpenClaw package cannot set up a self-updating OpenClaw. Run 'omarchy update', then try again." >&2 + exit 1 + fi + + # Every choice the installer reads from the environment is named, so an + # OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it. + echo "Setting up OpenClaw in $prefix..." + bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard + if ! runtime_runs; then + echo "OpenClaw setup did not complete. Re-run this command after resolving the installer error." >&2 + exit 1 + fi +fi + +if command_ours; then + mkdir -p "${command_path%/*}" + ln -sfn "$runtime_command" "$command_path" +else + echo "$command_path is not the OpenClaw Omarchy set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2 + exit 1 +fi + +rehome_services + +if ! on_path; then + echo "$runtime_command is ready, but 'openclaw' on PATH is $(type -P openclaw || echo missing), which is what Omarchy runs." >&2 + echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2 + exit 1 +fi diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw index a5b15a23..f4502cd8 100755 --- a/bin/omarchy-remove-ai-openclaw +++ b/bin/omarchy-remove-ai-openclaw @@ -49,6 +49,19 @@ done omarchy-pkg-drop openclaw +# The runtime omarchy-install-openclaw-cli set up lives beside the user's state +# in ~/.openclaw: the command upstream's installer wrote, and the Node and +# package under tools/. Only a command that execs into those tools is the +# installer's; the link on PATH goes only when it points at that command. +if [[ -f $HOME/.openclaw/bin/openclaw ]] && grep -qF "\"$HOME/.openclaw/tools/" "$HOME/.openclaw/bin/openclaw"; then + rm -rf "$HOME/.openclaw/tools" + rm -f "$HOME/.openclaw/bin/openclaw" + rmdir "$HOME/.openclaw/bin" 2>/dev/null || true +fi +if [[ -L $HOME/.local/bin/openclaw && $(readlink -- "$HOME/.local/bin/openclaw") == "$HOME/.openclaw/bin/openclaw" ]]; then + rm -f "$HOME/.local/bin/openclaw" +fi + # The web app launcher and icon omarchy-install-ai-openclaw created. rm -f "$HOME/.local/share/applications/OpenClaw.desktop" rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png" @@ -59,12 +72,13 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true # better surprise. But it also holds the plugin runtimes and caches OpenClaw # downloads for itself, easily hundreds of megabytes, so the choice is put in # front of the user with the size rather than left silent. Without a terminal -# to ask in, keeping it is the answer. +# to ask in, keeping it is the answer. The snapshots `openclaw update` takes +# of that state sit beside it and go with it. state_removed=false if [[ -d $HOME/.openclaw && -t 0 ]]; then size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then - rm -rf "$HOME/.openclaw" + rm -rf "$HOME/.openclaw" "$HOME/.openclaw.update-captures" state_removed=true fi fi diff --git a/manual/17-ai.md b/manual/17-ai.md index 6739a612..70061b6f 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -54,7 +54,7 @@ The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT deskto Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ closes Hermes first, gateway included, takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. -OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. +OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw lives in `~/.openclaw` and updates itself: use the Control UI's "Update Gateway" button or run `openclaw update`, and the gateway restarts on the new version. Omarchy only chooses the release a fresh install starts from. Removing OpenClaw under _Remove > AI_ takes the gateway service, the app and OpenClaw itself with it and then asks whether the rest of `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. ### Local LLMs diff --git a/migrations/1790397381.sh b/migrations/1790397381.sh new file mode 100644 index 00000000..0103159c --- /dev/null +++ b/migrations/1790397381.sh @@ -0,0 +1,12 @@ +echo "Move OpenClaw to a self-updating install under ~/.openclaw" + +# The openclaw package used to be OpenClaw itself, installed under /usr where `openclaw update` and the Control UI's Update button cannot write. It is now the seed for a copy under ~/.openclaw that updates itself, and omarchy-install-openclaw-cli sets that copy up, points the command on PATH at it and moves a gateway service the old package installed over to it. Only machines with the package have an OpenClaw of Omarchy's to move. +omarchy-pkg-present openclaw || exit 0 + +# Updates install packages before migrations. An older package is still the runtime and has no seed, so this stays pending until it is updated. +if [[ ! -r /usr/share/openclaw/install-cli.sh || ! -r /usr/share/openclaw/openclaw.tgz ]]; then + echo "Update the openclaw package before rerunning this migration." >&2 + exit 1 +fi + +omarchy-install-openclaw-cli --now diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 60e630d3..1bd126a3 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -761,15 +761,16 @@ grep -F "missing is not installed" "$test_tmp/missing-output" >/dev/null || fail "agent launcher explains when the default command is missing" pass "agent launcher reports a missing default command" -# OpenClaw comes from its pacman package, not mise: choosing it must route +# OpenClaw is its own self-updating runtime, not mise's: choosing it must route # through omarchy-install-openclaw-cli and never touch a mise environment. -cat >"$mock_bin/omarchy-pkg-present" <<'SH' +# openclaw-cli-test.sh covers the installer itself. +cat >"$mock_bin/omarchy-install-openclaw-cli" <<'SH' #!/bin/bash -[[ $1 == openclaw && ${OMARCHY_TEST_OPENCLAW_INSTALLED:-false} == "true" ]] -SH -cat >"$mock_bin/omarchy-pkg-add" <<'SH' -#!/bin/bash -printf '%s\n' "pkg-add $*" >>"$OMARCHY_TEST_STUB_LOG" +if [[ $1 == "--check" ]]; then + [[ ${OMARCHY_TEST_OPENCLAW_INSTALLED:-false} == "true" ]] +else + printf '%s\n' "install-openclaw-cli $*" >>"$OMARCHY_TEST_STUB_LOG" +fi SH cat >"$mock_bin/omarchy-launch-openclaw" <<'SH' #!/bin/bash @@ -779,7 +780,7 @@ cat >"$mock_bin/openclaw" <<'SH' #!/bin/bash exit 0 SH -chmod +x "$mock_bin/omarchy-pkg-present" "$mock_bin/omarchy-pkg-add" \ +chmod +x "$mock_bin/omarchy-install-openclaw-cli" \ "$mock_bin/omarchy-launch-openclaw" "$mock_bin/openclaw" : >"$launch_log" @@ -793,7 +794,7 @@ mapfile -d '' -t launch_args <"$launch_log" fail "choosing OpenClaw launches its terminal UI" [[ ! -s $terminal_log ]] || fail "an installed OpenClaw needs no install terminal" ! grep -q 'use -g openclaw' "$mise_history" || fail "OpenClaw never installs through mise" -pass "choosing OpenClaw uses the package and launches its terminal UI" +pass "choosing OpenClaw uses its runtime and launches its terminal UI" : >"$terminal_log" OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent openclaw @@ -805,12 +806,12 @@ pass "a missing OpenClaw routes through the install terminal" : >"$stub_log" : >"$inline_log" OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent --install openclaw >/dev/null -grep -Fx "pkg-add openclaw" "$stub_log" >/dev/null || - fail "installing OpenClaw as default agent adds its package" +grep -Fx "install-openclaw-cli --now" "$stub_log" >/dev/null || + fail "installing OpenClaw as default agent sets up its runtime" mapfile -d '' -t inline_args <"$inline_log" [[ ${inline_args[*]} == "omarchy-launch-openclaw --tui" ]] || fail "installing OpenClaw as default agent hands over to its terminal UI" -pass "installing OpenClaw as default agent adds its package" +pass "installing OpenClaw as default agent sets up its runtime" : >"$launch_log" omarchy agent prompt "Review this project" diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh new file mode 100755 index 00000000..29654d76 --- /dev/null +++ b/test/shell.d/openclaw-cli-test.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +seed="$test_tmp/share" +events="$test_tmp/events" +mkdir -p "$mock_bin" "$seed" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == openclaw && -e $OMARCHY_TEST_ROOT/package-installed ]] +SH +cat >"$mock_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf 'pkg-add %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +touch "$OMARCHY_TEST_ROOT/package-installed" +SH +chmod +x "$mock_bin/"* + +# Stands in for upstream's install-cli.sh: it writes the command the way the +# real one does, execing into the prefix's tools, and that command logs what +# it is asked. OMARCHY_TEST_INSTALL_BROKEN leaves a command that cannot run. +cat >"$seed/install-cli.sh" <<'SH' +printf 'install-cli %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +prefix=$HOME/.openclaw +mkdir -p "$prefix/bin" "$prefix/tools/node-v24.19.0" +cat >"$prefix/bin/openclaw" <>"$OMARCHY_TEST_ROOT/events" +exec true "$prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "\$@" +EOF +chmod 755 "$prefix/bin/openclaw" +SH +touch "$seed/openclaw.tgz" + +# Scratch copies of the actual scripts, with only the package's path swapped. +for script in bin/omarchy-install-openclaw-cli migrations/1790397381.sh; do + sed "s|/usr/share/openclaw|$seed|g" "$ROOT/$script" >"$mock_bin/${script##*/}" +done +mv "$mock_bin/1790397381.sh" "$test_tmp/migration.sh" +chmod +x "$mock_bin/omarchy-install-openclaw-cli" + +new_home() { + test_home="$test_tmp/$1" + runtime="$test_home/.openclaw/bin/openclaw" + command="$test_home/.local/bin/openclaw" + mkdir -p "$test_home/.local/bin" + rm -f "$test_tmp/package-installed" + : >"$events" +} + +# PATH puts a directory ahead of ~/.local/bin the way Omarchy's does, where a +# test can drop another openclaw. +mkdir -p "$test_tmp/usr-bin" +run() { + HOME="$test_home" OMARCHY_TEST_ROOT="$test_tmp" PATH="$test_tmp/usr-bin:$mock_bin:$test_home/.local/bin:$PATH" \ + "$@" >"$test_tmp/output" 2>&1 +} + +new_home usage +run omarchy-install-openclaw-cli && fail "no mode is a usage error" +[[ ! -s $events ]] || fail "no mode installs nothing" "$(cat "$events")" +pass "every mode is named outright" + +new_home fresh +run omarchy-install-openclaw-cli --check && fail "--check calls a machine without OpenClaw installed" +run omarchy-install-openclaw-cli --now || fail "--now sets OpenClaw up" "$(cat "$test_tmp/output")" +grep -Fxq "pkg-add openclaw" "$events" || fail "--now installs the package" "$(cat "$events")" +grep -Fxq "install-cli --install-method npm --prefix $test_home/.openclaw --version $seed/openclaw.tgz --no-onboard" "$events" || + fail "--now seeds the runtime from the packaged release" "$(cat "$events")" +[[ -L $command && $(readlink -- "$command") == "$runtime" ]] || fail "--now points the command on PATH at the runtime" +run omarchy-install-openclaw-cli --check || fail "--check follows a finished install" +pass "--now seeds a self-updating OpenClaw from the packaged release" + +: >"$events" +run omarchy-install-openclaw-cli --now || fail "--now accepts a finished install" "$(cat "$test_tmp/output")" +! grep -q '^install-cli\|^pkg-add' "$events" || fail "a runtime that answers is never reseeded" "$(cat "$events")" +pass "a runtime that answers is never reseeded, whatever version its updates reached" + +rm -f "$command" +ln -s "$runtime" "$command.tmp" && mv "$command.tmp" "$command" +mv "$test_home/.openclaw" "$test_home/.openclaw.gone" +run omarchy-install-openclaw-cli --check && fail "--check calls a dangling link installed" +run omarchy-install-openclaw-cli --now || fail "--now reseeds behind its own dangling link" "$(cat "$test_tmp/output")" +[[ -x $runtime && $(readlink -- "$command") == "$runtime" ]] || fail "--now reseeds behind its own dangling link" +pass "a link Omarchy left behind is rewritten, and a missing runtime reseeded" + +new_home old-package +touch "$test_tmp/package-installed" +mv "$seed" "$seed.old" +run omarchy-install-openclaw-cli --now && fail "a package with no seed cannot set OpenClaw up" +grep -q "Run 'omarchy update'" "$test_tmp/output" || fail "a package with no seed says to update" "$(cat "$test_tmp/output")" +[[ ! -e $test_home/.openclaw && ! -e $command ]] || fail "a package with no seed leaves the home untouched" +mv "$seed.old" "$seed" +pass "a package that is still the runtime is refused before anything is touched" + +new_home broken +OMARCHY_TEST_INSTALL_BROKEN=1 run omarchy-install-openclaw-cli --now && fail "a runtime that does not run is not a finished install" +grep -q "did not complete" "$test_tmp/output" || fail "a failed setup says so" "$(cat "$test_tmp/output")" +[[ ! -e $command ]] || fail "a failed setup puts nothing on PATH" +pass "a runtime that does not run fails the install" + +new_home foreign +printf '#!/bin/bash\necho mine\n' >"$command" +chmod +x "$command" +run omarchy-install-openclaw-cli --now && fail "a command that is the user's is not replaced" +grep -q "Move it aside" "$test_tmp/output" || fail "a command that is the user's is named" "$(cat "$test_tmp/output")" +[[ ! -L $command ]] && grep -q mine "$command" || fail "a command that is the user's is kept" +run omarchy-install-openclaw-cli --check && fail "--check calls the runtime installed while the command is somebody else's" +pass "a command at the path that is the user's is kept and named" + +new_home shadowed +printf '#!/bin/bash\n' >"$test_tmp/usr-bin/openclaw" +chmod +x "$test_tmp/usr-bin/openclaw" +run omarchy-install-openclaw-cli --now && fail "an openclaw earlier on PATH fails the install" +grep -q "on PATH is $test_tmp/usr-bin/openclaw" "$test_tmp/output" || fail "an openclaw earlier on PATH is named" "$(cat "$test_tmp/output")" +run omarchy-install-openclaw-cli --check && fail "--check calls a shadowed runtime installed" +rm "$test_tmp/usr-bin/openclaw" +run omarchy-install-openclaw-cli --check || fail "--check follows once nothing shadows the runtime" +pass "the runtime has to be the openclaw PATH finds" + +# A gateway the old package installed runs from /usr/lib/node_modules, which +# the seed package no longer ships; one running any other OpenClaw stays. +new_home services +units="$test_home/.config/systemd/user" +mkdir -p "$units" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$units/openclaw-gateway.service" +printf 'ExecStart=/opt/node /home/someone/openclaw/dist/index.js node run\n' >"$units/openclaw-node.service" +run omarchy-install-openclaw-cli --now || fail "--now moves the old package's services" "$(cat "$test_tmp/output")" +grep -Fxq "runtime gateway install --force" "$events" || fail "--now moves a gateway the old package installed" "$(cat "$events")" +! grep -q "runtime node install" "$events" || fail "--now leaves a service running another OpenClaw alone" "$(cat "$events")" +pass "a service the old package installed moves to the runtime, and only that one" + +# The migration moves only machines that have the package, and waits for the +# package that seeds. +new_home migration-none +run bash -euo pipefail "$test_tmp/migration.sh" || fail "a machine without OpenClaw has nothing to move" "$(cat "$test_tmp/output")" +[[ ! -s $events && ! -e $test_home/.openclaw ]] || fail "a machine without OpenClaw is untouched" "$(cat "$events")" + +new_home migration-old +touch "$test_tmp/package-installed" +mv "$seed" "$seed.old" +run bash -euo pipefail "$test_tmp/migration.sh" && fail "an old package keeps the migration pending" +[[ ! -e $test_home/.openclaw ]] || fail "an old package leaves the home untouched" +mv "$seed.old" "$seed" + +new_home migration +touch "$test_tmp/package-installed" +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" +run bash -euo pipefail "$test_tmp/migration.sh" || fail "the migration moves OpenClaw" "$(cat "$test_tmp/output")" +grep -q "^install-cli " "$events" && grep -Fxq "runtime gateway install --force" "$events" || + fail "the migration seeds the runtime and moves the gateway to it" "$(cat "$events")" +[[ $(readlink -- "$command") == "$runtime" ]] || fail "the migration points the command at the runtime" + +new_home migration-foreign +touch "$test_tmp/package-installed" +printf '#!/bin/bash\n' >"$command" +run bash -euo pipefail "$test_tmp/migration.sh" && fail "a migration that cannot finish stays pending" +pass "the migration moves a packaged OpenClaw to its runtime, and stays pending until it can" diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index d81e6ca6..5778f373 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -295,6 +295,27 @@ pass "OpenClaw removal stops the gateway service" fail "OpenClaw removal keeps the user's agent state" "asked about ~/.openclaw without a terminal" pass "OpenClaw removal keeps the user's agent state" +# The runtime omarchy-install-openclaw-cli set up sits beside that state and +# goes; a command that is not the installer's, or a link elsewhere, stays. +fresh_openclaw_home +mkdir -p "$HOME/.openclaw/bin" "$HOME/.openclaw/tools/node-v24.19.0/lib" "$HOME/.local/bin" +printf '#!/usr/bin/env bash\nexec "%s/.openclaw/tools/node/bin/node" "%s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "$@"\n' "$HOME" "$HOME" >"$HOME/.openclaw/bin/openclaw" +ln -s "$HOME/.openclaw/bin/openclaw" "$HOME/.local/bin/openclaw" +"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null +for gone in .openclaw/tools .openclaw/bin .local/bin/openclaw; do + [[ ! -e $HOME/$gone && ! -L $HOME/$gone ]] || fail "OpenClaw removal deletes the runtime it set up" "$gone" +done +[[ -f $HOME/.openclaw/openclaw.json ]] || fail "OpenClaw removal deletes the runtime it set up" "state went with it" + +fresh_openclaw_home +mkdir -p "$HOME/.openclaw/bin" "$HOME/.openclaw/tools" "$HOME/.local/bin" +printf '#!/bin/bash\nexec /opt/openclaw/openclaw.mjs "$@"\n' >"$HOME/.openclaw/bin/openclaw" +ln -s /opt/openclaw/openclaw "$HOME/.local/bin/openclaw" +"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null +[[ -f $HOME/.openclaw/bin/openclaw && -d $HOME/.openclaw/tools && -L $HOME/.local/bin/openclaw ]] || + fail "OpenClaw removal deletes the runtime it set up" "someone else's install went with it" +pass "OpenClaw removal deletes the runtime it set up, and only that" + # A CLI that knows `gateway uninstall` owns the teardown; the manual systemd # fallback must not run. cat >"$tmp_dir/bin/openclaw" <<'SCRIPT' From 24d591da14ca5bbd2013450006f912bdbd502318 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 26 Sep 2026 00:18:45 -0500 Subject: [PATCH 02/13] Refuse before touching anything, and move an old gateway cleanly The first E2E run of the migration found that the new runtime cannot install its gateway service while the old package's gateway, still running from deleted files, holds the state directory, so that service is stopped first. Removal also takes the unit backups `openclaw update` leaves behind. Codex's review found the rest: upstream's installer takes over any loaded gateway service, so a gateway running another OpenClaw, a foreign command on PATH or an openclaw shadowing it are refused before anything is set up; root is refused before --check runs the user's wrapper; --check needs the package, since --now would otherwise run pacman where no terminal can ask for a password; and moving a service clears the selectors that would aim the install at another unit. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 72 ++++++++++++++++++++++++------- bin/omarchy-remove-ai-openclaw | 5 ++- test/shell.d/openclaw-cli-test.sh | 39 ++++++++++++++--- test/shell.d/remove-ai-test.sh | 4 ++ 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 3a13c6c7..e4469181 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -20,6 +20,12 @@ set -euo pipefail mode=${1:-} +# Everything here runs the user's own files, --check included. +if (( EUID == 0 )); then + echo "Run this command as your desktop user, without sudo." >&2 + exit 1 +fi + prefix="$HOME/.openclaw" runtime_command="$prefix/bin/openclaw" command_path="$HOME/.local/bin/openclaw" @@ -48,22 +54,47 @@ on_path() { [[ $(realpath -m -- "$found") == "$(realpath -m -- "$runtime_command")" ]] } +# Nothing yet, or the runtime's command. +path_clear() { + ! type -P openclaw >/dev/null || on_path +} + +# The package counts too: without it --now has a pacman step to take, and +# answering yes here would run that where no terminal can ask for a password. installed() { - runtime_runs && on_path + omarchy-pkg-present openclaw && runtime_runs && on_path +} + +# The service a unit runs, read from its ExecStart alone: other lines can name +# ~/.openclaw paths whichever OpenClaw the unit starts. +unit_runs() { + grep -E '^ExecStart=' "$1" | grep -qF -- "$2" +} + +# A gateway service already running some other OpenClaw. Upstream's installer +# rewrites a loaded gateway service to the copy it has just installed, so +# seeding beside one would take it over. +foreign_gateway() { + local unit="$HOME/.config/systemd/user/openclaw-gateway.service" + [[ -f $unit ]] && ! unit_runs "$unit" "$prefix/" && ! unit_runs "$unit" "/usr/lib/node_modules/openclaw/" } # A service the openclaw package's own copy installed runs from # /usr/lib/node_modules/openclaw, which that package no longer ships. Installed # again from the runtime, it runs code `openclaw update` can replace; upstream -# rewrites the unit and restarts it. A service running any other OpenClaw is -# the user's arrangement and stays. +# rewrites the unit and starts it. It is stopped first: still running the old +# code from deleted files, it holds the state directory the newer runtime has +# to migrate, and could not restart from those files anyway. A service running +# any other OpenClaw is the user's arrangement and stays. rehome_services() { local role unit for role in gateway node; do unit="$HOME/.config/systemd/user/openclaw-$role.service" - if [[ -f $unit ]] && grep -qF "/usr/lib/node_modules/openclaw/" "$unit"; then + if [[ -f $unit ]] && unit_runs "$unit" "/usr/lib/node_modules/openclaw/"; then echo "Moving the OpenClaw $role service to $prefix..." - if ! "$runtime_command" "$role" install --force; then + systemctl --user stop "openclaw-$role.service" 2>/dev/null || true + # The selectors would point the install at another unit and command. + if ! env -u OPENCLAW_PROFILE -u OPENCLAW_SYSTEMD_UNIT -u OPENCLAW_WRAPPER "$runtime_command" "$role" install --force; then echo "Could not move the OpenClaw $role service. Finish with: openclaw $role install --force" >&2 return 1 fi @@ -82,8 +113,8 @@ case "$mode" in ;; esac -if (( EUID == 0 )); then - echo "Run this command as your desktop user, without sudo." >&2 +if ! command_ours; then + echo "$command_path is not the OpenClaw Omarchy set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2 exit 1 fi @@ -94,12 +125,28 @@ fi # A runtime that already answers is never reinstalled: it may be past the # packaged release by its own updates, and seeding would take it back. +seeding=false if ! runtime_runs; then + seeding=true if [[ ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ]]; then echo "The installed OpenClaw package cannot set up a self-updating OpenClaw. Run 'omarchy update', then try again." >&2 exit 1 fi +fi +# Refused before anything in the home is touched. +if ! path_clear; then + echo "'openclaw' on PATH is $(type -P openclaw), which is what Omarchy would run instead of $runtime_command." >&2 + echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2 + exit 1 +fi +if [[ $seeding == "true" ]] && foreign_gateway; then + echo "The OpenClaw gateway service runs another OpenClaw, which setting one up in $prefix would take over." >&2 + echo "Remove that gateway with 'openclaw gateway uninstall', then run omarchy-install-openclaw-cli --now again." >&2 + exit 1 +fi + +if [[ $seeding == "true" ]]; then # Every choice the installer reads from the environment is named, so an # OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it. echo "Setting up OpenClaw in $prefix..." @@ -110,18 +157,13 @@ if ! runtime_runs; then fi fi -if command_ours; then - mkdir -p "${command_path%/*}" - ln -sfn "$runtime_command" "$command_path" -else - echo "$command_path is not the OpenClaw Omarchy set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2 - exit 1 -fi +mkdir -p "${command_path%/*}" +ln -sfn "$runtime_command" "$command_path" rehome_services if ! on_path; then echo "$runtime_command is ready, but 'openclaw' on PATH is $(type -P openclaw || echo missing), which is what Omarchy runs." >&2 - echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2 + echo "Put ~/.local/bin on PATH, then run omarchy-install-openclaw-cli --now again." >&2 exit 1 fi diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw index f4502cd8..7d793bdc 100755 --- a/bin/omarchy-remove-ai-openclaw +++ b/bin/omarchy-remove-ai-openclaw @@ -35,8 +35,9 @@ for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node. systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then # .bak is what `gateway install --force` leaves behind when it rewrites a - # unit, so it goes with the unit. - rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit" + # unit, and .reconcile-*.bak what `openclaw update` leaves when it + # refreshes one, so they go with the unit. + rm -f "$unit_file" "$unit_file.bak" "$unit_file".reconcile-*.bak "$unit_dir/default.target.wants/$unit" systemctl --user daemon-reload 2>/dev/null || true # A unit that had been failing stays listed as "not-found failed" after # its file is gone until its failed state is reset. diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index 29654d76..74ac24b6 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -21,6 +21,10 @@ cat >"$mock_bin/omarchy-pkg-add" <<'SH' printf 'pkg-add %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" touch "$OMARCHY_TEST_ROOT/package-installed" SH +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +SH chmod +x "$mock_bin/"* # Stands in for upstream's install-cli.sh: it writes the command the way the @@ -34,7 +38,7 @@ cat >"$prefix/bin/openclaw" <>"$OMARCHY_TEST_ROOT/events" +printf 'runtime %s%s\n' "\$*" "\${OPENCLAW_PROFILE:+ profile=\$OPENCLAW_PROFILE}" >>"$OMARCHY_TEST_ROOT/events" exec true "$prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "\$@" EOF chmod 755 "$prefix/bin/openclaw" @@ -122,10 +126,33 @@ printf '#!/bin/bash\n' >"$test_tmp/usr-bin/openclaw" chmod +x "$test_tmp/usr-bin/openclaw" run omarchy-install-openclaw-cli --now && fail "an openclaw earlier on PATH fails the install" grep -q "on PATH is $test_tmp/usr-bin/openclaw" "$test_tmp/output" || fail "an openclaw earlier on PATH is named" "$(cat "$test_tmp/output")" +[[ ! -e $test_home/.openclaw && ! -e $command ]] || fail "an openclaw earlier on PATH is refused before anything is touched" +rm "$test_tmp/usr-bin/openclaw" +run omarchy-install-openclaw-cli --now || fail "--now follows once nothing shadows the runtime" "$(cat "$test_tmp/output")" +printf '#!/bin/bash\n' >"$test_tmp/usr-bin/openclaw" +chmod +x "$test_tmp/usr-bin/openclaw" run omarchy-install-openclaw-cli --check && fail "--check calls a shadowed runtime installed" rm "$test_tmp/usr-bin/openclaw" run omarchy-install-openclaw-cli --check || fail "--check follows once nothing shadows the runtime" -pass "the runtime has to be the openclaw PATH finds" +pass "the runtime has to be the openclaw PATH finds, and anything else is refused before it is set up" + +# A runtime that answers without the package still leaves --now a pacman step, +# which the default agent must not run outside a terminal. +rm "$test_tmp/package-installed" +run omarchy-install-openclaw-cli --check && fail "--check calls a runtime without its package installed" +pass "--check needs the package too, so --now never has a password to ask for unseen" + +# Upstream's installer rewrites a loaded gateway service to the copy it has +# just made, so a gateway running another OpenClaw stops the seeding first. +new_home foreign-gateway +mkdir -p "$test_home/.config/systemd/user" +printf 'Environment=OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json\nExecStart=/opt/node %s/openclaw/dist/index.js gateway\n' "$test_home" "$test_home" \ + >"$test_home/.config/systemd/user/openclaw-gateway.service" +run omarchy-install-openclaw-cli --now && fail "a gateway running another OpenClaw stops the install" +grep -q "runs another OpenClaw" "$test_tmp/output" || fail "a gateway running another OpenClaw is named" "$(cat "$test_tmp/output")" +! grep -q '^install-cli' "$events" && [[ ! -e $test_home/.openclaw ]] || + fail "a gateway running another OpenClaw is refused before anything is set up" "$(cat "$events")" +pass "a gateway running another OpenClaw is refused before upstream's installer can take it over" # A gateway the old package installed runs from /usr/lib/node_modules, which # the seed package no longer ships; one running any other OpenClaw stays. @@ -134,9 +161,11 @@ units="$test_home/.config/systemd/user" mkdir -p "$units" printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$units/openclaw-gateway.service" printf 'ExecStart=/opt/node /home/someone/openclaw/dist/index.js node run\n' >"$units/openclaw-node.service" -run omarchy-install-openclaw-cli --now || fail "--now moves the old package's services" "$(cat "$test_tmp/output")" -grep -Fxq "runtime gateway install --force" "$events" || fail "--now moves a gateway the old package installed" "$(cat "$events")" -! grep -q "runtime node install" "$events" || fail "--now leaves a service running another OpenClaw alone" "$(cat "$events")" +OPENCLAW_PROFILE=work run omarchy-install-openclaw-cli --now || fail "--now moves the old package's services" "$(cat "$test_tmp/output")" +grep -A1 -Fx "systemctl --user stop openclaw-gateway.service" "$events" | grep -Fxq "runtime gateway install --force" || + fail "--now stops a gateway the old package installed, then moves it" "$(cat "$events")" +! grep -q "runtime node install\|openclaw-node" "$events" || fail "--now leaves a service running another OpenClaw alone" "$(cat "$events")" +! grep -q "install --force profile=work" "$events" || fail "--now moves the default unit whatever profile the shell selects" "$(cat "$events")" pass "a service the old package installed moves to the runtime, and only that one" # The migration moves only machines that have the package, and waits for the diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index 5778f373..693b929d 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -245,6 +245,8 @@ fresh_openclaw_home() { "$HOME/.local/share/applications" "$HOME/.local/share/icons/hicolor/256x256/apps" touch "$HOME/.config/systemd/user/openclaw-gateway.service" \ "$HOME/.config/systemd/user/openclaw-gateway.service.bak" \ + "$HOME/.config/systemd/user/openclaw-gateway.service.reconcile-0f1e.bak" \ + "$HOME/.config/systemd/user/openclaw-gateway.service.reconcile-0f1e.receipt.bak" \ "$HOME/.config/systemd/user/openclaw-node.service" \ "$HOME/.openclaw/openclaw.json" \ "$HOME/.local/share/applications/OpenClaw.desktop" \ @@ -269,6 +271,8 @@ fresh_openclaw_home for gone in .config/systemd/user/openclaw-gateway.service \ .config/systemd/user/openclaw-gateway.service.bak \ + .config/systemd/user/openclaw-gateway.service.reconcile-0f1e.bak \ + .config/systemd/user/openclaw-gateway.service.reconcile-0f1e.receipt.bak \ .config/systemd/user/default.target.wants/openclaw-gateway.service \ .config/systemd/user/openclaw-node.service \ .config/systemd/user/default.target.wants/openclaw-node.service \ From 44f5fd8f0e53380cdd360066208ce885b6113364 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 26 Sep 2026 01:06:36 -0500 Subject: [PATCH 03/13] Stop an old gateway before seeding, and refuse before installing Upstream's installer runs `gateway install --force` on any gateway it finds loaded, so the old package's gateway has to be stopped before seeding rather than after, and a stop that fails ends the run. The profile selectors are cleared for the whole command, installer included, and every refusal now comes before the package is installed. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 81 ++++++++++++++++++++----------- test/shell.d/openclaw-cli-test.sh | 20 ++++++-- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index e4469181..5b01eecf 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -26,6 +26,10 @@ if (( EUID == 0 )); then exit 1 fi +# These select another profile's unit and command, for upstream's installer +# and the CLI alike, and everything here is about the default ones. +unset OPENCLAW_PROFILE OPENCLAW_SYSTEMD_UNIT OPENCLAW_WRAPPER + prefix="$HOME/.openclaw" runtime_command="$prefix/bin/openclaw" command_path="$HOME/.local/bin/openclaw" @@ -80,24 +84,41 @@ foreign_gateway() { } # A service the openclaw package's own copy installed runs from -# /usr/lib/node_modules/openclaw, which that package no longer ships. Installed -# again from the runtime, it runs code `openclaw update` can replace; upstream -# rewrites the unit and starts it. It is stopped first: still running the old -# code from deleted files, it holds the state directory the newer runtime has -# to migrate, and could not restart from those files anyway. A service running -# any other OpenClaw is the user's arrangement and stays. -rehome_services() { +# /usr/lib/node_modules/openclaw, which that package no longer ships, and is +# moved to the runtime, where `openclaw update` can replace its code. A service +# running any other OpenClaw is the user's arrangement and stays. +legacy_units() { local role unit for role in gateway node; do unit="$HOME/.config/systemd/user/openclaw-$role.service" if [[ -f $unit ]] && unit_runs "$unit" "/usr/lib/node_modules/openclaw/"; then - echo "Moving the OpenClaw $role service to $prefix..." - systemctl --user stop "openclaw-$role.service" 2>/dev/null || true - # The selectors would point the install at another unit and command. - if ! env -u OPENCLAW_PROFILE -u OPENCLAW_SYSTEMD_UNIT -u OPENCLAW_WRAPPER "$runtime_command" "$role" install --force; then - echo "Could not move the OpenClaw $role service. Finish with: openclaw $role install --force" >&2 - return 1 - fi + echo "$role" + fi + done +} + +# Stopped before anything is installed: still running the old code from deleted +# files, such a service holds the state directory the newer runtime has to +# migrate, which upstream's installer does the moment it finds one loaded. +stop_legacy_services() { + local role + for role in $(legacy_units); do + if ! systemctl --user stop "openclaw-$role.service"; then + echo "Could not stop the OpenClaw $role service, so OpenClaw was not moved to $prefix." >&2 + return 1 + fi + done +} + +# Installed again from the runtime, upstream rewrites the unit and starts it, +# unless its installer already did. +rehome_services() { + local role + for role in $(legacy_units); do + echo "Moving the OpenClaw $role service to $prefix..." + if ! "$runtime_command" "$role" install --force; then + echo "Could not move the OpenClaw $role service. Finish with: openclaw $role install --force" >&2 + return 1 fi done } @@ -113,14 +134,16 @@ case "$mode" in ;; esac +# Refused before anything is installed or touched: the command's name, what +# PATH finds, and a gateway running another OpenClaw all have to be clear. if ! command_ours; then echo "$command_path is not the OpenClaw Omarchy set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2 exit 1 fi - -if ! omarchy-pkg-present openclaw; then - echo "Installing OpenClaw..." - omarchy-pkg-add openclaw +if ! path_clear; then + echo "'openclaw' on PATH is $(type -P openclaw), which is what Omarchy would run instead of $runtime_command." >&2 + echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2 + exit 1 fi # A runtime that already answers is never reinstalled: it may be past the @@ -128,17 +151,6 @@ fi seeding=false if ! runtime_runs; then seeding=true - if [[ ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ]]; then - echo "The installed OpenClaw package cannot set up a self-updating OpenClaw. Run 'omarchy update', then try again." >&2 - exit 1 - fi -fi - -# Refused before anything in the home is touched. -if ! path_clear; then - echo "'openclaw' on PATH is $(type -P openclaw), which is what Omarchy would run instead of $runtime_command." >&2 - echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2 - exit 1 fi if [[ $seeding == "true" ]] && foreign_gateway; then echo "The OpenClaw gateway service runs another OpenClaw, which setting one up in $prefix would take over." >&2 @@ -146,6 +158,17 @@ if [[ $seeding == "true" ]] && foreign_gateway; then exit 1 fi +if ! omarchy-pkg-present openclaw; then + echo "Installing OpenClaw..." + omarchy-pkg-add openclaw +fi +if [[ $seeding == "true" && ( ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ) ]]; then + echo "The installed OpenClaw package cannot set up a self-updating OpenClaw. Run 'omarchy update', then try again." >&2 + exit 1 +fi + +stop_legacy_services + if [[ $seeding == "true" ]]; then # Every choice the installer reads from the environment is named, so an # OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it. diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index 74ac24b6..21bdb17a 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -24,6 +24,7 @@ SH cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +[[ -z ${OMARCHY_TEST_STOP_FAIL:-} ]] SH chmod +x "$mock_bin/"* @@ -31,7 +32,7 @@ chmod +x "$mock_bin/"* # real one does, execing into the prefix's tools, and that command logs what # it is asked. OMARCHY_TEST_INSTALL_BROKEN leaves a command that cannot run. cat >"$seed/install-cli.sh" <<'SH' -printf 'install-cli %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +printf 'install-cli %s%s\n' "$*" "${OPENCLAW_PROFILE:+ profile=$OPENCLAW_PROFILE}" >>"$OMARCHY_TEST_ROOT/events" prefix=$HOME/.openclaw mkdir -p "$prefix/bin" "$prefix/tools/node-v24.19.0" cat >"$prefix/bin/openclaw" <"$units/openclaw-gateway.service" printf 'ExecStart=/opt/node /home/someone/openclaw/dist/index.js node run\n' >"$units/openclaw-node.service" OPENCLAW_PROFILE=work run omarchy-install-openclaw-cli --now || fail "--now moves the old package's services" "$(cat "$test_tmp/output")" -grep -A1 -Fx "systemctl --user stop openclaw-gateway.service" "$events" | grep -Fxq "runtime gateway install --force" || - fail "--now stops a gateway the old package installed, then moves it" "$(cat "$events")" +order=$(grep -n -e '^systemctl --user stop openclaw-gateway.service$' -e '^install-cli ' -e '^runtime gateway install --force$' "$events" | cut -d: -f2- | cut -c1-11) +[[ $order == $'systemctl -\ninstall-cli\nruntime gat' ]] || + fail "--now stops a gateway the old package installed before seeding, then moves it" "$(cat "$events")" ! grep -q "runtime node install\|openclaw-node" "$events" || fail "--now leaves a service running another OpenClaw alone" "$(cat "$events")" -! grep -q "install --force profile=work" "$events" || fail "--now moves the default unit whatever profile the shell selects" "$(cat "$events")" +! grep -q "profile=work" <(grep -v -e '^runtime --version' "$events") || + fail "--now seeds and moves the default unit whatever profile the shell selects" "$(cat "$events")" pass "a service the old package installed moves to the runtime, and only that one" +new_home stop-fails +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" +OMARCHY_TEST_STOP_FAIL=1 run omarchy-install-openclaw-cli --now && fail "a gateway that will not stop stops the install" +grep -q "Could not stop the OpenClaw gateway service" "$test_tmp/output" || fail "a gateway that will not stop is named" "$(cat "$test_tmp/output")" +! grep -q '^install-cli' "$events" && [[ ! -e $test_home/.openclaw ]] || + fail "a gateway that will not stop leaves the runtime unseeded" "$(cat "$events")" +pass "a gateway that will not stop stops the install before anything is seeded" + # The migration moves only machines that have the package, and waits for the # package that seeds. new_home migration-none From d2305add6a0ff88fe129cb473548f4fa3eceb459 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 26 Sep 2026 01:16:26 -0500 Subject: [PATCH 04/13] Keep the OpenClaw that can open the state when removal keeps the state Removing the runtime while keeping ~/.openclaw left state that its own updates had migrated past the packaged release, and reinstalling seeded a release that refused to open it; even `openclaw update` refused. Removal now takes openclaw off PATH and leaves the runtime inside ~/.openclaw with the state, where a reinstall picks it back up; deleting ~/.openclaw still takes both. --- bin/omarchy-remove-ai-openclaw | 15 +++++---------- manual/17-ai.md | 2 +- test/shell.d/remove-ai-test.sh | 21 +++++++++------------ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw index 7d793bdc..2b9ea1a4 100755 --- a/bin/omarchy-remove-ai-openclaw +++ b/bin/omarchy-remove-ai-openclaw @@ -50,15 +50,10 @@ done omarchy-pkg-drop openclaw -# The runtime omarchy-install-openclaw-cli set up lives beside the user's state -# in ~/.openclaw: the command upstream's installer wrote, and the Node and -# package under tools/. Only a command that execs into those tools is the -# installer's; the link on PATH goes only when it points at that command. -if [[ -f $HOME/.openclaw/bin/openclaw ]] && grep -qF "\"$HOME/.openclaw/tools/" "$HOME/.openclaw/bin/openclaw"; then - rm -rf "$HOME/.openclaw/tools" - rm -f "$HOME/.openclaw/bin/openclaw" - rmdir "$HOME/.openclaw/bin" 2>/dev/null || true -fi +# The command on PATH goes, so nothing answers to openclaw. The OpenClaw it +# pointed at stays in ~/.openclaw with the state below: its own updates may +# have migrated that state past the packaged release, which could not open it +# again, and a reinstall picks the kept copy back up instead of seeding one. if [[ -L $HOME/.local/bin/openclaw && $(readlink -- "$HOME/.local/bin/openclaw") == "$HOME/.openclaw/bin/openclaw" ]]; then rm -f "$HOME/.local/bin/openclaw" fi @@ -78,7 +73,7 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true state_removed=false if [[ -d $HOME/.openclaw && -t 0 ]]; then size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) - if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then + if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, downloaded plugins, and OpenClaw itself)?"; then rm -rf "$HOME/.openclaw" "$HOME/.openclaw.update-captures" state_removed=true fi diff --git a/manual/17-ai.md b/manual/17-ai.md index 70061b6f..1329a55f 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -54,7 +54,7 @@ The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT deskto Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ closes Hermes first, gateway included, takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. -OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw lives in `~/.openclaw` and updates itself: use the Control UI's "Update Gateway" button or run `openclaw update`, and the gateway restarts on the new version. Omarchy only chooses the release a fresh install starts from. Removing OpenClaw under _Remove > AI_ takes the gateway service, the app and OpenClaw itself with it and then asks whether the rest of `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. +OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw lives in `~/.openclaw` and updates itself: use the Control UI's "Update Gateway" button or run `openclaw update`, and the gateway restarts on the new version. Omarchy only chooses the release a fresh install starts from. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside OpenClaw itself and the plugin runtimes it downloads; the default keeps it, and installing OpenClaw again picks it back up. ### Local LLMs diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index 693b929d..03887c53 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -299,26 +299,23 @@ pass "OpenClaw removal stops the gateway service" fail "OpenClaw removal keeps the user's agent state" "asked about ~/.openclaw without a terminal" pass "OpenClaw removal keeps the user's agent state" -# The runtime omarchy-install-openclaw-cli set up sits beside that state and -# goes; a command that is not the installer's, or a link elsewhere, stays. +# The command on PATH goes; the OpenClaw it pointed at stays with the state, +# since only that copy is sure to open it. A link somewhere else is not Omarchy's. fresh_openclaw_home mkdir -p "$HOME/.openclaw/bin" "$HOME/.openclaw/tools/node-v24.19.0/lib" "$HOME/.local/bin" -printf '#!/usr/bin/env bash\nexec "%s/.openclaw/tools/node/bin/node" "%s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "$@"\n' "$HOME" "$HOME" >"$HOME/.openclaw/bin/openclaw" +printf '#!/usr/bin/env bash\n' >"$HOME/.openclaw/bin/openclaw" ln -s "$HOME/.openclaw/bin/openclaw" "$HOME/.local/bin/openclaw" "$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null -for gone in .openclaw/tools .openclaw/bin .local/bin/openclaw; do - [[ ! -e $HOME/$gone && ! -L $HOME/$gone ]] || fail "OpenClaw removal deletes the runtime it set up" "$gone" -done -[[ -f $HOME/.openclaw/openclaw.json ]] || fail "OpenClaw removal deletes the runtime it set up" "state went with it" +[[ ! -e $HOME/.local/bin/openclaw && ! -L $HOME/.local/bin/openclaw ]] || fail "OpenClaw removal takes openclaw off PATH" +[[ -f $HOME/.openclaw/bin/openclaw && -d $HOME/.openclaw/tools && -f $HOME/.openclaw/openclaw.json ]] || + fail "OpenClaw removal keeps the OpenClaw that can open the state" fresh_openclaw_home -mkdir -p "$HOME/.openclaw/bin" "$HOME/.openclaw/tools" "$HOME/.local/bin" -printf '#!/bin/bash\nexec /opt/openclaw/openclaw.mjs "$@"\n' >"$HOME/.openclaw/bin/openclaw" +mkdir -p "$HOME/.local/bin" ln -s /opt/openclaw/openclaw "$HOME/.local/bin/openclaw" "$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null -[[ -f $HOME/.openclaw/bin/openclaw && -d $HOME/.openclaw/tools && -L $HOME/.local/bin/openclaw ]] || - fail "OpenClaw removal deletes the runtime it set up" "someone else's install went with it" -pass "OpenClaw removal deletes the runtime it set up, and only that" +[[ -L $HOME/.local/bin/openclaw ]] || fail "OpenClaw removal takes openclaw off PATH" "someone else's link went with it" +pass "OpenClaw removal takes openclaw off PATH and keeps the copy that can open the state" # A CLI that knows `gateway uninstall` owns the teardown; the manual systemd # fallback must not run. From 8d25d6aef3cc56df36f26fd5b979d1457aa20daf Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 26 Sep 2026 01:38:40 -0500 Subject: [PATCH 05/13] Require a moved OpenClaw service to be running again Upstream's installer rewrites a loaded gateway onto the new copy but only warns when it will not start, which let the install finish with no gateway running. A service that was running before the move now has to be running after it, or the install fails and says the service is stopped; a failed seed says so too. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 68 ++++++++++++++++++++++--------- test/shell.d/openclaw-cli-test.sh | 48 ++++++++++++++++++++-- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 5b01eecf..825de500 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -79,22 +79,18 @@ unit_runs() { # rewrites a loaded gateway service to the copy it has just installed, so # seeding beside one would take it over. foreign_gateway() { - local unit="$HOME/.config/systemd/user/openclaw-gateway.service" + local unit + unit=$(unit_path gateway) [[ -f $unit ]] && ! unit_runs "$unit" "$prefix/" && ! unit_runs "$unit" "/usr/lib/node_modules/openclaw/" } # A service the openclaw package's own copy installed runs from # /usr/lib/node_modules/openclaw, which that package no longer ships, and is # moved to the runtime, where `openclaw update` can replace its code. A service -# running any other OpenClaw is the user's arrangement and stays. -legacy_units() { - local role unit - for role in gateway node; do - unit="$HOME/.config/systemd/user/openclaw-$role.service" - if [[ -f $unit ]] && unit_runs "$unit" "/usr/lib/node_modules/openclaw/"; then - echo "$role" - fi - done +# running any other OpenClaw is the user's arrangement and stays. The --now +# steps below list these, and which were running, before anything changes. +unit_path() { + echo "$HOME/.config/systemd/user/openclaw-$1.service" } # Stopped before anything is installed: still running the old code from deleted @@ -102,7 +98,7 @@ legacy_units() { # migrate, which upstream's installer does the moment it finds one loaded. stop_legacy_services() { local role - for role in $(legacy_units); do + for role in "${moving[@]}"; do if ! systemctl --user stop "openclaw-$role.service"; then echo "Could not stop the OpenClaw $role service, so OpenClaw was not moved to $prefix." >&2 return 1 @@ -110,19 +106,36 @@ stop_legacy_services() { done } -# Installed again from the runtime, upstream rewrites the unit and starts it, -# unless its installer already did. +# Not moved yet: still on the old copy, or running before and not now. +unsettled() { + unit_runs "$(unit_path "$1")" "/usr/lib/node_modules/openclaw/" || + { [[ " ${running[*]} " == *" $1 "* ]] && ! systemctl --user is-active --quiet "openclaw-$1.service"; } +} + +# Upstream's installer rewrites a loaded gateway itself, but only warns when +# the restart fails, so whatever is still unsettled is installed again from the +# runtime and then has to have settled. rehome_services() { local role - for role in $(legacy_units); do - echo "Moving the OpenClaw $role service to $prefix..." - if ! "$runtime_command" "$role" install --force; then - echo "Could not move the OpenClaw $role service. Finish with: openclaw $role install --force" >&2 + for role in "${moving[@]}"; do + if unsettled "$role"; then + echo "Moving the OpenClaw $role service to $prefix..." + "$runtime_command" "$role" install --force || true + fi + if unsettled "$role"; then + echo "Could not move the OpenClaw $role service to $prefix. Finish with: openclaw $role install --force" >&2 return 1 fi done } +# Said whenever a failure leaves a service stopped by this run. +stopped_note() { + if (( ${#running[@]} )); then + echo "The OpenClaw ${running[*]} service was stopped for this and is not running now." >&2 + fi +} + case "$mode" in --check) if installed; then exit 0; else exit 1; fi @@ -167,15 +180,29 @@ if [[ $seeding == "true" && ( ! -r $seed/install-cli.sh || ! -r $seed/openclaw.t exit 1 fi +# What the old package installed and what of it was running, read before +# anything changes. +moving=() +running=() +for role in gateway node; do + if [[ -f $(unit_path "$role") ]] && unit_runs "$(unit_path "$role")" "/usr/lib/node_modules/openclaw/"; then + moving+=("$role") + if systemctl --user is-active --quiet "openclaw-$role.service"; then + running+=("$role") + fi + fi +done + stop_legacy_services if [[ $seeding == "true" ]]; then # Every choice the installer reads from the environment is named, so an # OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it. echo "Setting up OpenClaw in $prefix..." - bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard + bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard || true if ! runtime_runs; then echo "OpenClaw setup did not complete. Re-run this command after resolving the installer error." >&2 + stopped_note exit 1 fi fi @@ -183,7 +210,10 @@ fi mkdir -p "${command_path%/*}" ln -sfn "$runtime_command" "$command_path" -rehome_services +if ! rehome_services; then + stopped_note + exit 1 +fi if ! on_path; then echo "$runtime_command is ready, but 'openclaw' on PATH is $(type -P openclaw || echo missing), which is what Omarchy runs." >&2 diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index 21bdb17a..1ffa279e 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -21,16 +21,27 @@ cat >"$mock_bin/omarchy-pkg-add" <<'SH' printf 'pkg-add %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" touch "$OMARCHY_TEST_ROOT/package-installed" SH +# The user manager, as far as these tests need one: a service is active while +# a marker says so. Stopping clears it; OMARCHY_TEST_STOP_FAIL refuses. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash -printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" -[[ -z ${OMARCHY_TEST_STOP_FAIL:-} ]] +case "$2" in + stop) + printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" + [[ -z ${OMARCHY_TEST_STOP_FAIL:-} ]] || exit 1 + rm -f "$HOME/active-$3" + ;; + is-active) [[ -e $HOME/active-$4 ]] ;; +esac SH chmod +x "$mock_bin/"* # Stands in for upstream's install-cli.sh: it writes the command the way the # real one does, execing into the prefix's tools, and that command logs what # it is asked. OMARCHY_TEST_INSTALL_BROKEN leaves a command that cannot run. +# Like upstream, ` install --force` rewrites the unit onto the runtime +# and starts it, OMARCHY_TEST_START_FAIL making the start fail, and the +# installer does that itself for a gateway it finds loaded. cat >"$seed/install-cli.sh" <<'SH' printf 'install-cli %s%s\n' "$*" "${OPENCLAW_PROFILE:+ profile=$OPENCLAW_PROFILE}" >>"$OMARCHY_TEST_ROOT/events" prefix=$HOME/.openclaw @@ -38,11 +49,19 @@ mkdir -p "$prefix/bin" "$prefix/tools/node-v24.19.0" cat >"$prefix/bin/openclaw" <>"$OMARCHY_TEST_ROOT/events" +if [[ \${2:-} == "install" ]]; then + printf 'ExecStart=$prefix/tools/node-v24.19.0/bin/node $prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js %s\n' "\$1" >"\$HOME/.config/systemd/user/openclaw-\$1.service" + [[ -z "\${OMARCHY_TEST_START_FAIL:-}" ]] || exit 1 + touch "\$HOME/active-openclaw-\$1.service" +fi exec true "$prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "\$@" EOF chmod 755 "$prefix/bin/openclaw" +if [[ -f $HOME/.config/systemd/user/openclaw-gateway.service ]]; then + "$prefix/bin/openclaw" gateway install --force || true +fi SH touch "$seed/openclaw.tgz" @@ -180,6 +199,29 @@ grep -q "Could not stop the OpenClaw gateway service" "$test_tmp/output" || fail fail "a gateway that will not stop leaves the runtime unseeded" "$(cat "$events")" pass "a gateway that will not stop stops the install before anything is seeded" +# Upstream's installer rewrites the gateway itself and only warns when it will +# not start again, so a gateway that was running has to be running afterwards. +new_home start-fails +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" +touch "$test_home/active-openclaw-gateway.service" +OMARCHY_TEST_START_FAIL=1 run omarchy-install-openclaw-cli --now && fail "a moved gateway that does not start fails the install" +grep -q "Could not move the OpenClaw gateway service" "$test_tmp/output" && grep -q "is not running now" "$test_tmp/output" || + fail "a moved gateway that does not start is named, and so is its being stopped" "$(cat "$test_tmp/output")" +pass "a gateway that was running is running again from the runtime, or the install fails saying it is stopped" + +# With the runtime already in place nothing is seeded, so the move is Omarchy's. +new_home runtime-first +run omarchy-install-openclaw-cli --now || fail "--now sets OpenClaw up" "$(cat "$test_tmp/output")" +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" +touch "$test_home/active-openclaw-gateway.service" +: >"$events" +run omarchy-install-openclaw-cli --now || fail "--now moves a gateway beside a runtime that already runs" "$(cat "$test_tmp/output")" +! grep -q '^install-cli' "$events" && grep -Fxq "runtime gateway install --force" "$events" && [[ -e $test_home/active-openclaw-gateway.service ]] || + fail "--now moves a gateway beside a runtime that already runs" "$(cat "$events")" +pass "a gateway beside a runtime that already runs is moved without seeding" + # The migration moves only machines that have the package, and waits for the # package that seeds. new_home migration-none From abf2f756f265ece1a9df476d142d3fcd4f6bcbc1 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 26 Sep 2026 01:48:45 -0500 Subject: [PATCH 06/13] Judge a service by its program, and name any service left stopped A unit is Omarchy's or the old package's by the program its ExecStart runs or the script it hands that program, not by a path appearing anywhere in the line, so a gateway that merely names ~/.openclaw in an argument is not taken over. The state directory and config overrides are cleared with the profile selectors, since Omarchy's OpenClaw is the default one. Any failure after a running service was stopped for the move names that service, a partial stop included. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 47 +++++++++++++++++++++---------- test/shell.d/openclaw-cli-test.sh | 20 ++++++++++--- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 825de500..10084c63 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -26,9 +26,10 @@ if (( EUID == 0 )); then exit 1 fi -# These select another profile's unit and command, for upstream's installer -# and the CLI alike, and everything here is about the default ones. -unset OPENCLAW_PROFILE OPENCLAW_SYSTEMD_UNIT OPENCLAW_WRAPPER +# Omarchy's OpenClaw is the default one, in ~/.openclaw with the default unit, +# whatever another profile, unit or state directory a shell selects; a service +# installed from here would otherwise carry that selection. +unset OPENCLAW_PROFILE OPENCLAW_SYSTEMD_UNIT OPENCLAW_WRAPPER OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH prefix="$HOME/.openclaw" runtime_command="$prefix/bin/openclaw" @@ -69,10 +70,22 @@ installed() { omarchy-pkg-present openclaw && runtime_runs && on_path } -# The service a unit runs, read from its ExecStart alone: other lines can name -# ~/.openclaw paths whichever OpenClaw the unit starts. +# Whether a unit's ExecStart runs a program from under a path: the program +# itself, or the script it is handed. Later arguments and other lines can name +# ~/.openclaw whichever OpenClaw the unit starts. unit_runs() { - grep -E '^ExecStart=' "$1" | grep -qF -- "$2" + local words word + read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)" + if [[ ${words[0]:-} == "$2"* ]]; then + return 0 + fi + for word in "${words[@]:1}"; do + if [[ $word != -* ]]; then + [[ $word == "$2"* ]] + return + fi + done + return 1 } # A gateway service already running some other OpenClaw. Upstream's installer @@ -103,6 +116,9 @@ stop_legacy_services() { echo "Could not stop the OpenClaw $role service, so OpenClaw was not moved to $prefix." >&2 return 1 fi + if [[ " ${running[*]} " == *" $role "* ]]; then + stopped+=("$role") + fi done } @@ -129,11 +145,14 @@ rehome_services() { done } -# Said whenever a failure leaves a service stopped by this run. +# Said on any failure that leaves a service this run stopped not running. stopped_note() { - if (( ${#running[@]} )); then - echo "The OpenClaw ${running[*]} service was stopped for this and is not running now." >&2 - fi + local role + for role in "${stopped[@]}"; do + if ! systemctl --user is-active --quiet "openclaw-$role.service"; then + echo "The OpenClaw $role service was stopped for this and is not running now." >&2 + fi + done } case "$mode" in @@ -184,6 +203,7 @@ fi # anything changes. moving=() running=() +stopped=() for role in gateway node; do if [[ -f $(unit_path "$role") ]] && unit_runs "$(unit_path "$role")" "/usr/lib/node_modules/openclaw/"; then moving+=("$role") @@ -193,6 +213,7 @@ for role in gateway node; do fi done +trap 'status=$?; (( status == 0 )) || stopped_note' EXIT stop_legacy_services if [[ $seeding == "true" ]]; then @@ -202,7 +223,6 @@ if [[ $seeding == "true" ]]; then bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard || true if ! runtime_runs; then echo "OpenClaw setup did not complete. Re-run this command after resolving the installer error." >&2 - stopped_note exit 1 fi fi @@ -210,10 +230,7 @@ fi mkdir -p "${command_path%/*}" ln -sfn "$runtime_command" "$command_path" -if ! rehome_services; then - stopped_note - exit 1 -fi +rehome_services if ! on_path; then echo "$runtime_command is ready, but 'openclaw' on PATH is $(type -P openclaw || echo missing), which is what Omarchy runs." >&2 diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index 1ffa279e..a06c7742 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -22,13 +22,14 @@ printf 'pkg-add %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" touch "$OMARCHY_TEST_ROOT/package-installed" SH # The user manager, as far as these tests need one: a service is active while -# a marker says so. Stopping clears it; OMARCHY_TEST_STOP_FAIL refuses. +# a marker says so. Stopping clears it, except for the unit named in +# OMARCHY_TEST_STOP_FAIL. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash case "$2" in stop) printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" - [[ -z ${OMARCHY_TEST_STOP_FAIL:-} ]] || exit 1 + [[ ${OMARCHY_TEST_STOP_FAIL:-} != "$3" ]] || exit 1 rm -f "$HOME/active-$3" ;; is-active) [[ -e $HOME/active-$4 ]] ;; @@ -166,7 +167,7 @@ pass "--check needs the package too, so --now never has a password to ask for un # just made, so a gateway running another OpenClaw stops the seeding first. new_home foreign-gateway mkdir -p "$test_home/.config/systemd/user" -printf 'Environment=OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json\nExecStart=/opt/node %s/openclaw/dist/index.js gateway\n' "$test_home" "$test_home" \ +printf 'Environment=OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json\nExecStart=/opt/node %s/openclaw/dist/index.js gateway --config %s/.openclaw/openclaw.json\n' "$test_home" "$test_home" "$test_home" \ >"$test_home/.config/systemd/user/openclaw-gateway.service" run omarchy-install-openclaw-cli --now && fail "a gateway running another OpenClaw stops the install" grep -q "runs another OpenClaw" "$test_tmp/output" || fail "a gateway running another OpenClaw is named" "$(cat "$test_tmp/output")" @@ -193,12 +194,23 @@ pass "a service the old package installed moves to the runtime, and only that on new_home stop-fails mkdir -p "$test_home/.config/systemd/user" printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" -OMARCHY_TEST_STOP_FAIL=1 run omarchy-install-openclaw-cli --now && fail "a gateway that will not stop stops the install" +OMARCHY_TEST_STOP_FAIL=openclaw-gateway.service run omarchy-install-openclaw-cli --now && fail "a gateway that will not stop stops the install" grep -q "Could not stop the OpenClaw gateway service" "$test_tmp/output" || fail "a gateway that will not stop is named" "$(cat "$test_tmp/output")" ! grep -q '^install-cli' "$events" && [[ ! -e $test_home/.openclaw ]] || fail "a gateway that will not stop leaves the runtime unseeded" "$(cat "$events")" pass "a gateway that will not stop stops the install before anything is seeded" +new_home stop-partial +mkdir -p "$test_home/.config/systemd/user" +for role in gateway node; do + printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js %s\n' "$role" >"$test_home/.config/systemd/user/openclaw-$role.service" + touch "$test_home/active-openclaw-$role.service" +done +OMARCHY_TEST_STOP_FAIL=openclaw-node.service run omarchy-install-openclaw-cli --now && fail "a node host that will not stop stops the install" +grep -q "gateway service was stopped for this and is not running now" "$test_tmp/output" || + fail "a gateway stopped before a later stop failed is named as stopped" "$(cat "$test_tmp/output")" +pass "a service this run stopped is named whenever the run then fails" + # Upstream's installer rewrites the gateway itself and only warns when it will # not start again, so a gateway that was running has to be running afterwards. new_home start-fails From 64e102aaf2386427a77c323fdabf80ce29566e55 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 27 Sep 2026 08:34:08 -0500 Subject: [PATCH 07/13] Keep OpenClaw's gateway probes off the wizard's terminal OpenClaw's CLI takes a terminal on stdin out of raw mode as it exits, and the onboarding watcher's `openclaw dashboard --json` probes inherited the wizard's terminal. Current releases write the config partway through the wizard, so the probes started while prompts remained, and every two seconds the terminal fell back to line mode: typing at the channel prompt echoed instead of selecting. The probes now read from /dev/null. --- bin/omarchy-openclaw-onboard | 7 +++++-- test/shell.d/openclaw-onboard-test.sh | 14 +++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-openclaw-onboard b/bin/omarchy-openclaw-onboard index 5bac15bd..e2195dc3 100755 --- a/bin/omarchy-openclaw-onboard +++ b/bin/omarchy-openclaw-onboard @@ -26,8 +26,11 @@ settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3} # setup; the prompts before that take as long as the user takes. gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180} +# Probes never get the terminal: OpenClaw's CLI takes a terminal on stdin out +# of raw mode as it exits, which would leave the wizard's prompts unable to +# read keys, and the wizard can write its config while prompts remain. gateway_answers() { - timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1 + timeout 10 openclaw dashboard --json /dev/null | jq -e '.ok == true' >/dev/null 2>&1 } # A gateway already answering means OpenClaw is set up, and this run must not @@ -81,7 +84,7 @@ config_applied() { gateway_ready() { config_applied || return 1 local json port listener main_pid - json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1 + json=$(timeout 10 openclaw dashboard --json /dev/null) || return 1 jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1 port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null) [[ -n $port ]] || return 1 diff --git a/test/shell.d/openclaw-onboard-test.sh b/test/shell.d/openclaw-onboard-test.sh index 852e3e3d..3057bfe7 100755 --- a/test/shell.d/openclaw-onboard-test.sh +++ b/test/shell.d/openclaw-onboard-test.sh @@ -55,6 +55,7 @@ onboard) while :; do sleep 0.2; done ;; dashboard) + printf 'dashboard-stdin:%s\n' "$(readlink /proc/$$/fd/0)" >>"$TEST_LOG" [[ -f $HOME/.openclaw/openclaw.json ]] && echo '{"ok":true,"port":18789}' || { echo '{"ok":false}'; exit 1; } ;; esac @@ -63,7 +64,10 @@ chmod +x "$tmp_dir/bin/openclaw" start=$SECONDS rc=0 -"$ROOT/bin/omarchy-openclaw-onboard" /dev/null 2>&1 || rc=$? +# A file stands in for the terminal the wizard reads, so where each probe's +# stdin points can be told apart from it. +: >"$tmp_dir/terminal" +"$ROOT/bin/omarchy-openclaw-onboard" <"$tmp_dir/terminal" >/dev/null 2>&1 || rc=$? elapsed=$((SECONDS - start)) grep -q '^openclaw:onboard --flow quickstart --install-daemon --skip-ui$' "$TEST_LOG" || @@ -76,6 +80,14 @@ grep -q '^terminated$' "$TEST_LOG" || (( elapsed < 30 )) || fail "a wizard that lingers after the gateway is up is stopped and counts as success" "took ${elapsed}s" pass "a wizard that lingers after the gateway is up is stopped and counts as success" +# OpenClaw's CLI takes a terminal on stdin out of raw mode as it exits, so a +# probe that inherited the wizard's terminal would leave its prompts unable to +# read keys. The wizard writes its config while prompts remain. +grep -q '^dashboard-stdin:' "$TEST_LOG" || fail "gateway probes never read from the wizard's terminal" "no probe ran" +! grep '^dashboard-stdin:' "$TEST_LOG" | grep -vqx 'dashboard-stdin:/dev/null' || + fail "gateway probes never read from the wizard's terminal" "$(grep '^dashboard-stdin:' "$TEST_LOG" | sort -u)" +pass "gateway probes never read from the wizard's terminal" + # The wizard only starts being stopped once the gateway actually answers: a # stub that never writes the config is left alone and must be ended by its own # exit, not the watcher. From b71b905548fd584dda0d23472f81bca4d7676d8d Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 27 Sep 2026 08:57:33 -0500 Subject: [PATCH 08/13] Revert "Keep OpenClaw's gateway probes off the wizard's terminal" The probes do not take the wizard's terminal out of raw mode: a raw pty stays raw across `openclaw dashboard --json`, and the channel search takes keys normally in foot and Ghostty with the probes running, with or without the change. The break seen at the channel prompt has another cause. --- bin/omarchy-openclaw-onboard | 7 ++----- test/shell.d/openclaw-onboard-test.sh | 14 +------------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/bin/omarchy-openclaw-onboard b/bin/omarchy-openclaw-onboard index e2195dc3..5bac15bd 100755 --- a/bin/omarchy-openclaw-onboard +++ b/bin/omarchy-openclaw-onboard @@ -26,11 +26,8 @@ settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3} # setup; the prompts before that take as long as the user takes. gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180} -# Probes never get the terminal: OpenClaw's CLI takes a terminal on stdin out -# of raw mode as it exits, which would leave the wizard's prompts unable to -# read keys, and the wizard can write its config while prompts remain. gateway_answers() { - timeout 10 openclaw dashboard --json /dev/null | jq -e '.ok == true' >/dev/null 2>&1 + timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1 } # A gateway already answering means OpenClaw is set up, and this run must not @@ -84,7 +81,7 @@ config_applied() { gateway_ready() { config_applied || return 1 local json port listener main_pid - json=$(timeout 10 openclaw dashboard --json /dev/null) || return 1 + json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1 jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1 port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null) [[ -n $port ]] || return 1 diff --git a/test/shell.d/openclaw-onboard-test.sh b/test/shell.d/openclaw-onboard-test.sh index 3057bfe7..852e3e3d 100755 --- a/test/shell.d/openclaw-onboard-test.sh +++ b/test/shell.d/openclaw-onboard-test.sh @@ -55,7 +55,6 @@ onboard) while :; do sleep 0.2; done ;; dashboard) - printf 'dashboard-stdin:%s\n' "$(readlink /proc/$$/fd/0)" >>"$TEST_LOG" [[ -f $HOME/.openclaw/openclaw.json ]] && echo '{"ok":true,"port":18789}' || { echo '{"ok":false}'; exit 1; } ;; esac @@ -64,10 +63,7 @@ chmod +x "$tmp_dir/bin/openclaw" start=$SECONDS rc=0 -# A file stands in for the terminal the wizard reads, so where each probe's -# stdin points can be told apart from it. -: >"$tmp_dir/terminal" -"$ROOT/bin/omarchy-openclaw-onboard" <"$tmp_dir/terminal" >/dev/null 2>&1 || rc=$? +"$ROOT/bin/omarchy-openclaw-onboard" /dev/null 2>&1 || rc=$? elapsed=$((SECONDS - start)) grep -q '^openclaw:onboard --flow quickstart --install-daemon --skip-ui$' "$TEST_LOG" || @@ -80,14 +76,6 @@ grep -q '^terminated$' "$TEST_LOG" || (( elapsed < 30 )) || fail "a wizard that lingers after the gateway is up is stopped and counts as success" "took ${elapsed}s" pass "a wizard that lingers after the gateway is up is stopped and counts as success" -# OpenClaw's CLI takes a terminal on stdin out of raw mode as it exits, so a -# probe that inherited the wizard's terminal would leave its prompts unable to -# read keys. The wizard writes its config while prompts remain. -grep -q '^dashboard-stdin:' "$TEST_LOG" || fail "gateway probes never read from the wizard's terminal" "no probe ran" -! grep '^dashboard-stdin:' "$TEST_LOG" | grep -vqx 'dashboard-stdin:/dev/null' || - fail "gateway probes never read from the wizard's terminal" "$(grep '^dashboard-stdin:' "$TEST_LOG" | sort -u)" -pass "gateway probes never read from the wizard's terminal" - # The wizard only starts being stopped once the gateway actually answers: a # stub that never writes the config is left alone and must be ended by its own # exit, not the watcher. From 8942b0cd4c249ff760872cb6e96361e84526fd23 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 28 Sep 2026 00:32:20 -0500 Subject: [PATCH 09/13] Keep a moved OpenClaw gateway on the runtime's own Node Since 2026.9.6 upstream's installer keeps the Node a gateway service already ran when it rewrites the unit, so moving a gateway the old package installed left it running the runtime's code on /usr/bin/node, from the nodejs package the old openclaw package pulled in and the seed no longer depends on. A moved service now counts as moved only once its program is under ~/.openclaw, and the reinstall pins the runtime's Node with --runtime-path, which gives the same unit a fresh install gets. --- bin/omarchy-install-openclaw-cli | 19 ++++++++++++++----- test/shell.d/openclaw-cli-test.sh | 25 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 10084c63..83948199 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -122,21 +122,30 @@ stop_legacy_services() { done } -# Not moved yet: still on the old copy, or running before and not now. +# Not moved yet: still on the old copy, run by a Node outside the runtime, or +# running before and not now. Upstream's installer keeps the Node a service +# already had, so a moved unit can run the runtime's code on the system Node +# the old package depended on, which this one no longer does. unsettled() { - unit_runs "$(unit_path "$1")" "/usr/lib/node_modules/openclaw/" || + local unit words + unit=$(unit_path "$1") + read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$unit" | head -1)" + unit_runs "$unit" "/usr/lib/node_modules/openclaw/" || [[ ${words[0]:-} != "$prefix/"* ]] || { [[ " ${running[*]} " == *" $1 "* ]] && ! systemctl --user is-active --quiet "openclaw-$1.service"; } } # Upstream's installer rewrites a loaded gateway itself, but only warns when # the restart fails, so whatever is still unsettled is installed again from the -# runtime and then has to have settled. +# runtime, on the runtime's own Node, and then has to have settled. rehome_services() { - local role + local role node pin=() + if node=$(realpath -e -- "$prefix/tools/node/bin/node" 2>/dev/null); then + pin=(--runtime-path "$node") + fi for role in "${moving[@]}"; do if unsettled "$role"; then echo "Moving the OpenClaw $role service to $prefix..." - "$runtime_command" "$role" install --force || true + "$runtime_command" "$role" install --force "${pin[@]}" || true fi if unsettled "$role"; then echo "Could not move the OpenClaw $role service to $prefix. Finish with: openclaw $role install --force" >&2 diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index a06c7742..d7e6625d 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -42,18 +42,29 @@ chmod +x "$mock_bin/"* # it is asked. OMARCHY_TEST_INSTALL_BROKEN leaves a command that cannot run. # Like upstream, ` install --force` rewrites the unit onto the runtime # and starts it, OMARCHY_TEST_START_FAIL making the start fail, and the -# installer does that itself for a gateway it finds loaded. +# installer does that itself for a gateway it finds loaded. As upstream does +# since 2026.9.6, a rewrite keeps the Node the unit already ran unless +# --runtime-path pins one. cat >"$seed/install-cli.sh" <<'SH' printf 'install-cli %s%s\n' "$*" "${OPENCLAW_PROFILE:+ profile=$OPENCLAW_PROFILE}" >>"$OMARCHY_TEST_ROOT/events" prefix=$HOME/.openclaw -mkdir -p "$prefix/bin" "$prefix/tools/node-v24.19.0" +mkdir -p "$prefix/bin" "$prefix/tools/node-v24.19.0/bin" +touch "$prefix/tools/node-v24.19.0/bin/node" +ln -sfn "$prefix/tools/node-v24.19.0" "$prefix/tools/node" cat >"$prefix/bin/openclaw" <>"$OMARCHY_TEST_ROOT/events" if [[ \${2:-} == "install" ]]; then - printf 'ExecStart=$prefix/tools/node-v24.19.0/bin/node $prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js %s\n' "\$1" >"\$HOME/.config/systemd/user/openclaw-\$1.service" + unit="\$HOME/.config/systemd/user/openclaw-\$1.service" + node=$prefix/tools/node-v24.19.0/bin/node + if [[ \${4:-} == "--runtime-path" ]]; then + node=\$5 + elif [[ -f \$unit ]]; then + node=\$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "\$unit") + fi + printf 'ExecStart=%s $prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js %s\n' "\$node" "\$1" >"\$unit" [[ -z "\${OMARCHY_TEST_START_FAIL:-}" ]] || exit 1 touch "\$HOME/active-openclaw-\$1.service" fi @@ -189,6 +200,10 @@ order=$(grep -n -e '^systemctl --user stop openclaw-gateway.service$' -e '^insta ! grep -q "runtime node install\|openclaw-node" "$events" || fail "--now leaves a service running another OpenClaw alone" "$(cat "$events")" ! grep -q "profile=work" <(grep -v -e '^runtime --version' "$events") || fail "--now seeds and moves the default unit whatever profile the shell selects" "$(cat "$events")" +sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$units/openclaw-gateway.service" | grep -qx "$test_home/.openclaw/tools/node-v24.19.0/bin/node" || + fail "--now leaves the moved gateway on the runtime's own Node" "$(cat "$units/openclaw-gateway.service")" +grep -Fxq "runtime gateway install --force --runtime-path $test_home/.openclaw/tools/node-v24.19.0/bin/node" "$events" || + fail "--now pins the runtime's Node when the installer kept the system one" "$(cat "$events")" pass "a service the old package installed moves to the runtime, and only that one" new_home stop-fails @@ -230,7 +245,7 @@ printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gat touch "$test_home/active-openclaw-gateway.service" : >"$events" run omarchy-install-openclaw-cli --now || fail "--now moves a gateway beside a runtime that already runs" "$(cat "$test_tmp/output")" -! grep -q '^install-cli' "$events" && grep -Fxq "runtime gateway install --force" "$events" && [[ -e $test_home/active-openclaw-gateway.service ]] || +! grep -q '^install-cli' "$events" && grep -Fxq "runtime gateway install --force --runtime-path $test_home/.openclaw/tools/node-v24.19.0/bin/node" "$events" && [[ -e $test_home/active-openclaw-gateway.service ]] || fail "--now moves a gateway beside a runtime that already runs" "$(cat "$events")" pass "a gateway beside a runtime that already runs is moved without seeding" @@ -255,6 +270,8 @@ run bash -euo pipefail "$test_tmp/migration.sh" || fail "the migration moves Ope grep -q "^install-cli " "$events" && grep -Fxq "runtime gateway install --force" "$events" || fail "the migration seeds the runtime and moves the gateway to it" "$(cat "$events")" [[ $(readlink -- "$command") == "$runtime" ]] || fail "the migration points the command at the runtime" +sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$test_home/.config/systemd/user/openclaw-gateway.service" | grep -qx "$test_home/.openclaw/tools/node-v24.19.0/bin/node" || + fail "the migration leaves the gateway on the runtime's own Node, not the old package's system Node" "$(cat "$test_home/.config/systemd/user/openclaw-gateway.service")" new_home migration-foreign touch "$test_tmp/package-installed" From 72da1ec851132c0ba0d5162c77c78c32ec04674c Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 28 Sep 2026 00:40:59 -0500 Subject: [PATCH 10/13] Move only services still on the old package's script or Node Judging a moved service by whether its program sat under ~/.openclaw missed a retry after a half-finished move, whose unit already ran the runtime's script on /usr/bin/node and so was never selected again; misread a symlinked home against the resolved Node it pins; and would have moved a gateway the user runs on Bun. A service now needs moving while it runs the old package's script, or the runtime's script on /usr/bin/node, the Node the old package pulled in; any other runtime is left alone. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 23 ++++++++++++++--------- test/shell.d/openclaw-cli-test.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index 83948199..e3578d53 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -122,15 +122,20 @@ stop_legacy_services() { done } -# Not moved yet: still on the old copy, run by a Node outside the runtime, or -# running before and not now. Upstream's installer keeps the Node a service -# already had, so a moved unit can run the runtime's code on the system Node -# the old package depended on, which this one no longer does. +# Still the old package's: its script, or the runtime's script on the old +# package's Node. Upstream's installer keeps the Node a service already ran, so +# a moved unit can end up running ~/.openclaw on /usr/bin/node, from the nodejs +# package the seed no longer depends on. Any other Node or Bun is the user's. +on_old_package() { + local program + program=$(sed -n 's/^ExecStart=//p' "$1" | head -1) + program=${program%% *} + unit_runs "$1" "/usr/lib/node_modules/openclaw/" || { [[ $program == "/usr/bin/node" ]] && unit_runs "$1" "$prefix/"; } +} + +# Not moved yet: still on the old package, or running before and not now. unsettled() { - local unit words - unit=$(unit_path "$1") - read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$unit" | head -1)" - unit_runs "$unit" "/usr/lib/node_modules/openclaw/" || [[ ${words[0]:-} != "$prefix/"* ]] || + on_old_package "$(unit_path "$1")" || { [[ " ${running[*]} " == *" $1 "* ]] && ! systemctl --user is-active --quiet "openclaw-$1.service"; } } @@ -214,7 +219,7 @@ moving=() running=() stopped=() for role in gateway node; do - if [[ -f $(unit_path "$role") ]] && unit_runs "$(unit_path "$role")" "/usr/lib/node_modules/openclaw/"; then + if [[ -f $(unit_path "$role") ]] && on_old_package "$(unit_path "$role")"; then moving+=("$role") if systemctl --user is-active --quiet "openclaw-$role.service"; then running+=("$role") diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index d7e6625d..d629ee27 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -249,6 +249,36 @@ run omarchy-install-openclaw-cli --now || fail "--now moves a gateway beside a r fail "--now moves a gateway beside a runtime that already runs" "$(cat "$events")" pass "a gateway beside a runtime that already runs is moved without seeding" +# A move that stopped halfway left the runtime's code on the old package's +# /usr/bin/node; the next run finishes it, and moves a node host the same way. +new_home half-moved +run omarchy-install-openclaw-cli --now || fail "--now sets OpenClaw up" "$(cat "$test_tmp/output")" +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node %s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' "$test_home" >"$test_home/.config/systemd/user/openclaw-gateway.service" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js node run\n' >"$test_home/.config/systemd/user/openclaw-node.service" +touch "$test_home/active-openclaw-gateway.service" "$test_home/active-openclaw-node.service" +: >"$events" +run omarchy-install-openclaw-cli --now || fail "--now finishes a half-moved gateway" "$(cat "$test_tmp/output")" +for role in gateway node; do + grep -Fxq "runtime $role install --force --runtime-path $test_home/.openclaw/tools/node-v24.19.0/bin/node" "$events" && + sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$test_home/.config/systemd/user/openclaw-$role.service" | grep -qx "$test_home/.openclaw/tools/node-v24.19.0/bin/node" || + fail "--now finishes a half-moved $role on the runtime's own Node" "$(cat "$events")" +done +pass "a half-moved gateway and a node host end up on the runtime's own Node" + +# The runtime run by any other Node or by Bun is the user's choice, not the old +# package's dependency. +new_home bun +run omarchy-install-openclaw-cli --now || fail "--now sets OpenClaw up" "$(cat "$test_tmp/output")" +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/bun %s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' "$test_home" >"$test_home/.config/systemd/user/openclaw-gateway.service" +cp "$test_home/.config/systemd/user/openclaw-gateway.service" "$test_tmp/bun-unit" +: >"$events" +run omarchy-install-openclaw-cli --now || fail "--now leaves a Bun gateway alone" "$(cat "$test_tmp/output")" +! grep -q "install --force\|^systemctl" "$events" && cmp -s "$test_tmp/bun-unit" "$test_home/.config/systemd/user/openclaw-gateway.service" || + fail "--now leaves a Bun gateway alone" "$(cat "$events")" +pass "a gateway the user runs on Bun is left alone" + # The migration moves only machines that have the package, and waits for the # package that seeds. new_home migration-none From 9f0d4641bd121a9d49a36ef8c9b08e6617f56d21 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 28 Sep 2026 00:52:56 -0500 Subject: [PATCH 11/13] Know the runtime's OpenClaw by its own script A service counted as the runtime's, or as a half-moved old one on /usr/bin/node, whenever it ran anything under ~/.openclaw, so a script of the user's in that directory could be stopped and rewritten, and seeding could go ahead beside it. The runtime is now OpenClaw's own script as upstream's installer lays it out, under ~/.openclaw/tools/*/lib/node_modules/openclaw, and both checks use that one definition. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 36 ++++++++++++++++++------------- test/shell.d/openclaw-cli-test.sh | 10 +++++++-- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index e3578d53..aa853bb5 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -70,22 +70,29 @@ installed() { omarchy-pkg-present openclaw && runtime_runs && on_path } -# Whether a unit's ExecStart runs a program from under a path: the program -# itself, or the script it is handed. Later arguments and other lines can name +# A unit's ExecStart, as the program and the script it is handed: the first +# argument that is not an option. Later arguments and other lines can name # ~/.openclaw whichever OpenClaw the unit starts. -unit_runs() { +unit_program() { + local words + read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)" + echo "${words[0]:-}" +} + +unit_script() { local words word read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)" - if [[ ${words[0]:-} == "$2"* ]]; then - return 0 - fi for word in "${words[@]:1}"; do if [[ $word != -* ]]; then - [[ $word == "$2"* ]] + echo "$word" return fi done - return 1 +} + +# The runtime's own OpenClaw, as upstream's installer lays it out. +runs_runtime() { + [[ $(unit_script "$1") == "$prefix"/tools/*/lib/node_modules/openclaw/* ]] } # A gateway service already running some other OpenClaw. Upstream's installer @@ -94,7 +101,7 @@ unit_runs() { foreign_gateway() { local unit unit=$(unit_path gateway) - [[ -f $unit ]] && ! unit_runs "$unit" "$prefix/" && ! unit_runs "$unit" "/usr/lib/node_modules/openclaw/" + [[ -f $unit ]] && ! runs_runtime "$unit" && ! on_old_package "$unit" } # A service the openclaw package's own copy installed runs from @@ -124,13 +131,12 @@ stop_legacy_services() { # Still the old package's: its script, or the runtime's script on the old # package's Node. Upstream's installer keeps the Node a service already ran, so -# a moved unit can end up running ~/.openclaw on /usr/bin/node, from the nodejs -# package the seed no longer depends on. Any other Node or Bun is the user's. +# a moved unit can end up running the runtime's OpenClaw on /usr/bin/node, from +# the nodejs package the seed no longer depends on. Any other Node, Bun or +# script is the user's. on_old_package() { - local program - program=$(sed -n 's/^ExecStart=//p' "$1" | head -1) - program=${program%% *} - unit_runs "$1" "/usr/lib/node_modules/openclaw/" || { [[ $program == "/usr/bin/node" ]] && unit_runs "$1" "$prefix/"; } + [[ $(unit_script "$1") == /usr/lib/node_modules/openclaw/* ]] || + { [[ $(unit_program "$1") == "/usr/bin/node" ]] && runs_runtime "$1"; } } # Not moved yet: still on the old package, or running before and not now. diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index d629ee27..44693750 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -255,7 +255,7 @@ new_home half-moved run omarchy-install-openclaw-cli --now || fail "--now sets OpenClaw up" "$(cat "$test_tmp/output")" mkdir -p "$test_home/.config/systemd/user" printf 'ExecStart=/usr/bin/node %s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' "$test_home" >"$test_home/.config/systemd/user/openclaw-gateway.service" -printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js node run\n' >"$test_home/.config/systemd/user/openclaw-node.service" +printf 'ExecStart=/usr/bin/node %s/.openclaw/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js node run\n' "$test_home" >"$test_home/.config/systemd/user/openclaw-node.service" touch "$test_home/active-openclaw-gateway.service" "$test_home/active-openclaw-node.service" : >"$events" run omarchy-install-openclaw-cli --now || fail "--now finishes a half-moved gateway" "$(cat "$test_tmp/output")" @@ -277,7 +277,13 @@ cp "$test_home/.config/systemd/user/openclaw-gateway.service" "$test_tmp/bun-uni run omarchy-install-openclaw-cli --now || fail "--now leaves a Bun gateway alone" "$(cat "$test_tmp/output")" ! grep -q "install --force\|^systemctl" "$events" && cmp -s "$test_tmp/bun-unit" "$test_home/.config/systemd/user/openclaw-gateway.service" || fail "--now leaves a Bun gateway alone" "$(cat "$events")" -pass "a gateway the user runs on Bun is left alone" +printf 'ExecStart=/usr/bin/node %s/.openclaw/custom/bridge.js\n' "$test_home" >"$test_home/.config/systemd/user/openclaw-gateway.service" +cp "$test_home/.config/systemd/user/openclaw-gateway.service" "$test_tmp/own-unit" +: >"$events" +run omarchy-install-openclaw-cli --now || fail "--now leaves a script of the user's alone" "$(cat "$test_tmp/output")" +! grep -q "install --force\|^systemctl" "$events" && cmp -s "$test_tmp/own-unit" "$test_home/.config/systemd/user/openclaw-gateway.service" || + fail "--now leaves a script of the user's alone" "$(cat "$events")" +pass "a gateway the user runs on Bun, or a script of their own on /usr/bin/node, is left alone" # The migration moves only machines that have the package, and waits for the # package that seeds. From de8645490b49f6476e53d2526914e2bac9d3d2de Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 28 Sep 2026 00:59:44 -0500 Subject: [PATCH 12/13] Leave a moved OpenClaw service stopped or disabled if it was Installing a service enables and starts it, so moving an old-package gateway or node host the user had stopped or disabled turned it back on. What was running and enabled is read before the move and put back after it. Co-Authored-By: Codex XHigh --- bin/omarchy-install-openclaw-cli | 16 ++++++++++++++-- test/shell.d/openclaw-cli-test.sh | 23 ++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli index aa853bb5..17e5d0eb 100755 --- a/bin/omarchy-install-openclaw-cli +++ b/bin/omarchy-install-openclaw-cli @@ -162,6 +162,14 @@ rehome_services() { echo "Could not move the OpenClaw $role service to $prefix. Finish with: openclaw $role install --force" >&2 return 1 fi + # Installing a service enables and starts it; one the user had left + # stopped or disabled goes back to that. + if [[ " ${running[*]} " != *" $role "* ]]; then + systemctl --user stop "openclaw-$role.service" || true + fi + if [[ " ${enabled[*]} " != *" $role "* ]]; then + systemctl --user disable "openclaw-$role.service" 2>/dev/null || true + fi done } @@ -219,10 +227,11 @@ if [[ $seeding == "true" && ( ! -r $seed/install-cli.sh || ! -r $seed/openclaw.t exit 1 fi -# What the old package installed and what of it was running, read before -# anything changes. +# What the old package installed, and what of it was running and enabled, read +# before anything changes. moving=() running=() +enabled=() stopped=() for role in gateway node; do if [[ -f $(unit_path "$role") ]] && on_old_package "$(unit_path "$role")"; then @@ -230,6 +239,9 @@ for role in gateway node; do if systemctl --user is-active --quiet "openclaw-$role.service"; then running+=("$role") fi + if systemctl --user is-enabled --quiet "openclaw-$role.service"; then + enabled+=("$role") + fi fi done diff --git a/test/shell.d/openclaw-cli-test.sh b/test/shell.d/openclaw-cli-test.sh index 44693750..e31a971f 100755 --- a/test/shell.d/openclaw-cli-test.sh +++ b/test/shell.d/openclaw-cli-test.sh @@ -22,8 +22,8 @@ printf 'pkg-add %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" touch "$OMARCHY_TEST_ROOT/package-installed" SH # The user manager, as far as these tests need one: a service is active while -# a marker says so. Stopping clears it, except for the unit named in -# OMARCHY_TEST_STOP_FAIL. +# a marker says so, and enabled likewise. Stopping clears it, except for the +# unit named in OMARCHY_TEST_STOP_FAIL. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash case "$2" in @@ -33,6 +33,11 @@ case "$2" in rm -f "$HOME/active-$3" ;; is-active) [[ -e $HOME/active-$4 ]] ;; + is-enabled) [[ -e $HOME/enabled-$4 ]] ;; + disable) + printf 'systemctl %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" + rm -f "$HOME/enabled-$3" + ;; esac SH chmod +x "$mock_bin/"* @@ -66,7 +71,7 @@ if [[ \${2:-} == "install" ]]; then fi printf 'ExecStart=%s $prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/index.js %s\n' "\$node" "\$1" >"\$unit" [[ -z "\${OMARCHY_TEST_START_FAIL:-}" ]] || exit 1 - touch "\$HOME/active-openclaw-\$1.service" + touch "\$HOME/active-openclaw-\$1.service" "\$HOME/enabled-openclaw-\$1.service" fi exec true "$prefix/tools/node-v24.19.0/lib/node_modules/openclaw/dist/entry.js" "\$@" EOF @@ -193,6 +198,7 @@ units="$test_home/.config/systemd/user" mkdir -p "$units" printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$units/openclaw-gateway.service" printf 'ExecStart=/opt/node /home/someone/openclaw/dist/index.js node run\n' >"$units/openclaw-node.service" +touch "$test_home/active-openclaw-gateway.service" "$test_home/enabled-openclaw-gateway.service" OPENCLAW_PROFILE=work run omarchy-install-openclaw-cli --now || fail "--now moves the old package's services" "$(cat "$test_tmp/output")" order=$(grep -n -e '^systemctl --user stop openclaw-gateway.service$' -e '^install-cli ' -e '^runtime gateway install --force$' "$events" | cut -d: -f2- | cut -c1-11) [[ $order == $'systemctl -\ninstall-cli\nruntime gat' ]] || @@ -206,6 +212,17 @@ grep -Fxq "runtime gateway install --force --runtime-path $test_home/.openclaw/t fail "--now pins the runtime's Node when the installer kept the system one" "$(cat "$events")" pass "a service the old package installed moves to the runtime, and only that one" +# Installing a service enables and starts it, so one the user had left stopped +# and disabled is moved and then put back that way. +new_home dormant +mkdir -p "$test_home/.config/systemd/user" +printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" +run omarchy-install-openclaw-cli --now || fail "--now moves a dormant gateway" "$(cat "$test_tmp/output")" +runs=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$test_home/.config/systemd/user/openclaw-gateway.service") +[[ $runs == "$test_home/.openclaw/tools/node-v24.19.0/bin/node" && ! -e $test_home/active-openclaw-gateway.service && ! -e $test_home/enabled-openclaw-gateway.service ]] || + fail "--now moves a dormant gateway and leaves it stopped and disabled" "$(cat "$events")" +pass "a gateway the user left stopped and disabled is moved and stays that way" + new_home stop-fails mkdir -p "$test_home/.config/systemd/user" printf 'ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789\n' >"$test_home/.config/systemd/user/openclaw-gateway.service" From 9ace84e6c3d47d3a04acdf8ac806837c8adeabbe Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 28 Sep 2026 01:40:39 -0500 Subject: [PATCH 13/13] Keep OpenClaw's gateway probes off the wizard's terminal The wizard writes its config while prompts remain, so the watcher starts probing a gateway that is not up yet; each probe hangs until its 10 second timeout, and OpenClaw's CLI, killed by it, takes the terminal it inherited on stdin out of raw mode on the way out. The wizard does not set raw mode again, so from then on its prompts echo keys instead of reading them: the first letter typed at the channel search landed and the next one was printed below the prompt. The probes now read from /dev/null. This is the change reverted earlier on a test that only ever saw probes that exited on their own, never one the timeout killed. --- bin/omarchy-openclaw-onboard | 8 ++++++-- test/shell.d/openclaw-onboard-test.sh | 14 +++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-openclaw-onboard b/bin/omarchy-openclaw-onboard index 5bac15bd..d8665b3e 100755 --- a/bin/omarchy-openclaw-onboard +++ b/bin/omarchy-openclaw-onboard @@ -26,8 +26,12 @@ settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3} # setup; the prompts before that take as long as the user takes. gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180} +# Probes never get the terminal. The wizard writes its config while prompts +# remain, a probe of a gateway not yet up then hangs until the timeout, and +# OpenClaw's CLI takes a terminal on stdin out of raw mode as it is killed, +# which leaves the wizard's next prompt echoing keys instead of reading them. gateway_answers() { - timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1 + timeout 10 openclaw dashboard --json /dev/null | jq -e '.ok == true' >/dev/null 2>&1 } # A gateway already answering means OpenClaw is set up, and this run must not @@ -81,7 +85,7 @@ config_applied() { gateway_ready() { config_applied || return 1 local json port listener main_pid - json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1 + json=$(timeout 10 openclaw dashboard --json /dev/null) || return 1 jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1 port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null) [[ -n $port ]] || return 1 diff --git a/test/shell.d/openclaw-onboard-test.sh b/test/shell.d/openclaw-onboard-test.sh index 852e3e3d..1c315fdd 100755 --- a/test/shell.d/openclaw-onboard-test.sh +++ b/test/shell.d/openclaw-onboard-test.sh @@ -55,6 +55,7 @@ onboard) while :; do sleep 0.2; done ;; dashboard) + printf 'dashboard-stdin:%s\n' "$(readlink /proc/$$/fd/0)" >>"$TEST_LOG" [[ -f $HOME/.openclaw/openclaw.json ]] && echo '{"ok":true,"port":18789}' || { echo '{"ok":false}'; exit 1; } ;; esac @@ -63,7 +64,10 @@ chmod +x "$tmp_dir/bin/openclaw" start=$SECONDS rc=0 -"$ROOT/bin/omarchy-openclaw-onboard" /dev/null 2>&1 || rc=$? +# A file stands in for the terminal the wizard reads, so where each probe's +# stdin points can be told apart from it. +: >"$tmp_dir/terminal" +"$ROOT/bin/omarchy-openclaw-onboard" <"$tmp_dir/terminal" >/dev/null 2>&1 || rc=$? elapsed=$((SECONDS - start)) grep -q '^openclaw:onboard --flow quickstart --install-daemon --skip-ui$' "$TEST_LOG" || @@ -76,6 +80,14 @@ grep -q '^terminated$' "$TEST_LOG" || (( elapsed < 30 )) || fail "a wizard that lingers after the gateway is up is stopped and counts as success" "took ${elapsed}s" pass "a wizard that lingers after the gateway is up is stopped and counts as success" +# OpenClaw's CLI takes a terminal on stdin out of raw mode as the timeout kills +# it, so a probe that inherited the wizard's terminal would leave its prompts +# unable to read keys. The wizard writes its config while prompts remain. +grep -q '^dashboard-stdin:' "$TEST_LOG" || fail "gateway probes never read from the wizard's terminal" "no probe ran" +! grep '^dashboard-stdin:' "$TEST_LOG" | grep -vqx 'dashboard-stdin:/dev/null' || + fail "gateway probes never read from the wizard's terminal" "$(grep '^dashboard-stdin:' "$TEST_LOG" | sort -u)" +pass "gateway probes never read from the wizard's terminal" + # The wizard only starts being stopped once the gateway actually answers: a # stub that never writes the config is left alone and must be ended by its own # exit, not the watcher.