Skip to content

fix: refuse an unsupported edition transition and make validation honest - #377

Merged
KrasimirKralev merged 2 commits into
betafrom
fix/refuse-edition-switch
Aug 11, 2026
Merged

fix: refuse an unsupported edition transition and make validation honest#377
KrasimirKralev merged 2 commits into
betafrom
fix/refuse-edition-switch

Conversation

@KrasimirKralev

@KrasimirKralev KrasimirKralev commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Re-running the installer with a different CLAWBOX_EDITION on a device that is already provisioned completed all 26 steps and printed a healthy summary β€” for a device left in a state it cannot recover from on its own.

The installer installs an edition; it does not migrate one. There is no step that stops, disables or masks the harness a device is leaving, and each harness keeps its AI provider credentials in its own config file, so a sign-in does not move across. What you get is:

  • both harnesses running at once β€” two agents, two dashboards, and two pollers on one Telegram bot token;
  • the incoming harness with an empty provider registry, so no model resolves and every chat turn fails;
  • a previously signed-in device reading as signed out;
  • setup_complete carried over, so the wizard step that would repair all of it never runs again.

docs-site/editions/overview.mdx already documented edition changes as reflash-only. The installer just did not enforce it.

What this changes

1. The installer refuses the transition. install.sh compares the requested edition against the root-owned lock β€” with the legacy systemd drop-in as the fallback, so boxes provisioned before edition.env existed are covered too β€” and stops before anything is changed. The message states what the device currently is, what was requested, that changing edition requires a reflash, and names the single override.

The check sits immediately after the edition resolves, during constant parsing. That is ahead of the lock and drop-in rewrite, ahead of any mask/unmask, and ahead of any unit being copied, enabled or started β€” on the full-install path and on the --step dispatch path the in-app updater uses, since both reach that point before any step function is even defined. A refusal that fires after the gateway has been unmasked would not be a refusal.

CLAWBOX_ALLOW_EDITION_CHANGE=1 is the one explicit escape hatch. It is named in the refusal and left out of the customer-facing docs on purpose.

2. The other writer of the lock carries the same refusal. scripts/setup-hermes-edition.sh also rewrites /etc/clawbox/edition.env and the legacy drop-in, and it read $CLAWBOX_EDITION ahead of the lock β€” the one place where the environment outranked the root-owned file, and the place that then rewrote it. Guarding only install.sh would have left that path open, and worse, it launders the change: once the lock reads the new edition, a later plain install.sh sees no mismatch and provisions on top. Both writers now refuse, and this one reads the lock first like every other consumer.

3. dual is a distinct edition in both directions. openclaw β†’ dual and hermes β†’ dual are refused too. The additive direction fails the same way as the destructive one β€” the harness being added comes up with no provider registry and no credentials, and setup_complete suppresses the step that would populate them β€” and the lock exists precisely so the SKU cannot be selected from the environment.

4. Validation is honest about foreign units. step_validate_services only ever asserted that this edition's units are up; it appends the Hermes units inside if has_hermes_harness, so on openclaw a fully running Hermes stack was not merely tolerated, it was invisible. A new FOREIGN_EDITION_UNITS registry β€” built from the existing has_hermes_harness / has_openclaw_harness predicates, so dual accumulates nothing and needs no special case β€” is now checked, and any foreign unit that is active or merely enabled fails the run. That is what stops the healthy summary being printable on a device running two harnesses.

Edition migration is deliberately not implemented. Doing it properly means migrating credentials, tearing down the other harness and re-running the AI step; that is a feature, not this fix.

Unaffected

  • Fresh installs β€” no lock yet, so nothing to disagree with.
  • Same-edition re-installs β€” recorded equals requested.
  • post_update / the in-app updater β€” clawbox-root-update@.service exports CLAWBOX_EDITION from the lock, so requested and recorded are equal by construction on every step it dispatches.
  • config/edition.txt is still a resolution source but is never treated as a record; it lives in a customer-writable tree.
  • An unrecognised recorded value normalises to openclaw before comparing, so a typo'd lock cannot brick a device's updates.

Tests

