Skip to content

fix: bring the other edition's harness down instead of only reporting it - #381

Merged
KrasimirKralev merged 2 commits into
betafrom
fix/foreign-edition-teardown
Aug 12, 2026
Merged

fix: bring the other edition's harness down instead of only reporting it#381
KrasimirKralev merged 2 commits into
betafrom
fix/foreign-edition-teardown

Conversation

@KrasimirKralev

@KrasimirKralev KrasimirKralev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What happened on a device

A box was converted in place from the hermes edition to openclaw by re-running install.sh. The OpenClaw gateway came up healthy — and the Hermes stack was still running alongside it:

clawbox-gateway                  active
clawbox-hermes-dashboard         active
clawbox-hermes-dashboard-proxy   active
hermes-gateway                   active

Two harnesses long-polling getUpdates on one Telegram bot token, each knocking the other off:

[Telegram] polling conflict — Conflict: terminated by other getUpdates request
[telegram] Polling stall detected (no completed getUpdates for 145.06s); forcing restart

Telegram was dead on that box for hours.

The decision: act, don't just report

FOREIGN_EDITION_UNITS already saw this — step_validate_services failed the install over it. But nothing acted on it, so the appliance finished loudly broken and waited for an operator who knew which units to name.

Three things decided it:

1. The repo already accepts this mechanism — it was only written in one direction. step_edition_gateway_state stops, disables, removes and masks clawbox-gateway on hermes. "install.sh brings a foreign harness down" is shipped behaviour; the openclaw/dual half was simply missing. The old comment "Nothing in install.sh brings these down" described the Hermes-side units, not the mechanism.

2. #377's refusal does not cover every route into this state. It fires only when a recorded edition exists and differs. Two gaps remain:

  • CLAWBOX_ALLOW_EDITION_CHANGE=1 is a documented escape hatch whose own warning text promised the old harness would be left running — that is this state, by design.
  • If /etc/clawbox/edition.env and the legacy drop-in are both absent (wiped, restored from backup, provisioned by a route that never wrote them), the refusal is skipped entirely and no flag is needed.

3. It is a shared exclusive resource, not a preference. One bot token, one long-poll. Two pollers is not "degraded" — neither side wins.

Rejected: leave it at detection. The argument against acting is that stopping services an operator may be mid-diagnosis on is its own hazard. That is answered rather than dismissed — the teardown is never silent, is reversible with one command, and CLAWBOX_KEEP_FOREIGN_UNITS=1 opts out entirely while leaving detection untouched. Detection alone leaves an appliance broken at the end of a run the operator started.

Rejected: mask the Hermes units the way the gateway is masked. The gateway's mask is justified by a specific fact — config/clawbox-sudoers grants the clawbox user NOPASSWD systemctl start clawbox-gateway, so a plain disable is undone from the in-UI terminal. No Hermes unit has an equivalent grant, so a mask would only cost reversibility. hermes-gateway.service is written by the upstream Hermes installer, so deleting its unit file is not ours to do either. A test reads the sudoers file and fails if that ever stops being true.

What this adds

step_edition_foreign_teardown, driven by the same FOREIGN_EDITION_UNITS list the validator uses so the two cannot drift. Called from step_edition_lock after step_edition_gateway_state, so it reaches the full install and the in-app updater's --step dispatch alike, and runs before any unit of this edition starts.

  • stop + disable only — no mask, no file removed; everything returns with one systemctl enable --now.
  • dual untouched by construction — its foreign list is empty because both harness predicates are negated. Zero systemctl calls, no output.
  • never silent — every unit named with the state it was in, why, and how to restore it.
  • CLAWBOX_KEEP_FOREIGN_UNITS=1 skips it; detection still fails the install.
  • dispatchablesudo bash install.sh --step edition_foreign_teardown.

Detection stays as the last line of defence (a unit can return under its own Restart=, and the opt-out skips the teardown). Its messages now carry the exact command per unit, and the failure list ends with the one command that redoes the whole teardown.