src/tests/unit/install-edition-switch-refusal.test.ts (43 tests). Following the conventions in install-edition-lock.test.ts and register-mcp-hermes.test.ts, these execute the shipped shell text rather than string-matching it: the edition-resolution/refusal region and step_validate_services are extracted from install.sh and run against a temp lock file and a fake systemd.

  • Refusal fires on lock/env disagreement, and the lock file is byte-identical afterwards with no drop-in created.
  • The escape hatch permits it; only a literal 1 opens it (yes, true, 2, 0 all still refuse).
  • Fresh install, same-edition re-install, updater path (no env var), case-insensitive lock, and unrecognised lock values are all unaffected.
  • All six recordedβ†’requested transitions refuse; all three same-edition re-installs pass.
  • Ordering is pinned structurally: the refusal precedes the first step_* definition, DISPATCH_STEPS, and the installer banner.
  • A test asserts the refusal block contains no systemctl stop|disable|mask|unmask and no rm -rf, holding the "no migration" scope boundary.
  • Validator: a clean openclaw box passes; the same box with the Hermes stack alive fails 3 of 15; enabled-but-inactive fails; installed-but-disabled passes; hermes still running the gateway fails; dual running both is healthy.

Full suite green β€” 197 files, 2458 tests. eslint clean on the changed files, shellcheck -S warning clean on both scripts, no new tsc errors.

Summary by CodeRabbit

  • New Features

    • Installation now records and validates the selected edition.
    • Reinstallations reuse the recorded edition by default and prevent incompatible edition changes.
    • Edition changes can be explicitly overridden when required.
    • Installation checks now detect active or enabled services from other editions.
  • Documentation

    • Added guidance on first-install edition selection, reinstallations, overrides, and reflashing requirements.
  • Bug Fixes

    • Improved handling of invalid, legacy, missing, and case-variant edition values.

Re-running the installer with a different CLAWBOX_EDITION on a provisioned
device completed every step and reported a healthy box, while leaving it in a
state it cannot recover from on its own.

The installer installs an edition; it does not migrate one. There is no step
that removes the harness a device is leaving, and each harness keeps its AI
provider credentials in its own config file, so a sign-in does not move across.
The result is two harnesses running at once, an incoming harness with an empty
provider registry so no model resolves, and setup_complete carried over so the
wizard step that would repair it never runs again.

Refuse the transition instead:

* install.sh compares the requested edition against the root-owned lock (with
  the legacy drop-in as the fallback for older boxes) and stops before anything
  is changed. The check sits immediately after the edition resolves, so it runs
  ahead of the lock rewrite, ahead of any mask/unmask, and ahead of any unit
  being installed or started, on the full-install path and on --step dispatch
  alike. CLAWBOX_ALLOW_EDITION_CHANGE=1 is the single explicit override, and the
  refusal names it.
* scripts/setup-hermes-edition.sh is the other writer of the lock and carries
  the same refusal. Without it, a standalone run could rewrite the lock and a
  later plain install.sh would then see no mismatch at all.
* dual is treated as a distinct edition in both directions. The additive
  direction fails the same way as the destructive one, and the lock exists so
  the SKU cannot be selected from the environment.

Fresh installs, same-edition re-installs and the updater's provisioning path
are unaffected: they either have no lock yet or resolve the recorded value.

Validation was blind in one direction as well. It only ever asserted that this
edition's units are up, so a leftover harness from another edition scored zero
failures. step_validate_services now also asserts the absence of foreign units
and fails on any that are active or enabled, which is what stops the healthy
summary from being printable on a device running two harnesses.

Edition migration is deliberately not implemented.
@KrasimirKralev
KrasimirKralev requested a review from a team as a code owner August 11, 2026 21:45
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. πŸŽ‰

ℹ️ Recent review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 409a00d1-c14b-4c00-a1fe-070ef78e0320

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between f8644d5 and 4c18a3c.

πŸ“’ Files selected for processing (3)
  • install.sh
  • scripts/setup-hermes-edition.sh
  • src/tests/unit/install-edition-switch-refusal.test.ts

πŸ“ Walkthrough

Walkthrough

The installer and standalone Hermes setup now enforce recorded edition consistency. They normalize edition values, refuse unsupported switches before changes, support an explicit override, detect foreign edition services, and document reflash requirements.

Changes

Edition consistency enforcement

Layer / File(s) Summary
Installer edition lock
install.sh, docs-site/editions/overview.mdx, src/tests/unit/install-edition-switch-refusal.test.ts
The installer reads and normalizes the recorded edition, rejects mismatches before installer entry paths, and documents reinstall, override, dual, and reflash behavior. Tests cover lock handling and no-write refusal.
Foreign service validation
install.sh, src/tests/unit/install-edition-switch-refusal.test.ts
Service validation tracks edition-specific units and fails for foreign units that are active, activating, reloading, or enabled. Tests cover classification and check totals.
Standalone Hermes edition resolution
scripts/setup-hermes-edition.sh, src/tests/unit/install-edition-switch-refusal.test.ts
Hermes setup normalizes requested and recorded values, rejects conflicting editions unless overridden, and preserves supported setup paths. Tests cover precedence and transitions.

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

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant install_sh as install.sh
  participant edition_lock as recorded edition lock
  participant systemd as systemd services
  Operator->>install_sh: request installation or edition
  install_sh->>edition_lock: read and normalize recorded edition
  install_sh->>install_sh: reject mismatched edition or apply override
  install_sh->>systemd: validate edition-scoped units
  systemd-->>install_sh: report active or enabled foreign units
  install_sh-->>Operator: continue or fail validation
Loading

Possibly related PRs

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 changes: refusing unsupported edition transitions and improving service validation.
Description check βœ… Passed The description clearly covers the problem, implementation, scope, unaffected paths, and tests, although it omits the template's Type of change and Checklist sections.
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/refuse-edition-switch

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

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

πŸ¦€ ClawReview

Poked my eyestalks out for this one. Quick tour:

The installer previously let a CLAWBOX_EDITION change run to completion on an already-provisioned device β€” printing a healthy summary while leaving two agent harnesses running, no usable AI provider, and setup_complete blocking the wizard from ever repairing it. This PR adds an early refusal in install.sh (before any unit is touched) and the identical guard in scripts/setup-hermes-edition.sh, makes step_validate_services visible to units belonging to a foreign edition, and updates the docs with the reflash-only guidance that was already policy but not enforced. A 703-line Vitest suite shells out to exercise the real installer text unprivileged.

At a glance

  • πŸ”§ Fix Β· touches installer (install.sh + scripts/setup-hermes-edition.sh), service-validation logic, editions docs
  • Base branch: beta Β· +278 source / +703 tests across 4 files
  • βœ… base beta matches the beta-first convention
  • βœ… conventional PR title
  • 🟑 large PR (990 lines changed) β€” consider splitting
  • ℹ️ touches security-sensitive paths (install.sh) β€” review with extra care

Good to know

  • 🟑 Touches install.sh, which also runs on the --step dispatch path the in-app updater uses on every customer device update β€” the refusal is placed before constant parsing completes, so it fires on that path too.
  • ℹ️ CLAWBOX_ALLOW_EDITION_CHANGE=1 escape hatch is named in the refusal message but deliberately omitted from customer-facing docs; operators who need it will find it in the error output.
  • ℹ️ probe_count in step_validate_services now includes foreign-edition checks, so 'All N checks healthy' only prints when those absence checks actually ran and passed.
  • ℹ️ Large PR (990 lines) but ~700 are new tests; the functional shell changes are ~290 lines across two scripts.

β€” ClawReview πŸ¦€, scuttling off. General info only β€” see CodeRabbit for the detailed review. Conventions: docs.

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

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

CI Summary

βœ… Tests

  • Result: passed
  • View run
  • Coverage: statements 64.78%, branches 53.82%, functions 62.79%, lines 66.9%

βœ… 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: 3

πŸ€– 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 `@install.sh`:
- Around line 120-136: The edition resolution case should apply the same
whitespace normalization as _normalise_edition before matching values. Update
the resolution flow using CLAWBOX_EDITION_RAW so inputs such as β€œ hermes ”
resolve identically to normalized editions, while preserving the existing
handling for empty and unrecognized values.