The refusal from #377 still precedes all of it — a top-level exit during constant parsing, so a refused transition reaches no step function and "nothing has been changed" stays literally true.

Corrected claims

Three places promised the old harness would be left running and no longer can: install.sh's escape-hatch warning and refusal text, the same passage in docs-site/editions/overview.mdx, and the note in scripts/setup-hermes-edition.sh. What is still true — and what the refusal now rests on — is that the sign-in does not move: each harness keeps its credentials in its own config and setup_complete carries over, so a converted device comes up quiet rather than conflicted and still has no usable model. Changing edition is still a reflash.

Tests

src/tests/unit/install-foreign-edition-teardown.test.ts (29 tests), following install-edition-switch-refusal.test.ts: the real shell function is extracted from install.sh and run against a stubbed systemctl that records every state-changing verb to a file — install.sh sends those calls to /dev/null, so a file is the only way to assert stop and disable were the only things done, and mask/unmask/rm were not.

Covered: the exact observed device state; dual with zero calls; hermes silent after the gateway state already masked, and still backstopping if it did not; enabled-but-inactive; masked-but-running; activating/reloading; idempotence; the opt-out taking only an explicit 1; the wiring and ordering; and the new validator hints.

Test Files  2 passed (2)
     Tests  76 passed (76)

(new file + install-edition-switch-refusal.test.ts, run on Linux — the bash-backed suites skip on win32)

Full unit project: 174 files / 2406 tests passed. ESLint and tsc --noEmit clean; bash -n clean on both scripts.

Summary by CodeRabbit

  • New Features
    • Edition transitions now detect and report services from other editions.
    • Foreign-edition services are stopped and disabled automatically, with an option to preserve them using CLAWBOX_KEEP_FOREIGN_UNITS=1.
    • Added a manual command for tearing down foreign-edition services.
  • Bug Fixes
    • Improved migration warnings and refusal messages, including clearer guidance about service cleanup and provider credentials.
    • Added actionable repair instructions when service validation detects conflicts.
  • Tests
    • Expanded coverage for cleanup, validation, opt-out behavior, and safe repeated execution.

A device converted in place from hermes to openclaw came up with the OpenClaw
gateway healthy and the entire Hermes stack still running: clawbox-gateway,
clawbox-hermes-dashboard, clawbox-hermes-dashboard-proxy and hermes-gateway all
active at once. Both harnesses long-poll getUpdates on the same Telegram bot
token, so each terminated the other's request and the box could not receive a
message for hours.

FOREIGN_EDITION_UNITS could already see that state and step_validate_services
failed the install over it, but nothing acted on it — the appliance finished
loudly broken and waited for an operator who knew which units to name.

step_edition_foreign_teardown closes the gap. The mechanism is not new:
step_edition_gateway_state has always stopped, disabled, removed and masked
clawbox-gateway on hermes; only the openclaw/dual direction was missing. It
drives the same FOREIGN_EDITION_UNITS list the validator uses, so the two cannot
drift, and it runs from step_edition_lock — reaching the full install and the
in-app updater's --step dispatch alike, before any unit of this edition starts.

Deliberately conservative:
  - stop + disable only. No mask, no unit file removed, so everything returns
    with one `systemctl enable --now`. The mask in step_edition_gateway_state
    exists because config/clawbox-sudoers grants the clawbox user NOPASSWD
    `systemctl start clawbox-gateway`, which undoes a plain disable; no Hermes
    unit has an equivalent grant. hermes-gateway.service is written by the
    upstream Hermes installer, so its file is not ours to delete.
  - dual is untouched by construction, not by a special case: its foreign list
    is empty because both harness predicates are negated.
  - never silent — every unit is named with the state it was in and the command
    that restores it.
  - CLAWBOX_KEEP_FOREIGN_UNITS=1 skips it entirely for an operator mid-diagnosis.
    Detection is unaffected either way.

Detection stays as the last line of defence, since a unit can come back under
its own Restart= and the opt-out skips the teardown. Its messages now carry the
exact command to run, and the failure list ends with the one command that redoes
the whole teardown.

The refusal from #377 still precedes all of this: it is a top-level exit during
constant parsing, so a refused transition reaches no step function and "nothing
has been changed" stays literally true. The three places that promised the old
harness would be left running are corrected — install.sh's escape-hatch warning
and refusal text, the same passage in docs-site/editions/overview.mdx, and the
matching note in scripts/setup-hermes-edition.sh. What is still true, and is
what the refusal now rests on, is that the sign-in does not move: each harness
keeps its credentials in its own config and setup_complete carries over, so a
converted device comes up quiet rather than conflicted and still has no usable
model.
Follows install-edition-switch-refusal.test.ts: the real shell function is
extracted from install.sh and run against a stubbed systemctl, so the code under
test is the code that ships.

The stub records every state-changing verb to a file, because install.sh sends
those calls to /dev/null — a file is the only way to assert that stop and disable
were the only things done, and that mask, unmask and rm were not.

Covered: the exact observed device state (openclaw box, whole Hermes stack up);
dual left completely alone, with zero systemctl calls; hermes silent when
step_edition_gateway_state has already masked the gateway, and still backstopping
it if it has not; enabled-but-inactive disabled; masked-but-running stopped;
activating and reloading treated as running; idempotence; the
CLAWBOX_KEEP_FOREIGN_UNITS opt-out taking only an explicit 1. The wiring tests
pin that the teardown is called from step_edition_lock after the gateway state,
that the updater path reaches it, that it is dispatchable so the printed repair
command is real, that it runs before any unit of this edition starts, and that
the refusal still precedes it in the file.

Two tests guard the judgement rather than the behaviour: one reads
config/clawbox-sudoers and fails if a Hermes unit ever gains the NOPASSWD start
grant that justifies masking the gateway, and one fails if hermes-gateway.service
ever becomes a unit this repo ships. Either would mean stop+disable is no longer
the right amount of force.

The validator tests pin the new per-unit fix commands, the single command that
redoes the whole teardown, that it is offered only for edition failures, and that
a clean device still reports the same 15 checks.
@KrasimirKralev
KrasimirKralev requested a review from a team as a code owner August 12, 2026 08:57
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The installer now detects foreign edition units, stops active units, disables enabled units, supports an opt-out variable, and exposes manual teardown. Edition transition messages and service validation now describe credential boundaries and remediation commands. Tests cover teardown, wiring, validation, and non-destructive behavior.

Changes

Edition transition contracts

Layer / File(s) Summary
Transition messaging and edition contracts
docs-site/editions/overview.mdx, install.sh, scripts/setup-hermes-edition.sh
Edition transition documentation and messages describe refused transitions, stopped and disabled harness units, non-migrated credentials, and foreign-unit handling.

Foreign-unit teardown

Layer / File(s) Summary
Foreign-unit teardown and dispatch
install.sh
step_edition_foreign_teardown() detects foreign units, stops active units, disables enabled units, honors CLAWBOX_KEEP_FOREIGN_UNITS=1, reports recovery commands, runs during edition locking, and supports --step edition_foreign_teardown.

Validation and tests

Layer / File(s) Summary
Validation and behavioral coverage
install.sh, src/tests/unit/install-foreign-edition-teardown.test.ts
Validation reports per-unit and aggregate remediation commands. Tests cover service states, idempotence, edition handling, opt-out behavior, ordering, dispatch, registry usage, and clean-device health results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EditionLock
  participant Teardown
  participant ServiceRegistry
  participant Systemctl
  EditionLock->>Teardown: Run foreign-unit teardown
  Teardown->>ServiceRegistry: Find foreign edition units
  ServiceRegistry-->>Teardown: Return matching units
  Teardown->>Systemctl: Stop active units
  Teardown->>Systemctl: Disable enabled units
  Teardown-->>EditionLock: Report affected units and recovery commands