In `@scripts/setup-hermes-edition.sh`:
- Around line 49-52: Update the recorded-edition initialization before any
configuration or service operations to fall back to the legacy systemd drop-in
when EDITION_FILE is absent. Reuse the existing normalization and parsing
behavior so RECORDED_EDITION is populated from the legacy CLAWBOX_EDITION value,
allowing the later mismatch check to reject conflicting editions.
- Around line 45-53: Validate RECORDED_EDITION with normalizeEdition() before
the edition mismatch check, treating unsupported values such as openclw as
absent rather than rejecting the request. Preserve valid recorded editions and
the existing REQUESTED_EDITION handling.
πŸͺ„ 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: 63223e0a-6fb5-4ca3-9f46-dbe04bb24721

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between a03f601 and f8644d5.

πŸ“’ Files selected for processing (4)
  • docs-site/editions/overview.mdx
  • install.sh
  • scripts/setup-hermes-edition.sh
  • src/tests/unit/install-edition-switch-refusal.test.ts

Comment thread install.sh
Comment thread scripts/setup-hermes-edition.sh
Comment thread scripts/setup-hermes-edition.sh Outdated
All three came out of review of the first commit.

Read the legacy drop-in in setup-hermes-edition.sh. It resolved the recorded
edition from /etc/clawbox/edition.env only, so on a device provisioned before
that file existed the recorded value stayed empty and a conflicting
CLAWBOX_EDITION walked straight past the refusal. That is the same gap the
installer already covers, left open in the second writer. It now reads the
drop-in as the fallback, using the same two-shape parser install.sh uses.

Map an unrecognised recorded value to openclaw there too. install.sh already
does this, so a typo'd lock described an openclaw device to one writer and an
unknown edition to the other. Reading it as "absent" instead would be the
dangerous direction: it would clear the refusal entirely and let a typo'd lock be
provisioned straight over.

Align the whitespace rule in install.sh. The recorded value was
whitespace-stripped but the resolution was not, so a padded
CLAWBOX_EDITION=" hermes " resolved to openclaw (unrecognised, warn, fall back)
while the lock still read hermes β€” refusing on a device nobody was trying to
change, and reporting a requested edition the operator never typed. The two
rules are now identical.

Tests cover each: the drop-in path, the typo'd lock in both directions, a padded
value that matches, and a padded value that genuinely differs.
@KrasimirKralev

Copy link
Copy Markdown
Contributor Author

All three findings were valid and are fixed in 4c18a3c.

1. Legacy drop-in in setup-hermes-edition.sh β€” correct, and the most important of the three. It resolved the recorded edition from /etc/clawbox/edition.env only, so a device provisioned before that file existed had an empty recorded value and a conflicting CLAWBOX_EDITION walked straight past the refusal. That is exactly the gap install.sh already covers, left open in the second writer. It now falls back to the drop-in using the same two-shape parser (CLAWBOX_EDITION=x and Environment=CLAWBOX_EDITION=x).

2. Unrecognised recorded value β€” correct that the two writers disagreed, and fixed, but not by treating the value as absent. install.sh maps an unrecognised recorded edition to openclaw, and setup-hermes-edition.sh now does the same. Treating it as absent is the dangerous reading: it would clear the refusal entirely and let a typo'd lock be provisioned straight over, which is the failure this PR exists to prevent. Mapping to openclaw keeps both writers agreeing on what the device is, and still refuses a genuine switch away from it.

3. Whitespace alignment in install.sh β€” correct. The recorded value was whitespace-stripped and the resolution was not, so a padded CLAWBOX_EDITION=" hermes " resolved to openclaw (unrecognised β†’ warn β†’ fall back) while the lock still read hermes. That refused on a device nobody was trying to change, and the message reported Requested edition: openclaw at an operator who had typed hermes. The two rules are now identical.

New tests cover each: the drop-in path, the typo'd lock in both directions, a padded value that matches, and a padded value that genuinely differs. 47 tests in the file, full suite 2462 green.

@KrasimirKralev
KrasimirKralev merged commit 317bc19 into beta Aug 11, 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