Loading

Possibly related PRs

Suggested labels: area: gateway

Suggested reviewers: georgik77, yalexx

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: stopping the other edition's harness instead of only reporting it.
Description check ✅ Passed The description gives a detailed change summary, rationale, behavior, safeguards, and test results, but it does not use all template headings or checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/foreign-edition-teardown

Note

This review was completed with usage-based billing: files reviewed beyond your plan's included limits are billed at $0.25/file. Track spend and usage in your billing settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🦀 ClawReview

Fresh PR washed in with the tide — here's the gist.

Fills the gap where re-running install.sh to switch editions (or using CLAWBOX_ALLOW_EDITION_CHANGE=1) left the old harness's systemd units fully running alongside the new one. On a real device this meant both harnesses long-polling the same Telegram bot token, looping forever on 'Conflict: terminated by other getUpdates request'. The fix is a new step_edition_foreign_teardown that stops and disables whatever is in FOREIGN_EDITION_UNITS before any unit of the incoming edition starts; it is wired into step_edition_lock so it reaches the full install and the in-app updater alike.

At a glance

  • 🔧 Fix · touches install.sh edition lifecycle (step_edition_lock, FOREIGN_EDITION_UNITS) + docs-site editions overview
  • Base branch: beta · +167 source / +490 tests across 4 files
  • ✅ base beta matches the beta-first convention
  • ✅ conventional PR title
  • ℹ️ touches security-sensitive paths (install.sh) — review with extra care

Good to know

  • 🟡 Runs on customer devices via the auto-update path — step_post_update dispatches step_edition_lock, which now calls this teardown on every update cycle, not just fresh installs.
  • ℹ️ Deliberately stop+disable only (no mask, no unit-file removal), so every foreign unit is one 'systemctl enable --now' away from being restored. The mask in step_edition_gateway_state is NOT copied here — good, given the sudoers grant that justifies it only covers clawbox-gateway.
  • ℹ️ 490 lines of new Vitest tests drive the shell function directly via spawnSync, including the sudoers invariant that enforces why mask was rejected. Good coverage of the dual-edition no-op and the CLAWBOX_KEEP_FOREIGN_UNITS escape hatch.
  • 🟡 The dual edition is protected by construction (FOREIGN_EDITION_UNITS is empty when both harness predicates are satisfied), not by a special-case branch — worth confirming the SERVICE_REGISTRY slice captured in the test matches what ships if that block is ever touched.

— ClawReview 🦀, your resident reef crab. Just orientation — CodeRabbit does the line-by-line, humans do the merge. Conventions: docs.

@github-actions github-actions Bot added area: docs Auto-triage area area: install Auto-triage area labels Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

CI Summary

✅ Tests

  • Result: passed
  • View run
  • Coverage: statements 65.18%, branches 54.15%, functions 63.12%, lines 67.26%

✅ E2E

✅ E2E Install

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/editions/overview.mdx`:
- Around line 96-103: Clarify the edition-mismatch behavior: in
docs-site/editions/overview.mdx (96-103), state that refusal is a no-op
requiring a reflash, and limit foreign-unit teardown to same-edition installs or
manual repair; update install.sh (166-175) to distinguish refusal from cleanup
and (221-225) to remove the stop/disable claim; change
scripts/setup-hermes-edition.sh (109-111) to say install.sh does not migrate;
update src/tests/unit/install-foreign-edition-teardown.test.ts (349-359) to
execute the refusal path and assert its diagnostic says no units changed.

In `@install.sh`:
- Around line 1220-1226: Update the foreign-unit teardown flow around
needs_stop, needs_disable, and brought_down so each requested systemctl stop or
disable is followed by a state check. Return nonzero and skip adding the unit to
brought_down when it remains active or enabled, and add coverage for stop and
disable failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ff82d3cc-5500-4920-86d9-1837d1712131

📥 Commits

Reviewing files that changed from the base of the PR and between accbc70 and 4bc1cfa.

📒 Files selected for processing (4)
  • docs-site/editions/overview.mdx
  • install.sh
  • scripts/setup-hermes-edition.sh
  • src/tests/unit/install-foreign-edition-teardown.test.ts

Comment on lines +96 to +103
The installer does not migrate an edition. It will stop and disable the harness
the device is leaving — two agents on one box conflict over shared resources such
as the Telegram bot token, so that half is not left to chance — but each harness
keeps its AI provider sign-in in its own configuration file, and signing in does
not carry across. A device installed over the top of another edition ends up with
no usable model while still reporting that setup is complete, which is why the
transition is refused rather than half performed. Changing a device's edition is
a reflash.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not describe a refused edition change as a teardown.

The edition-mismatch path exits before any step function runs. It does not stop or disable units. The current text can cause an operator to believe that a rejected migration changed service state.

  • docs-site/editions/overview.mdx#L96-L103: state that a refused edition change is a no-op and requires a reflash; describe foreign-unit teardown only for same-edition installs or manual repair.
  • install.sh#L166-L175: update the implementation comment to distinguish refused edition changes from foreign-unit cleanup.
  • install.sh#L221-L225: remove the claim that the departing harness “will stop and disable” from the refusal diagnostic.
  • scripts/setup-hermes-edition.sh#L109-L111: change “install.sh migrates” to “install.sh does not migrate.”
  • src/tests/unit/install-foreign-edition-teardown.test.ts#L349-L359: execute the refusal path and assert that its diagnostic states that no units changed.
📍 Affects 4 files
  • docs-site/editions/overview.mdx#L96-L103 (this comment)
  • install.sh#L166-L175
  • install.sh#L221-L225
  • scripts/setup-hermes-edition.sh#L109-L111
  • src/tests/unit/install-foreign-edition-teardown.test.ts#L349-L359
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/editions/overview.mdx` around lines 96 - 103, Clarify the
edition-mismatch behavior: in docs-site/editions/overview.mdx (96-103), state
that refusal is a no-op requiring a reflash, and limit foreign-unit teardown to
same-edition installs or manual repair; update install.sh (166-175) to
distinguish refusal from cleanup and (221-225) to remove the stop/disable claim;
change scripts/setup-hermes-edition.sh (109-111) to say install.sh does not
migrate; update src/tests/unit/install-foreign-edition-teardown.test.ts
(349-359) to execute the refusal path and assert its diagnostic says no units
changed.

Comment thread install.sh
Comment on lines +1220 to +1226
if [ "$needs_stop" = true ]; then
systemctl stop "$funit" >/dev/null 2>&1 || true
fi
if [ "$needs_disable" = true ]; then
systemctl disable "$funit" >/dev/null 2>&1 || true
fi
brought_down+=("$funit (was active=$f_active enabled=$f_enabled)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail when foreign-unit teardown fails.

Lines 1221 and 1224 discard systemctl failures. Line 1226 then reports the unit as brought down. A direct --step edition_foreign_teardown run can return success while a foreign unit remains active or enabled.

Re-check the required state after each operation. Return nonzero and do not add the unit to brought_down if it remains active or enabled. Add tests where systemctl stop and systemctl disable fail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@install.sh` around lines 1220 - 1226, Update the foreign-unit teardown flow
around needs_stop, needs_disable, and brought_down so each requested systemctl
stop or disable is followed by a state check. Return nonzero and skip adding the
unit to brought_down when it remains active or enabled, and add coverage for
stop and disable failures.

@KrasimirKralev
KrasimirKralev merged commit 8b1ed5c into beta Aug 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: docs Auto-triage area area: install Auto-triage area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant