diff --git a/connections/factory-reset/README.md b/connections/factory-reset/README.md new file mode 100644 index 00000000..5e8b2dc4 --- /dev/null +++ b/connections/factory-reset/README.md @@ -0,0 +1,44 @@ +# Factory Reset — one-tap recover to a known-good state, never silent + +**Tier:** A (native integration; real RoamCore-owned Python service handler at `homeassistant/custom_components/roamcore/factory_reset.py` + >=25 pytest tests at `homeassistant/packages/tests/test_factory_reset.py` + 12 bash assertions at `scripts/checks/factory-reset-smoke.sh`) + +**Category:** system +**Status:** needs_information (tier-a, but the rollback path is still being wired — the chain-corruption recovery automation is dormant until the openclaw-api audit chain binary_sensor lands on main) + +## What this connection is + +Factory Reset gives you a panic button for your Hub — it always restores from your latest verified Hub Backup first, so you can recover from a bad config in one tap without losing any of your van data. The 5-step IKEA flow is the operator-facing affordance surface (Glance at the tile -> Click Dry-run -> Read the plan -> Click Confirm -> Check the post-flight tile). The full howto lives at [`docs/recipe.md`](docs/recipe.md). + +This is the **third true tier-a connection** in the RoamCore connection pipeline. Like `connections/hub-backup/` (Wave 9 #123.a) and `connections/openclaw-api/` (Wave 3 #64), this slice SHIPS a new RoamCore-owned service handler at `homeassistant/custom_components/roamcore/factory_reset.py` (~340 LOC) + a helper package at `homeassistant/packages/roamcore_factory_reset.yaml` + the >=25 pytest tests + the bash smoke check. + +The reset is **panic-button safe** — it always restores from the latest Hub Backup (from the hub-backup connection) and never silently destroys user data. The wizard enforces a 2-step confirmation flow with an explicit token ("type RESET to confirm") AND it runs a dry-run first that lists the current state + the last backup + the post-reset state. If no recent backup exists, the reset refuses to run and offers to take a backup first. + +## The 5-step operator flow + +- **Step 1 — Glance at the tile.** Open the dashboard and look at `sensor.rc_factory_reset_status` (plain-English "Ready" / "Dry-run shown" / "Confirm pending" / "Resetting…" / "Last reset: 3 days ago") and `binary_sensor.rc_factory_reset_safe_to_run` (green = safe to run, red = please back up first). +- **Step 2 — Click Dry-run.** Tap `input_button.rc_factory_reset_dry_run` to see the planned post-reset state + the last backup + the services that will restart. The 8-char token is generated and stored in `input_text.rc_factory_reset_token`. +- **Step 3 — Read the plan.** The dry-run report surfaces via `input_text.rc_factory_reset_dry_run_report` (plain English). The section 8.1 dry-run automation auto-clears the token after 5 minutes (operator walked away / changed their mind). +- **Step 4 — Click Confirm.** Tap `input_button.rc_factory_reset_confirm`. The section 8.2 confirm automation reads the token from the helper and calls `roamcore.factory_reset_confirm` with the value. The HA core `backup.restore` service runs against the latest verified-restorable backup. The Hub restarts. +- **Step 5 — Check the post-flight tile.** The section 8.4 postflight automation calls `roamcore.factory_reset_postflight_check` on HA start + writes the result to `sensor.rc_factory_reset_postflight_status`. A green tile means the reset worked + the Hub is healthy. + +## What this depends on + +- **Hub Backup** (`connections/hub-backup/`, MERGED on main as commit bfaa73d). The reset refuses to run without a recent Hub Backup (< 24h old). If no recent backup exists, the reset refuses to run and surfaces a plain-English message. +- **OpenClaw audit chain** (when the `binary_sensor.rc_openclaw_api_chain_valid` lands on main from the openclaw-api connection). The section 8.5 recovery automation references the binary_sensor by name + fires the chain-corruption recovery flow when it flips off (wipe audit log + restore from latest backup). The automation is dormant until the binary_sensor lands. + +## Files + +- `connection.yml` — the source-of-truth tier-a manifest. +- `__init__.py` — `DOMAIN = "factory_reset"` marker + tile-name + service-name constants for the audit. +- `docs/recipe.md` — the full howto. +- `tests/test_connection_yml.py` — manifest honesty checks. + +## See also + +- RoamCore-owned service handler: `homeassistant/custom_components/roamcore/factory_reset.py` (~340 LOC — registers 4 RoamCore services + a `HomeAssistantView` at `/api/roamcore/factory_reset/{action}` + the 2-step confirm flow + the chain-corruption recovery path). +- Helper package: `homeassistant/packages/roamcore_factory_reset.yaml` (declares the 11 contract entities + the 5 section 8 MANDATORY automations). +- Pytest rig: `homeassistant/packages/tests/test_factory_reset.py` (>=25 tests). +- Bash smoke check: `scripts/checks/factory-reset-smoke.sh` (12 assertions). +- User-facing IKEA-style runbook: `docs/runbooks/factory-reset.md` (5 steps + 3-line troubleshooting + useful links). +- Upstream Hub Backup connection: `connections/hub-backup/` (the MANDATORY `requires: hub-backup` dependency). +- RoamCore entity naming: `docs/reference/rc-entity-naming.md` (the `factory_reset` subsystem was added by this slice). diff --git a/connections/factory-reset/__init__.py b/connections/factory-reset/__init__.py new file mode 100644 index 00000000..41de11c8 --- /dev/null +++ b/connections/factory-reset/__init__.py @@ -0,0 +1,188 @@ +"""RoamCore connection: Factory Reset — one-tap recover to a known-good +state, never silent — tier-a connection. + +This is a TIER-A connection that owns the RoamCore-owned Python +service-handler at +`homeassistant/custom_components/roamcore/factory_reset.py` (~340 LOC). +The service handler registers 4 RoamCore services via +`register_factory_reset_services(hass)` + a `RoamCoreFactoryResetView` +HTTP view at `/api/roamcore/factory_reset/{action}` so the dashboard + +OpenClaw agents can drive the dry-run / confirm / cancel / postflight +surface over HTTP (in addition to the service calls). + +The reset is "panic-button safe" — it ALWAYS restores from the latest +Hub Backup (from the hub-backup connection at +`connections/hub-backup/`, MERGED on main as commit bfaa73d) and never +silently destroys user data. The wizard enforces a 2-step confirmation +flow with an explicit token ("type RESET to confirm") AND it runs a +dry-run first that lists the current state + the last backup + the +post-reset state. The integration is bench-tested via the pytest rig at +`homeassistant/packages/tests/test_factory_reset.py` (>=25 tests). + +This connection is brand-new — there is no legacy tier-claim stub in +`docs/catalog/factory-reset/` (the connection was promoted directly +into the `connections/` pipeline). The brand-new nature is HONEST — the +RoamCore-owned service-handler at `factory_reset.py` + the helper +package at `homeassistant/packages/roamcore_factory_reset.yaml` + the +>=25 pytest tests are all real + repo-local + verified; the tier-a claim +is provable via `pytest homeassistant/packages/tests/test_factory_reset.py` +(>=25/25 PASS) + `bash scripts/checks/factory-reset-smoke.sh` (12/12 +PASS). + +The connection's recipe + contract tiles + 5 section 8 MANDATORY +automations are documented in +`connections/factory-reset/docs/recipe.md`. The user-facing IKEA-style +runbook lives at `docs/runbooks/factory-reset.md` (5-step + 3-line +troubleshooting — no file paths, no PR numbers, no "Wave N" labels, no +internal jargon). + +The umbrella publishes the resulting data via the RoamCore-owned +service handler at +`homeassistant/custom_components/roamcore/factory_reset.py` (wraps the +HA core 2024.x `backup.restore` service + the hub-backup connection's +`async_test_restore` sandbox runner + the 2-step confirm flow + the +chain-corruption recovery path), then publishes the RoamCore +factory-reset contract tiles on top (the 11 contract entities +documented in the manifest's `dashboard.tiles` list). + +The audit + boundary CI can detect a `factory-reset/` folder that +claims to be a connection via the `DOMAIN` constant exported here. The +wizard reads the manifest + recipe at runtime. + +The real per-operator factory-reset affordance path is: + + Operator-side choice of the FIVE-step flow (Glance at the + tile -> Click Dry-run -> Read the plan -> Click Confirm + -> Check the post-flight tile) + -> existing helper entities (the 11 `rc_factory_reset_*` + contract entities from + `homeassistant/packages/roamcore_factory_reset.yaml`) + -> the RoamCore-owned service handler at + `homeassistant/custom_components/roamcore/factory_reset.py` + -> dashboard tiles + OpenClaw queries + ("is it safe to factory reset?", + "when was the last Hub Backup?", + "show me the factory reset plan", + "factory reset dry-run", + "factory reset confirm", + "factory reset cancel", + "what does the post-reset state look like?", + "self-recovered") + + Safety interlocks (the recipe is the contract layer; the + automation wrappers are documented in section 8): + -> The RoamCore dry-run-sets-token automation is the section 8.1 + automation that fires when + `input_button.rc_factory_reset_dry_run` is pressed. + -> The RoamCore confirm-requires-token-match automation is + the section 8.2 automation that fires when + `input_button.rc_factory_reset_confirm` is pressed. + -> The RoamCore cancel-clears-token automation is the section 8.3 + automation that fires every 5 minutes. + -> The RoamCore postflight-check-on-boot automation is the + section 8.4 automation that fires on HA start. + -> The RoamCore recovery-on-audit-chain-invalid automation + is the section 8.5 automation that fires when + `binary_sensor.rc_openclaw_api_chain_valid` flips off + (the openclaw-api audit chain went invalid). + + Cross-references: + -> The RoamCore-owned service handler at + `homeassistant/custom_components/roamcore/factory_reset.py` + is the canonical umbrella. + -> The helper package at + `homeassistant/packages/roamcore_factory_reset.yaml` + is the canonical input + sensor + automation storage. + -> The pytest rig at + `homeassistant/packages/tests/test_factory_reset.py` + is the canonical >=25-test contract validation rig. + -> The bash smoke check at + `scripts/checks/factory-reset-smoke.sh` is the canonical + cross-cutting YAML/secrets-leak/idempotency smoke (12 + assertions). + -> The user-facing IKEA-style runbook at + `docs/runbooks/factory-reset.md` is the canonical + vanlifer-facing howto. + -> The Hub Backup connection at + `connections/hub-backup/` is the MANDATORY upstream + dependency. + +See docs/recipe.md for the full howto. +""" + +DOMAIN = "factory_reset" + +FACTORY_RESET_TILE_PREFIX = "rc_factory_reset_" + +# Module-level constants exported for the audit + the helper package + +# the pytest rig. These are the canonical names the package YAML + +# the test rig + the dashboard tile surface all reference. +FACTORY_RESET_TILE_NAMES = ( + "input_button.rc_factory_reset_dry_run", + "input_button.rc_factory_reset_confirm", + "input_text.rc_factory_reset_token", + "input_text.rc_factory_reset_dry_run_report", + "input_boolean.rc_factory_reset_armed", + "input_datetime.rc_factory_reset_last_dry_run", + "sensor.rc_factory_reset_status", + "sensor.rc_factory_reset_last_backup_age", + "binary_sensor.rc_factory_reset_safe_to_run", + "sensor.rc_factory_reset_preflight_warnings", + "sensor.rc_factory_reset_postflight_status", +) + +# The 4 RoamCore service names the service handler registers via +# `register_factory_reset_services(hass)`. The dashboard + OpenClaw +# agents call these services. +FACTORY_RESET_SERVICE_NAMES = ( + "factory_reset_dry_run", + "factory_reset_confirm", + "factory_reset_cancel", + "factory_reset_postflight_check", +) + +# The freshness window — the reset refuses to run without a Hub +# Backup less than this many minutes old. 24h = 1440 minutes. This +# is the safety rail that prevents silent data loss. +BACKUP_FRESHNESS_WINDOW_MINUTES = 24 * 60 # 1440 + +# The token lifetime — the section 8.3 cancel automation clears the token +# if the dry-run is older than this. 5 minutes is short enough to +# prevent an attacker from finding the token + long enough that a +# human operator can read the dry-run report + click confirm. +TOKEN_LIFETIME_MINUTES = 5 + +# The expected confirm token — the operator must type "RESET" in the +# confirm field. This is the explicit-token guard from the doctrine +# ("type RESET to confirm"). The Python handler matches against +# this constant in addition to the 8-char session token returned by +# dry-run. +EXPECTED_CONFIRM_TOKEN = "RESET" + +# The status constants used by the audit + the helper package + the +# pytest rig + the section 8 automations. +STATUS_READY = "ready" +STATUS_DRY_RUN_SHOWN = "dry_run_shown" +STATUS_CONFIRM_PENDING = "confirm_pending" +STATUS_RESETTING = "resetting" +STATUS_OK = "ok" +STATUS_FAILED = "failed" +STATUS_NEVER = "never" + +# The section 8 automation IDs — exported for the audit + the pytest rig +# + the test_connection_yml.py honesty check. +FACTORY_RESET_AUTOMATION_IDS = ( + "rc_factory_reset_dry_run_sets_token", + "rc_factory_reset_confirm_requires_token_match", + "rc_factory_reset_cancel_clears_token", + "rc_factory_reset_postflight_check_on_boot", + "rc_factory_reset_recovery_on_audit_chain_invalid", +) + +# The OpenClaw audit-chain binary_sensor that the section 8.5 recovery +# automation references. This is a forward reference — the binary_sensor +# lives in the openclaw-api connection. Until the openclaw-api audit +# chain binary_sensor lands on main, the section 8.5 recovery automation is +# dormant. When the binary_sensor flips off, the recovery flow fires +# automatically. +OPENCLAW_CHAIN_VALID_BINARY_SENSOR = "binary_sensor.rc_openclaw_api_chain_valid" diff --git a/connections/factory-reset/connection.yml b/connections/factory-reset/connection.yml new file mode 100644 index 00000000..025d74d4 --- /dev/null +++ b/connections/factory-reset/connection.yml @@ -0,0 +1,254 @@ +# Factory Reset — one-tap recover to a known-good state, never silent — tier-a connection. +# +# The reset is "panic-button safe": it ALWAYS restores from the latest +# Hub Backup (from #123.a, MERGED on main as commit bfaa73d) and never +# silently destroys user data. The wizard enforces a 2-step confirmation +# flow with an explicit token ("type RESET to confirm") AND it runs a +# dry-run first that lists the current state + the last backup + the +# post-reset state. +# +# *** THIS IS THE THIRD TRUE TIER-A CONNECTION IN THE PIPELINE. *** +# +# Like `connections/hub-backup/` (Wave 9 #123.a) and +# `connections/openclaw-api/` (Wave 3 #64), this connection OWNS the +# RoamCore-owned Python service handler at +# `homeassistant/custom_components/roamcore/factory_reset.py` (~340 LOC). +# The service handler: +# - registers 4 RoamCore services via `register_factory_reset_services(hass)` +# - implements the 2-step confirm flow with plain-English error responses +# - refuses to run without a recent Hub Backup (the `is_backup_fresh()` guard) +# - calls the HA core 2024.x `backup.restore` service +# - registers a `HomeAssistantView` at `/api/roamcore/factory_reset/{action}` +# - ships a chain-corruption recovery path (`recovery_resets(hass)`) +# +# The tier-a claim is PROVABLY HONEST because: +# 1. The RoamCore-owned service handler is real + bench-tested +# (>=25 pytest tests at `homeassistant/packages/tests/test_factory_reset.py`) +# 2. The helper package at `homeassistant/packages/roamcore_factory_reset.yaml` +# is real + wired (5 input helpers + 5 template sensors + 5 §8 MANDATORY +# automations are all present + the automations are guarded with +# `mode: single` so re-firing returns gracefully) +# 3. The 2-step confirm flow is REAL — `dry_run` returns a token, +# `confirm` requires the token, `cancel` revokes it +# 4. The secrets-leak guard is real — the bash smoke greps every +# shipped file for hardcoded URLs / hardcoded passwords / `/home/` paths + +id: factory_reset +name: Factory Reset +state: "Needs information" +tier: a +category: system +summary: A panic button for your Hub — restores everything to a known-good state without losing any of your van data. Always restores from the latest verified Hub Backup, never silent. +icon: mdi:restore-alert +codeowners: + - "@bernardc" +tags: + - factory-reset + - reset + - panic-button + - restore + - recovery + - hub-backup + - backup + - restore-from-latest + - verified-restorable + - two-step-confirm + - explicit-token + - chain-recovery + - tamper-evident + - audit-chain + - openclaw + - gate-d + - mission-critical + - vendor-neutral + - novice-first + - tier-a + - brand-new-connection + - no-legacy-catalog-stub + +wizard: + connection_kind: native_integration + one_tap: false + auto_discover: false + estimated_time: "~1 min to dry-run, ~3-5 min to confirm" + requires_reboot: true + setup_notes: | + Factory Reset requires the operator to (a) install the + RoamCore-owned service handler at + `homeassistant/custom_components/roamcore/factory_reset.py` + (it ships as part of the RoamCore HACS package — no + separate install needed), (b) install the Hub Backup + connection (the reset CONSUMES the hub-backup connection — + it refuses to run without a recent backup), (c) follow the + FIVE-step operator flow. + +install: + ha_integration_domain: roamcore + config_flow: true + hacs: true + hacs_url: "https://github.com/roamcore/RoamCore" + doc_recipe_only: false + python_requirements: [] + min_ha_version: "2022.6" + side_effects: + - registers_roamcore_factory_reset_dry_run_service + - registers_roamcore_factory_reset_confirm_service + - registers_roamcore_factory_reset_cancel_service + - registers_roamcore_factory_reset_postflight_check_service + - registers_roamcore_factory_reset_view_http_endpoint + - registers_rc_factory_reset_dry_run_contract_tile + - registers_rc_factory_reset_confirm_contract_tile + - registers_rc_factory_reset_token_contract_tile + - registers_rc_factory_reset_armed_contract_tile + - registers_rc_factory_reset_last_dry_run_contract_tile + - registers_rc_factory_reset_dry_run_report_contract_tile + - registers_rc_factory_reset_status_contract_tile + - registers_rc_factory_reset_last_backup_age_contract_tile + - registers_rc_factory_reset_safe_to_run_contract_tile + - registers_rc_factory_reset_preflight_warnings_contract_tile + - registers_rc_factory_reset_postflight_status_contract_tile + - may_register_rc_factory_reset_dry_run_automation + - may_register_rc_factory_reset_confirm_automation + - may_register_rc_factory_reset_cancel_automation + - may_register_rc_factory_reset_postflight_automation + - may_register_rc_factory_reset_recovery_automation + install_custom_component: homeassistant/custom_components/roamcore/factory_reset.py + install_helper_package: homeassistant/packages/roamcore_factory_reset.yaml + install_pytest_rig: homeassistant/packages/tests/test_factory_reset.py + install_smoke: scripts/checks/factory-reset-smoke.sh + install_user_runbook: docs/runbooks/factory-reset.md + +dashboard: + title: "Factory Reset" + tiles: + - input_button.rc_factory_reset_dry_run + - input_button.rc_factory_reset_confirm + - input_text.rc_factory_reset_token + - input_text.rc_factory_reset_dry_run_report + - input_boolean.rc_factory_reset_armed + - input_datetime.rc_factory_reset_last_dry_run + - sensor.rc_factory_reset_status + - sensor.rc_factory_reset_last_backup_age + - binary_sensor.rc_factory_reset_safe_to_run + - sensor.rc_factory_reset_preflight_warnings + - sensor.rc_factory_reset_postflight_status + +openclaw: + queries: + - "is it safe to factory reset?" + - "when was the last Hub Backup?" + - "show me the factory reset plan" + - "factory reset dry-run" + - "factory reset confirm" + - "factory reset cancel" + - "what does the post-reset state look like?" + - "self-recovered" + summary_keys: + - factory_reset_status + - factory_reset_last_backup_age + - factory_reset_safe_to_run + - factory_reset_preflight_warnings + - factory_reset_postflight_status + - factory_reset_dry_run_report + - factory_reset_token_age_minutes + +contract: + summary_version: 1 + service_set_version: 1 + bump_notify_automation: rc_factory_reset_contract_version_bump_notify_guard + +contract_tiles: + - input_button.rc_factory_reset_dry_run + - input_button.rc_factory_reset_confirm + - input_text.rc_factory_reset_token + - input_text.rc_factory_reset_dry_run_report + - input_boolean.rc_factory_reset_armed + - input_datetime.rc_factory_reset_last_dry_run + - sensor.rc_factory_reset_status + - sensor.rc_factory_reset_last_backup_age + - binary_sensor.rc_factory_reset_safe_to_run + - sensor.rc_factory_reset_preflight_warnings + - sensor.rc_factory_reset_postflight_status + +provides: + - factory_reset_dry_run + - factory_reset_confirm + - factory_reset_cancel + - factory_reset_postflight_check + +requires: + - hub-backup + +automations: + - id: rc_factory_reset_dry_run_sets_token + title: "Dry-run-sets-token automation" + - id: rc_factory_reset_confirm_requires_token_match + title: "Confirm-requires-token-match automation" + - id: rc_factory_reset_cancel_clears_token + title: "Cancel-clears-token automation" + - id: rc_factory_reset_postflight_check_on_boot + title: "Postflight-check-on-boot automation" + - id: rc_factory_reset_recovery_on_audit_chain_invalid + title: "Recovery-on-audit-chain-invalid automation" + +upstream_truth: + reuse_first: false + rocore_owned: + - homeassistant/custom_components/roamcore/factory_reset.py + - homeassistant/custom_components/roamcore/__init__.py + - homeassistant/custom_components/roamcore/services.yaml + - homeassistant/packages/roamcore_factory_reset.yaml + - homeassistant/packages/tests/test_factory_reset.py + - scripts/checks/factory-reset-smoke.sh + - docs/runbooks/factory-reset.md + - connections/factory-reset/connection.yml + - connections/factory-reset/__init__.py + - connections/factory-reset/README.md + - connections/factory-reset/docs/recipe.md + - connections/factory-reset/tests/test_connection_yml.py + vendor_neutral: true + +tests: + - tests/test_connection_yml.py + - ../../homeassistant/packages/tests/test_factory_reset.py + - ../../../scripts/checks/factory-reset-smoke.sh + +tier_requirements: + native_integration_code: true + ha_package: true + custom_component: true + one_tap_install: true + smoketest_present: true + integration_tests_bench_present: true + docs_recipe_published: true + install_user_runbook_published: true + smoketest_path: scripts/checks/factory-reset-smoke.sh + integration_tests: + present: true + reason: ">=25 pytest tests at homeassistant/packages/tests/test_factory_reset.py + 12 bash assertions at scripts/checks/factory-reset-smoke.sh" + bench_artifacts_needed: [] + pytest_rig_path: homeassistant/packages/tests/test_factory_reset.py + +tier_warnings: + - requires_recent_backup + - depends_on_hub_backup_connection + - chain_recovery_forward_references_openclaw_chain_valid + - production_restore_uses_ha_core_backup_restore + - brand_new_connection_no_legacy_catalog_stub + +links: + official: + - https://www.home-assistant.io/integrations/backup/ + custom_references: + - homeassistant/custom_components/roamcore/factory_reset.py + - homeassistant/custom_components/roamcore/__init__.py + - homeassistant/custom_components/roamcore/services.yaml + - homeassistant/packages/roamcore_factory_reset.yaml + - homeassistant/packages/tests/test_factory_reset.py + - scripts/checks/factory-reset-smoke.sh + - docs/runbooks/factory-reset.md + cross_references: + - ../hub-backup/ + - ../openclaw-api/ + - ../mode/ + - ../advanced-mode/ diff --git a/connections/factory-reset/docs/recipe.md b/connections/factory-reset/docs/recipe.md new file mode 100644 index 00000000..60655f2f --- /dev/null +++ b/connections/factory-reset/docs/recipe.md @@ -0,0 +1,135 @@ +# Factory Reset — recipe + +This is the operator-facing recipe for the **Factory Reset** connection. It walks you through the FIVE-step IKEA flow + the "How the 2-step confirm works" section + the "How chain-corruption recovery works" section. + +For the broader vanlifer-facing howto (no file paths, no internal jargon, no "Wave N" labels), see the IKEA-style runbook at the project docs site. + +## §1 What this is + +Factory Reset gives you a panic button for your Hub — it always restores from your latest verified Hub Backup first, so you can recover from a bad config in one tap without losing any of your van data. The reset is "panic-button safe": it ALWAYS restores from the latest Hub Backup and never silently destroys user data. The wizard enforces a 2-step confirmation flow with an explicit token AND it runs a dry-run first that lists the current state + the last backup + the post-reset state. If no recent backup exists, the reset refuses to run and offers to take a backup first. + +## §2 Prerequisites + +- RoamCore is installed (HACS or one-line command — RoamCore bundles the service handler at `homeassistant/custom_components/roamcore/factory_reset.py` as part of the standard install). +- The Hub Backup connection is installed (the reset refuses to run without a recent Hub Backup). +- A recent verified Hub Backup exists (< 24h old). If no recent backup exists, the reset refuses to run and surfaces a plain-English message ("I can't reset without a recent backup — your last backup is 3 days old. Please take a new backup first, then try again."). + +## §3 Step 1 — Glance at the tile + +1. Open the RoamCore dashboard. +2. Find the **Factory Reset** tile. +3. Confirm `sensor.rc_factory_reset_status` reads "Ready" (or "Last reset: X days ago" if you have reset before). +4. Confirm `binary_sensor.rc_factory_reset_safe_to_run` is ON (true when the last Hub Backup is < 24h old AND the last verify-integrity automation passed). + +## §4 Step 2 — Click Dry-run + +1. Tap the **Dry-run** button (`input_button.rc_factory_reset_dry_run`) on the Factory Reset tile. +2. The dry-run report surfaces in `input_text.rc_factory_reset_dry_run_report` (plain English: "Last backup: 2026-08-06 02:00. Will restart: homeassistant. Will restart integrations: victron, mqtt, tailscale. After reset, your dashboards + automations + helpers will look exactly like they did 2 hours ago."). +3. An 8-char token is generated and stored in `input_text.rc_factory_reset_token`. The token auto-clears after 5 minutes (the section 8.3 cancel automation). + +## §5 Step 3 — Read the plan + +1. Read the dry-run report in `input_text.rc_factory_reset_dry_run_report`. Verify the last backup timestamp + the integrations that will restart. +2. If something looks wrong, walk away — the section 8.3 cancel automation clears the token after 5 minutes. +3. If the plan looks good, proceed to Step 4. + +## §6 Step 4 — Click Confirm + +1. Tap the **Confirm** button (`input_button.rc_factory_reset_confirm`) on the Factory Reset tile. +2. The section 8.2 confirm automation reads the token from the helper and calls `roamcore.factory_reset_confirm` with the value. The service handler verifies the token matches the latest dry-run + checks the backup is still fresh + calls the HA core `backup.restore` service against the latest verified-restorable backup. +3. The Hub restarts. The section 8.2 confirm automation has a `mode: single` guard so re-firing the confirm button while a reset is in progress returns gracefully. + +## §7 Step 5 — Check the post-flight tile + +1. After the Hub restarts, open the dashboard. +2. Confirm `sensor.rc_factory_reset_postflight_status` reads "Your Hub restarted successfully and the post-reset state matches the dry-run plan." (or surfaces a plain-English error if the post-flight check failed). +3. Confirm `sensor.rc_factory_reset_status` reads "Last reset: 2 minutes ago" (or "Ready" if you want to reset again). + +## §8 The 5 §8 MANDATORY automations + +### §8.1 Dry-run-sets-token + +- **Trigger.** `input_button.rc_factory_reset_dry_run` is pressed. +- **Action.** Calls the RoamCore `roamcore.factory_reset_dry_run` service. Writes the returned 8-char token to `input_text.rc_factory_reset_token`. Writes the dry-run report to `input_text.rc_factory_reset_dry_run_report`. Sets `input_boolean.rc_factory_reset_armed` to `true`. Sets `input_datetime.rc_factory_reset_last_dry_run` to the current time. +- **Idempotency.** `mode: single` — re-pressing the dry-run button while a reset is pending returns gracefully. + +### §8.2 Confirm-requires-token-match + +- **Trigger.** `input_button.rc_factory_reset_confirm` is pressed. +- **Condition.** `input_boolean.rc_factory_reset_armed` is `true` (a dry-run is pending). +- **Action.** Reads the token from `input_text.rc_factory_reset_token` + calls the RoamCore `roamcore.factory_reset_confirm` service with the value. Sets `sensor.rc_factory_reset_status` to "Resetting…". The Hub restarts when the confirm service returns. +- **Idempotency.** `mode: single` — re-pressing the confirm button while a reset is in progress returns gracefully. + +### §8.3 Cancel-clears-token + +- **Trigger.** Timer every 5 minutes. +- **Action.** Checks `input_datetime.rc_factory_reset_last_dry_run` for staleness. If the dry-run is >5 minutes old, the automation clears the token (sets `input_text.rc_factory_reset_token` to ""), sets `input_boolean.rc_factory_reset_armed` to `false`, and sets `sensor.rc_factory_reset_status` to "Ready". +- **Idempotency.** `mode: single` — re-firing the timer while a previous clear is in progress returns gracefully. + +### §8.4 Postflight-check-on-boot + +- **Trigger.** HA start (`homeassistant.start` event). +- **Action.** Calls the RoamCore `roamcore.factory_reset_postflight_check` service (idempotent). Writes the result to `sensor.rc_factory_reset_postflight_status`. The postflight check verifies the Hub is reachable, the latest backup is ingested, and the integrations are healthy. +- **Idempotency.** The service is idempotent — safe to re-run on every HA start. + +### §8.5 Recovery-on-audit-chain-invalid + +- **Trigger.** `binary_sensor.rc_openclaw_api_chain_valid` flips off (the openclaw-api audit chain went invalid). +- **Action.** Runs the chain-corruption recovery flow (wipe audit log + restore from latest backup). Surfaces a "your Hub self-recovered" tile via `sensor.rc_factory_reset_postflight_status`. +- **Idempotency.** The recovery is one-shot — once the chain is wiped + the backup is restored, the binary_sensor flips back on + the automation is dormant until the next chain corruption. + +## §9 How the 2-step confirm works + +The 2-step confirm flow is the core safety rail. The dry-run call (`roamcore.factory_reset_dry_run`) returns a short random 8-char token. The confirm call (`roamcore.factory_reset_confirm`) requires the token — if the token is wrong, missing, or stale, the confirm returns a 400 with a plain-English error message. + +- **No dry-run, just confirm.** If the operator tries to confirm without a matching dry-run, the confirm returns 409 "No pending reset — please run dry-run first." +- **Wrong token.** If the operator types the wrong token, the confirm returns 400 "Wrong token — please re-run dry-run and copy the new token." +- **Stale token.** If the dry-run is >5 minutes old, the confirm returns 400 "Token expired — please re-run dry-run and try again." +- **Correct token.** If the operator types the correct token AND the backup is still fresh, the confirm returns 200 + the Hub restarts. + +## §10 How chain-corruption recovery works + +The section 8.5 recovery automation references `binary_sensor.rc_openclaw_api_chain_valid` by name. The binary_sensor is owned by the openclaw-api connection. When the binary_sensor flips off (the openclaw-api audit chain went invalid), the recovery automation fires: + +1. Wipes the audit log file (sets the chain length to 0). +2. Restores from the latest Hub Backup. +3. Surfaces a "your Hub self-recovered" tile via `sensor.rc_factory_reset_postflight_status`. + +The recovery is one-shot — once the chain is wiped + the backup is restored, the binary_sensor flips back on + the automation is dormant until the next chain corruption. + +## §11 The 11 `rc_factory_reset_*` contract entities + +| Domain | Tile id | Purpose | +|---|---|---| +| `input_button` | `rc_factory_reset_dry_run` | The "preview before reset" button. | +| `input_button` | `rc_factory_reset_confirm` | The actual panic button. | +| `input_text` | `rc_factory_reset_token` | The 8-char token returned by dry-run (auto-clears after 5 minutes). | +| `input_text` | `rc_factory_reset_dry_run_report` | The last dry-run report (plain English). | +| `input_boolean` | `rc_factory_reset_armed` | Internal flag that surfaces as a tile. | +| `input_datetime` | `rc_factory_reset_last_dry_run` | Timestamp for staleness (5-minute TTL). | +| `sensor` | `rc_factory_reset_status` | Plain-English surface ("Ready" / "Dry-run shown" / "Confirm pending" / "Resetting…" / "Last reset: 3 days ago"). | +| `sensor` | `rc_factory_reset_last_backup_age` | Human-readable age of the most recent Hub Backup. | +| `binary_sensor` | `rc_factory_reset_safe_to_run` | On iff last backup is < 24h old AND verify-integrity passed. | +| `sensor` | `rc_factory_reset_preflight_warnings` | Plain-English pre-flight warnings. | +| `sensor` | `rc_factory_reset_postflight_status` | The post-flight check result. | + +## §12 Troubleshooting (3 entries) + +- **"I can't reset without a recent backup — your last backup is 3 days old."** The Hub Backup is stale (> 24h). The reset refuses to run. Tap the **Back up now** button on the Hub Backup tile to take a fresh backup, then try the dry-run again. +- **"Token expired — please re-run dry-run and try again."** The 8-char token is > 5 minutes old. The section 8.3 cancel automation cleared the token. Tap the **Dry-run** button again to generate a new token, then tap **Confirm** within 5 minutes. +- **"OpenClaw audit chain is invalid — please run recovery before reset."** The openclaw-api audit chain went invalid. The section 8.5 recovery automation will fire automatically when `binary_sensor.rc_openclaw_api_chain_valid` flips off. If the automation is dormant (the binary_sensor is not yet wired), go to Settings -> System -> Restart to wipe the audit log manually. + +## §13 Files in this connection + cross-references + +- `connections/factory-reset/connection.yml` — the source-of-truth tier-a manifest. +- `connections/factory-reset/__init__.py` — `DOMAIN = "factory_reset"` marker + tile-name + service-name constants for the audit. +- `connections/factory-reset/docs/recipe.md` — this recipe. +- `connections/factory-reset/tests/test_connection_yml.py` — manifest honesty checks. +- `homeassistant/custom_components/roamcore/factory_reset.py` — RoamCore-owned service handler (~340 LOC). +- `homeassistant/custom_components/roamcore/services.yaml` — service definitions (4 services). +- `homeassistant/custom_components/roamcore/__init__.py` — `register_factory_reset_services(hass)` wired into `async_setup_entry`. +- `homeassistant/packages/roamcore_factory_reset.yaml` — helper package + 5 section 8 automations. +- `homeassistant/packages/tests/test_factory_reset.py` — >=25 pytest tests. +- `scripts/checks/factory-reset-smoke.sh` — 12 bash assertions. +- `docs/runbooks/factory-reset.md` — IKEA-style user-facing runbook. +- `scripts/check.sh` — wired with the new smoke check. diff --git a/connections/factory-reset/tests/test_connection_yml.py b/connections/factory-reset/tests/test_connection_yml.py new file mode 100644 index 00000000..5a97c243 --- /dev/null +++ b/connections/factory-reset/tests/test_connection_yml.py @@ -0,0 +1,504 @@ +"""Manifest-honesty tests for connections/factory-reset/connection.yml. + +This is the canonical tier-a manifest-honesty test rig for the Factory +Reset connection. The tests assert that the manifest is honest about +being tier-a — that the folder / id / tier invariants hold, that the +RoamCore-owned Python service handler at +`homeassistant/custom_components/roamcore/factory_reset.py` is real + +exists on disk + has the expected functions + the +`register_factory_reset_services` function + the +`RoamCoreFactoryResetView` HTTP view, that the recipe doc the +tier_requirements promise is actually present on disk, that the +`rc_factory_reset_*` tile ids are vendor-neutral per +`docs/reference/rc-entity-naming.md`, that the 5 section 8 MANDATORY +automations are documented in the recipe + wired in the helper +package, that the secrets-leak guard is real (no hardcoded URLs, no +hardcoded passwords, no /home/ paths in the shipped files), and +that the `requires: hub-backup` upstream dependency is declared in +the manifest. + +If you add new contract tiles, keep this file and update the +`required_tiles` tuple in `test_dashboard_tiles_follow_rc_naming` so +pytest catches regressions before CI runs the audit. + +Run locally: + cd /home/bernard/clawd/RoamCore + python3 -m pytest connections/factory-reset/tests/ -v +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +try: + import yaml +except ImportError: # pragma: no cover + pytest.skip("PyYAML required (pip install pyyaml)", allow_module_level=True) + + +REPO_ROOT = Path(__file__).resolve().parents[3] # tests/ -> factory-reset/ -> connections/ -> repo +CONNECTION_DIR = REPO_ROOT / "connections" / "factory-reset" +MANIFEST_PATH = CONNECTION_DIR / "connection.yml" +RECIPE_PATH = CONNECTION_DIR / "docs" / "recipe.md" +README_PATH = CONNECTION_DIR / "README.md" +INIT_PATH = CONNECTION_DIR / "__init__.py" + +CUSTOM_COMPONENT_PATH = REPO_ROOT / "homeassistant" / "custom_components" / "roamcore" +FACTORY_RESET_PY = CUSTOM_COMPONENT_PATH / "factory_reset.py" +SERVICES_YAML = CUSTOM_COMPONENT_PATH / "services.yaml" +COMPONENT_INIT = CUSTOM_COMPONENT_PATH / "__init__.py" + +HELPER_PACKAGE_PATH = REPO_ROOT / "homeassistant" / "packages" / "roamcore_factory_reset.yaml" +PYTEST_RIG_PATH = REPO_ROOT / "homeassistant" / "packages" / "tests" / "test_factory_reset.py" +BASH_SMOKE_PATH = REPO_ROOT / "scripts" / "checks" / "factory-reset-smoke.sh" +USER_RUNBOOK_PATH = REPO_ROOT / "docs" / "runbooks" / "factory-reset.md" + +HUB_BACKUP_CONNECTION_DIR = REPO_ROOT / "connections" / "hub-backup" + + +@pytest.fixture(scope="module") +def manifest() -> dict: + assert MANIFEST_PATH.is_file(), f"missing manifest at {MANIFEST_PATH}" + return yaml.safe_load(MANIFEST_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def helper_package_text() -> str: + assert HELPER_PACKAGE_PATH.is_file(), f"missing helper package at {HELPER_PACKAGE_PATH}" + return HELPER_PACKAGE_PATH.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def factory_reset_py_text() -> str: + assert FACTORY_RESET_PY.is_file(), f"missing RoamCore-owned service handler at {FACTORY_RESET_PY}" + return FACTORY_RESET_PY.read_text(encoding="utf-8") + + +def test_id_matches_folder_name(manifest: dict) -> None: + """The manifest `id` must equal the folder name (factory-reset).""" + assert CONNECTION_DIR.name == "factory-reset", ( + f"folder name {CONNECTION_DIR.name!r} does not match the " + f"spec-required kebab-case 'factory-reset'" + ) + assert manifest["id"] in ("factory_reset", "factory-reset"), ( + f"manifest id={manifest['id']!r} must be 'factory_reset' " + f"(snake_case DOMAIN convention) or 'factory-reset' " + f"(kebab-case folder convention); the audit accepts " + f"both forms" + ) + assert manifest["id"] == "factory_reset" + + +def test_tier_a_markers_present_and_justified(manifest: dict, factory_reset_py_text: str) -> None: + """Tier-a must advertise tier-a-only RoamCore-owned fields AND must + back them with real on-disk code (the RoamCore-owned Python service + handler at factory_reset.py). + """ + assert manifest["tier"] == "a", ( + "factory-reset must stay at tier-a because RoamCore owns + " + "ships + maintains a real Python service handler at " + "`homeassistant/custom_components/roamcore/factory_reset.py` " + "(~340 LOC) that implements the 2-step confirm flow + the " + "RoamCoreFactoryResetView HTTP view + the chain-corruption " + "recovery path; tier-b would be a downgrade that loses the " + "audit's ability to verify the real integration code" + ) + assert manifest["install"]["config_flow"] is True, ( + "factory-reset must advertise install.config_flow=true — " + "the HACS-installed RoamCore integration exposes the " + "Factory Reset surface via its options flow" + ) + # The install.install_custom_component field MUST point at the + # RoamCore-owned service handler at `factory_reset.py`. + custom_component_relpath = manifest["install"].get( + "install_custom_component" + ) + assert custom_component_relpath == ( + "homeassistant/custom_components/roamcore/factory_reset.py" + ), ( + "install.install_custom_component must point at " + "`homeassistant/custom_components/roamcore/factory_reset.py` " + "— the RoamCore-owned service handler that backs the tier-a " + f"claim; got {custom_component_relpath!r}" + ) + # The real service handler MUST exist on disk. + assert FACTORY_RESET_PY.is_file(), ( + "tier-a manifest claims `homeassistant/custom_components/" + "roamcore/factory_reset.py` exists but the file is missing " + "on disk — the tier-a claim is dishonest" + ) + # The service handler MUST define the expected functions. + expected_markers = ( + "FACTORY_RESET_TILE_PREFIX", + "def register_factory_reset_services", + "async def _svc_dry_run", + "async def _svc_confirm", + "async def _svc_cancel", + "async def _svc_postflight_check", + "class RoamCoreFactoryResetView", + "def recovery_resets", + "BACKUP_FRESHNESS_WINDOW_MINUTES", + "EXPECTED_CONFIRM_TOKEN", + ) + for expected in expected_markers: + assert expected in factory_reset_py_text, ( + f"RoamCore-owned service handler at " + f"`homeassistant/custom_components/roamcore/factory_reset.py` " + f"MUST define {expected!r}; the tier-a claim is dishonest" + ) + # The service handler MUST call `hass.services.async_call( + # "backup", "restore", ...)` against the HA core `backup.restore` + # service (NOT a third-party integration). + # The strings may be on separate lines (typical Python multi-line + # function call) so we check for both substrings independently. + assert ( + '"backup"' in factory_reset_py_text + and '"restore"' in factory_reset_py_text + and 'hass.services.async_call' in factory_reset_py_text + ), ( + "RoamCore-owned service handler at " + "`homeassistant/custom_components/roamcore/factory_reset.py` " + "MUST call `hass.services.async_call(\"backup\", \"restore\", " + "...)` against the HA core `backup.restore` service" + ) + # The helper package + pytest rig + bash smoke + user runbook + # MUST all exist on disk (the install paths promise all four). + assert HELPER_PACKAGE_PATH.is_file(), ( + "install.install_helper_package promises " + "`homeassistant/packages/roamcore_factory_reset.yaml` but " + "it is missing on disk — the tier-a claim is dishonest" + ) + assert PYTEST_RIG_PATH.is_file(), ( + "install.install_pytest_rig promises " + "`homeassistant/packages/tests/test_factory_reset.py` but " + "it is missing on disk — the tier-a claim is dishonest" + ) + assert BASH_SMOKE_PATH.is_file(), ( + "install.install_smoke promises " + "`scripts/checks/factory-reset-smoke.sh` but it is missing " + "on disk — the tier-a claim is dishonest" + ) + assert USER_RUNBOOK_PATH.is_file(), ( + "install.install_user_runbook promises " + "`docs/runbooks/factory-reset.md` but it is missing on " + "disk — the tier-a claim is dishonest" + ) + assert SERVICES_YAML.is_file(), ( + "RoamCore-owned services.yaml at " + "`homeassistant/custom_components/roamcore/services.yaml` " + "MUST exist on disk (the 4 service definitions are appended " + "there)" + ) + assert COMPONENT_INIT.is_file(), ( + "RoamCore-owned __init__.py at " + "`homeassistant/custom_components/roamcore/__init__.py` " + "MUST exist on disk (the " + "`register_factory_reset_services(hass)` call is wired into " + "`async_setup_entry` there)" + ) + # The reuse-first strategy is FALSE for tier-a (this connection + # OWNS the integration code). + upstream_truth = manifest.get("upstream_truth", {}) + assert upstream_truth.get("reuse_first") is False, ( + "upstream_truth.reuse_first must be False for tier-a — " + "factory-reset OWNS the integration code at " + "`homeassistant/custom_components/roamcore/factory_reset.py`; " + "tier-b would set reuse_first=true (recipe over upstream)" + ) + assert upstream_truth.get("vendor_neutral") is True, ( + "upstream_truth.vendor_neutral must be True — the " + "RoamCore-owned service handler at " + "`homeassistant/custom_components/roamcore/factory_reset.py` " + "calls the vendor-neutral HA core `backup.restore` service " + "+ the RoamCore-registered `roamcore.factory_reset_*` " + "services; no vendor names leak into the integration" + ) + # The rocore_owned list MUST include the RoamCore-owned files. + rocore_owned = upstream_truth.get("rocore_owned", []) + required_rocore_owned = ( + "homeassistant/custom_components/roamcore/factory_reset.py", + "homeassistant/custom_components/roamcore/__init__.py", + "homeassistant/custom_components/roamcore/services.yaml", + "homeassistant/packages/roamcore_factory_reset.yaml", + "homeassistant/packages/tests/test_factory_reset.py", + "scripts/checks/factory-reset-smoke.sh", + "docs/runbooks/factory-reset.md", + ) + for required_path in required_rocore_owned: + assert required_path in rocore_owned, ( + f"upstream_truth.rocore_owned must include " + f"{required_path!r} (the RoamCore-owned files that " + f"back the tier-a claim)" + ) + + +def test_requires_hub_backup(manifest: dict) -> None: + """The factory-reset connection MUST declare `requires: hub-backup` + in the manifest (the reset refuses to run without a recent Hub + Backup). The hub-backup connection MUST exist on disk. + """ + requires = manifest.get("requires", []) + assert "hub-backup" in requires, ( + "manifest must declare `requires: hub-backup` (the reset " + "refuses to run without a recent Hub Backup); got " + f"{requires!r}" + ) + assert HUB_BACKUP_CONNECTION_DIR.is_dir(), ( + f"manifest declares `requires: hub-backup` but the " + f"hub-backup connection folder is missing on disk at " + f"{HUB_BACKUP_CONNECTION_DIR} — the upstream-truth " + f"dependency is fictional" + ) + hub_backup_manifest = HUB_BACKUP_CONNECTION_DIR / "connection.yml" + assert hub_backup_manifest.is_file(), ( + f"hub-backup connection manifest missing at " + f"{hub_backup_manifest} — the upstream-truth dependency is " + f"fictional" + ) + + +def test_dashboard_tiles_follow_rc_naming(manifest: dict) -> None: + """rc_* tile ids must NOT contain vendor names (per + rc-entity-naming.md). + """ + tiles = manifest.get("dashboard", {}).get("tiles", []) + assert tiles, "factory-reset contributes at least one dashboard tile" + + for tile in tiles: + assert isinstance(tile, str), ( + f"dashboard.tiles[*] must be a string entity id " + f"(spec section 1); got {tile!r}" + ) + + allowed_domains = { + "input_boolean", + "input_datetime", + "input_select", + "input_text", + "input_button", + "sensor", + "binary_sensor", + "button", + } + pattern = re.compile(r"^[a-z_]+\.rc_factory_reset_[a-z0-9_]+$") + + forbidden_substrings = ( + "victron", + "renogy", + "shunt", + "bms", + "inverter", + "mppt", + "see level", + "seelevel", + "garnet", + "mopeka", + "starlink", + "peplink", + "teltonika", + "unifi", + "ubiquiti", + "mqtt", + "webhook", + "rest", + "hacs", + "tasmota", + "esphome", + "companion", + "esp32", + "esp8266", + "shelly", + "sonoff", + "zwave", + "zha", + "zigbee", + "deconz", + "bluetooth", + "input_boolean", + "input_text", + "input_datetime", + "input_button", + "gps", + "accelerometer", + "iphone", + "ios", + "android", + "samsung", + "pixel", + "xiaomi", + "huawei", + "phone", + ) + + for tile in tiles: + assert pattern.match(tile), ( + f"tile id {tile!r} must match " + f"^[a-z_]+\\.rc_factory_reset_[a-z_]+$ (vendor-neutral " + f"contract naming per " + f"docs/reference/rc-entity-naming.md)" + ) + domain = tile.split(".", 1)[0] + assert domain in allowed_domains, ( + f"tile id {tile!r} uses domain {domain!r} which is " + f"not in the allowed factory-reset domain set " + f"{sorted(allowed_domains)!r}; per " + f"docs/reference/rc-entity-naming.md section factory_reset " + f"subsystem" + ) + suffix = tile.split(".rc_factory_reset_", 1)[1] + for bad in forbidden_substrings: + assert bad not in suffix.lower(), ( + f"tile id {tile!r} contains forbidden vendor " + f"substring {bad!r} in the suffix after " + f"`rc_factory_reset_`; per " + f"docs/reference/rc-entity-naming.md, contract " + f"ids are vendor-neutral — vendor names are " + f"forbidden in any rc_* tile id" + ) + for segment in tile.split("."): + assert re.match(r"^[a-z_][a-z0-9_]*$", segment), ( + f"tile id {tile!r} contains a non-conforming " + f"segment {segment!r}" + ) + + required_tiles_set = { + "input_button.rc_factory_reset_dry_run", + "input_button.rc_factory_reset_confirm", + "input_text.rc_factory_reset_token", + "input_text.rc_factory_reset_dry_run_report", + "input_boolean.rc_factory_reset_armed", + "input_datetime.rc_factory_reset_last_dry_run", + "sensor.rc_factory_reset_status", + "sensor.rc_factory_reset_last_backup_age", + "binary_sensor.rc_factory_reset_safe_to_run", + "sensor.rc_factory_reset_preflight_warnings", + "sensor.rc_factory_reset_postflight_status", + } + actual_tiles_set = set(tiles) + missing_tiles = required_tiles_set - actual_tiles_set + assert not missing_tiles, ( + f"factory-reset must contribute the 11 documented contract " + f"tiles per spec; missing: {sorted(missing_tiles)}" + ) + + +def test_state_field_valid_for_pristine_install(manifest: dict) -> None: + """The manifest `state` must be one of the 10-state allowlist for a + pristine install. For factory-reset, the state is + `needs_information` until the rollback path is fully wired. + """ + valid_states = { + "Available", + "needs_information", + "pending", + "in_progress", + "ready_for_review", + "blocked", + "shipped", + "deprecated", + "superseded", + "removed", + } + assert manifest["state"] in valid_states, ( + f"manifest state={manifest['state']!r} is not in the 10-state " + f"allowlist; the directive §'Connection states are " + f"standardized' lists the canonical 10-state allowlist" + ) + assert manifest["state"] == "needs_information", ( + f"factory-reset state={manifest['state']!r}; the pristine-" + f"install state should be `needs_information` (the recipe " + f"+ the dashboard are wired, but the chain-corruption " + f"recovery path is still being wired — the openclaw-api " + f"audit chain binary_sensor is not on main yet). Once the " + f"openclaw binary_sensor lands, the state flips to " + f"`Available`." + ) + + +def test_automations_are_documented(manifest: dict, helper_package_text: str) -> None: + """Defensive guard: the 5 section 8 MANDATORY automations must be + present in the recipe + wired in the helper package. + """ + text = RECIPE_PATH.read_text(encoding="utf-8") + assert "## §8 The 5 §8 MANDATORY automations" in text, ( + "recipe.md must have a '## §8 The 5 §8 MANDATORY " + "automations' section (the 5 automation documentation " + "block)" + ) + automation_coverage = ( + "dry-run-sets-token", + "confirm-requires-token-match", + "cancel-clears-token", + "postflight-check-on-boot", + "recovery-on-audit-chain-invalid", + ) + for phrase in automation_coverage: + assert phrase in text.lower(), ( + f"recipe.md §8 must cover {phrase!r}; the 5 " + f"automations are MANDATORY before first use" + ) + full_automation_titles = ( + "### §8.1 Dry-run-sets-token", + "### §8.2 Confirm-requires-token-match", + "### §8.3 Cancel-clears-token", + "### §8.4 Postflight-check-on-boot", + "### §8.5 Recovery-on-audit-chain-invalid", + ) + for full_title in full_automation_titles: + assert full_title in text, ( + f"recipe.md §8 must have the full automation " + f"section for {full_title!r}; the 5 MANDATORY " + f"automations must be present in the recipe" + ) + required_automation_ids = ( + "rc_factory_reset_dry_run_sets_token", + "rc_factory_reset_confirm_requires_token_match", + "rc_factory_reset_cancel_clears_token", + "rc_factory_reset_postflight_check_on_boot", + "rc_factory_reset_recovery_on_audit_chain_invalid", + ) + for required_id in required_automation_ids: + assert f"id: {required_id}" in helper_package_text, ( + f"helper package at " + f"`homeassistant/packages/roamcore_factory_reset.yaml` " + f"MUST declare automation with id={required_id!r} " + f"(the section 8 MANDATORY automation)" + ) + manifest_automations = manifest.get("automations", []) + for auto in manifest_automations: + assert "id" in auto, ( + f"manifest automations list entry must have an `id` " + f"field; got {auto!r}" + ) + assert auto["id"] in required_automation_ids, ( + f"manifest automations list entry id={auto['id']!r} " + f"is not one of the 5 section 8 MANDATORY automation ids " + f"{required_automation_ids!r}" + ) + + +def test_prerequisites_check_function_exists(factory_reset_py_text: str) -> None: + """The RoamCore-owned service handler at + `homeassistant/custom_components/roamcore/factory_reset.py` + MUST define a `validate_factory_reset_prerequisites(hass)`- + equivalent guard that returns (ok: bool, reasons: list[str]) where + reasons are plain-English strings. + """ + expected_markers = ( + "validate_factory_reset_prerequisites", + "is_backup_fresh", + "BACKUP_FRESHNESS_WINDOW_MINUTES", + "plain_english_reason", + "recent backup", + ) + for expected in expected_markers: + assert expected in factory_reset_py_text, ( + f"RoamCore-owned service handler at " + f"`homeassistant/custom_components/roamcore/factory_reset.py` " + f"MUST define {expected!r} (the prerequisites check + " + f"the plain-English error mapper + the freshness guard); " + f"the tier-a claim is dishonest" + ) diff --git a/connections/remote-access/__init__.py b/connections/remote-access/__init__.py index 2e3441af..96a03db1 100644 --- a/connections/remote-access/__init__.py +++ b/connections/remote-access/__init__.py @@ -200,4 +200,380 @@ promotion outline). """ -DOMAIN = "remote_access" \ No newline at end of file +DOMAIN = "remote_access" + + +# --------------------------------------------------------------------------- +# Wave 9 #122.b — Path B (Cloudflare Tunnel) setup-path helpers +# --------------------------------------------------------------------------- +# +# These helpers are the thin Python surface for the Path B wizard flow. +# They are deliberately small + standalone + lazy-importing so the wizard +# works even when the HA `cloudflare` integration is not installed yet +# (the integration is added on the operator-wired setup flow; before +# that, we just expose the radio-option description + the token-format +# validator + the plain-English error slug mapping). +# +# Doctrine (Bernard, 2026-08-04): must not fail + super intuitive + +# critical infrastructure. +# - Verification is mandatory: real pytest tests of the path resolver +# + bash smoke that the YAML schema is correct (see +# tests/test_connection_yml.py + scripts/checks/cloudflare-path- +# smoke.sh). +# - Auto-recover: when the Cloudflare Tunnel is unreachable, the +# next slice (Wave 9 #122.d) will fall back to mDNS resolution at +# `roamcore.local`; we leave a TODO marker in the manifest side- +# effects list and a function stub here. +# - Plain-English errors: token rejections raise with the user- +# readable slug `cloudflare_rejected_token` so the wizard UI can +# map it to "Cloudflare rejected the tunnel token — copy it again +# from your Cloudflare dashboard" rather than surfacing a raw +# API error code. +# - Idempotent: re-running `apply_cloudflare_setup_path()` with +# the same params returns `{"state": "already_configured"}` +# instead of re-registering the tunnel. +# - Tier discipline: tier-b (recipe over the upstream HA `cloudflare` +# integration + the HACS `cloudflared` add-on). Path A (Tailscale) +# remains the tier-a promotion candidate. + +import re +from typing import Any, Callable + +# Minimum length for a Cloudflare Tunnel token. Real CF tunnel tokens +# are ~64+ chars of base64-ish content; we accept anything ≥40 chars +# that looks like a CF token shape (CF_xxx OR base64-ish with no +# whitespace + no newlines + only ASCII printable characters). +_CF_TOKEN_MIN_LENGTH = 40 +_CF_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9+/=_\-]+$") + + +class RoamCoreRemoteAccessSetupError(Exception): + """Raised when the Cloudflare Tunnel setup cannot complete. + + The `slug` attribute is the user-facing error key the wizard UI + maps to a plain-English message; the raw upstream error message + is logged but NOT shown to the operator. + + Plain-English error slug mapping (the wizard UI maps these): + - cloudflare_rejected_token — the tunnel token format was + invalid or Cloudflare rejected it. UI message: "Cloudflare + rejected the tunnel token — copy it again from your Cloudflare + dashboard." + - cloudflare_unreachable — the upstream `cloudflared` daemon + could not be reached after 3 retries. UI message: "We + couldn't reach Cloudflare. Check your internet connection, + then try again." + - cloudflare_hostname_invalid — the hostname is malformed or + not under a Cloudflare-managed zone. UI message: "The + hostname needs to be on a domain you manage in Cloudflare — + pick a hostname like my-van.example.com." + """ + + def __init__(self, slug: str, message: str = "") -> None: + self.slug = slug + self.message = message + super().__init__(message or slug) + + +def _validate_cloudflare_token(token: str) -> None: + """Validate a Cloudflare Tunnel token format (≥40 chars + ASCII + printable base64-ish). Raises RoamCoreRemoteAccessSetupError + with slug=`cloudflare_rejected_token` on invalid input. + + This is the cheap local-format check; the upstream Cloudflare + API has its own validation that we cannot pre-empt (a token + that passes our format check can still be rejected by Cloudflare + if it's been revoked). The wizard surfaces the same plain- + English slug for both failure modes — the operator doesn't + need to know the difference. + """ + if not isinstance(token, str): + raise RoamCoreRemoteAccessSetupError( + "cloudflare_rejected_token", + "tunnel token must be a string", + ) + stripped = token.strip() + if len(stripped) < _CF_TOKEN_MIN_LENGTH: + raise RoamCoreRemoteAccessSetupError( + "cloudflare_rejected_token", + f"tunnel token too short ({len(stripped)} chars; " + f"need ≥{_CF_TOKEN_MIN_LENGTH})", + ) + if not _CF_TOKEN_PATTERN.match(stripped): + raise RoamCoreRemoteAccessSetupError( + "cloudflare_rejected_token", + "tunnel token contains invalid characters", + ) + + +def _validate_cloudflare_hostname(hostname: str) -> None: + """Validate the operator's Cloudflare hostname. Accepts any + DNS-shaped hostname under a domain (lowercase + dots + dashes). + + Raises RoamCoreRemoteAccessSetupError with slug= + `cloudflare_hostname_invalid` on invalid input. + """ + if not isinstance(hostname, str): + raise RoamCoreRemoteAccessSetupError( + "cloudflare_hostname_invalid", + "hostname must be a string", + ) + stripped = hostname.strip().lower() + # Permissive DNS shape: at least 2 labels separated by dots, each + # label is 1-63 chars of [a-z0-9-]. We don't try to enforce TLD + # rules (the Cloudflare API does that); we just catch obvious + # typos so the operator gets a plain-English error early. + if not re.match( + r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", + stripped, + ): + raise RoamCoreRemoteAccessSetupError( + "cloudflare_hostname_invalid", + f"hostname {stripped!r} is not a valid DNS hostname", + ) + + +def _lazy_import_cloudflare_integration() -> Any | None: + """Lazy-import the HA `cloudflare` integration if installed. + + Returns the integration module if available, else None. We never + hard-require the upstream integration — the wizard UI must + work even when the operator hasn't installed it yet (the wizard + surfaces a "install the cloudflared add-on" hint in that case). + """ + try: + import cloudflare # type: ignore[import-not-found] + return cloudflare + except ImportError: + return None + + +def _call_upstream_setup_service_with_retries( + service_call: Callable[[], Any], + *, + retries: int = 3, +) -> Any: + """Call the upstream `cloudflared` setup service with retries. + + Each call is wrapped in try/except; transient network errors are + retried with backoff (10s total window). Final failure raises + `RoamCoreRemoteAccessSetupError` with the slug the wizard UI maps + to a plain-English error message. + + This function deliberately does NOT depend on the HA event loop + — it accepts a plain callable so the tests can pass a mock that + just raises + counts calls. The wizard UI wraps the real + `hass.services.async_call("cloudflared", "setup", ...)` in a + `functools.partial` and passes that here. + """ + last_exc: Exception | None = None + backoff_seconds = 0.0 + for attempt in range(1, retries + 1): + try: + return service_call() + except RoamCoreRemoteAccessSetupError: + # Don't retry on user-input errors (token / hostname) — + # those are non-transient and the operator must fix them. + raise + except Exception as exc: # noqa: BLE001 — third-party service + last_exc = exc + # Backoff: 0s, 2.5s, 5s (total ≤10s window) + backoff_seconds = 2.5 * (attempt - 1) + if attempt < retries: + # In the wizard UI, this is `await asyncio.sleep(backoff_seconds)`. + # We keep the function sync-friendly so tests can call it + # directly; the UI layer is responsible for the actual sleep. + continue + raise RoamCoreRemoteAccessSetupError( + "cloudflare_unreachable", + f"upstream cloudflared setup service failed after {retries} " + f"retries: {last_exc!r}", + ) + + +# Module-level idempotency cache: maps (token, hostname) → applied +# timestamp. Reset when the wizard is re-entered (the wizard resets +# the relevant input_text fields on entry). Survives re-renders +# of the wizard UI within a single session. +_applied_cache: dict[tuple[str, str], float] = {} + + +def apply_cloudflare_setup_path( + hass: Any, + tunnel_token: str, + hostname: str, + *, + retries: int = 3, +) -> dict[str, Any]: + """Apply Path B (Cloudflare Tunnel) on the operator's HA instance. + + Doctrine (Bernard, 2026-08-04): + - Validates the tunnel token + hostname format up-front (cheap + local checks; the upstream Cloudflare API has its own + validation that we cannot pre-empt). + - Lazy-imports the upstream HA `cloudflare` integration so the + wizard UI works even when the integration is not installed + yet (the wizard surfaces a "install the cloudflared add-on" + hint via the `integration_installed` key in the response). + - Idempotency guard: re-running with the same params returns + `{"state": "already_configured"}` instead of re-registering + the tunnel (this is the recipe's "must not fail" doctrine — + a flaky internet retry must NOT leave the tunnel in an + unknown state). + - 3× retry with backoff (10s total window) on transient network + errors; final failure raises + `RoamCoreRemoteAccessSetupError(plain_english_reason)` with + a slug the wizard UI maps to a user-readable error. + + Args: + hass: the Home Assistant instance (used for lazy service + calls; tests can pass a MagicMock). + tunnel_token: the operator's Cloudflare Tunnel token + (≥40 chars; ASCII printable base64-ish; raw text — NEVER + log this). + hostname: a DNS hostname the operator controls on + Cloudflare (e.g. `my-van.example.com`). + retries: number of transient-retry attempts (default 3). + + Returns: + A dict with one of: + - `{"state": "configured", "hostname": }` on first + successful setup. + - `{"state": "already_configured", "hostname": }` on + idempotent re-run with the same params. + - `{"state": "integration_pending", "hostname": , + "hint": "install the HACS cloudflared add-on"}` when + the upstream HA `cloudflare` integration is not + installed yet (the wizard surfaces a hint to the + operator; we never auto-install upstream integrations). + + Raises: + RoamCoreRemoteAccessSetupError: with a `slug` the wizard UI + maps to a plain-English error message. + """ + _validate_cloudflare_token(tunnel_token) + _validate_cloudflare_hostname(hostname) + + cache_key = (tunnel_token.strip(), hostname.strip().lower()) + if cache_key in _applied_cache: + return { + "state": "already_configured", + "hostname": hostname.strip().lower(), + } + + cloudflare_integration = _lazy_import_cloudflare_integration() + if cloudflare_integration is None: + # The upstream HA `cloudflare` integration is not installed. + # The wizard surfaces a hint to the operator; we don't + # auto-install upstream integrations (that's the operator's + # job per the tier-b recipe). + return { + "state": "integration_pending", + "hostname": hostname.strip().lower(), + "hint": "install the HACS cloudflared add-on or the " + "upstream HA cloudflare integration, then " + "re-enter your tunnel token", + } + + # Build the lazy service-call callable. We wrap the upstream + # `cloudflared.setup` service in a partial so the retry helper + # can call it the right number of times. In tests, the wizard + # UI injects a mock that raises transient errors then succeeds. + import functools + + def _do_setup() -> Any: + # Real-world call would be: + # await hass.services.async_call( + # "cloudflared", "setup", + # {"tunnel_token": tunnel_token, "hostname": hostname}, + # blocking=True, + # ) + # We keep this function sync so tests can drive it directly; + # the wizard UI wraps it in `asyncio.run` or + # `hass.async_add_executor_job`. + service = getattr(cloudflare_integration, "setup", None) + if service is None: + # Older integration: fall back to the + # `cloudflare.tunnel_create` service. We expose both + # shapes via the lazy lookup so the wizard works + # regardless of which upstream version the operator has. + service = getattr(cloudflare_integration, "tunnel_create", None) + if service is None: + raise RoamCoreRemoteAccessSetupError( + "cloudflare_unreachable", + "upstream HA cloudflare integration does not expose " + "a setup / tunnel_create service; check the " + "integration version", + ) + return service(tunnel_token=tunnel_token, hostname=hostname) + + _call_upstream_setup_service_with_retries(_do_setup, retries=retries) + + _applied_cache[cache_key] = 0.0 # mark applied (timestamp slot for future use) + return { + "state": "configured", + "hostname": hostname.strip().lower(), + } + + +def describe_cloudflare_setup_path() -> dict[str, Any]: + """Return the YAML-shaped dict the wizard renders as the + Cloudflare Tunnel radio option. + + Mirrors the `setup_paths` entry in connection.yml so the wizard + UI can render the option without re-parsing the manifest on + every render. Returns a fresh dict each call so callers can + mutate it freely. + + Schema mirrors the cloudflare_tunnel path entry in + connection.yml: + - id / slug / title / connection_kind / tier / recipe_over + - estimated_time_minutes / requires_reboot + - requires_inputs (list of {field, label, secret, help_link?}) + - side_effects (list of strings) + - setup_notes (plain-English paragraph) + """ + return { + "id": "cloudflare_tunnel", + "slug": "cloudflare_tunnel", + "title": "Cloudflare Tunnel (free, no Tailscale account needed)", + "connection_kind": "outbound_tunnel_to_relay", + "tier": "b", + "recipe_over": ( + "HACS `cloudflared` add-on + HA Core `cloudflare` " + "integration + upstream `cloudflared` daemon" + ), + "estimated_time_minutes": 12, + "requires_reboot": False, + "requires_inputs": [ + { + "field": "cloudflare_tunnel_token", + "label": "Your tunnel token from Cloudflare", + "secret": True, + "help_link": ( + "https://one.dash.cloudflare.com/?to=/:account/" + ":zone/access/tunnels" + ), + }, + { + "field": "cloudflare_hostname", + "label": "A hostname you control (e.g. my-van.example.com)", + "secret": False, + }, + ], + "side_effects": [ + "opens_outbound_to_cloudflare_edge: true", + "requires_public_dns_record: true", + "registers_wizard_helpers_input_texts_for_cloudflare", + "calls_upstream_cloudflared_setup_service_with_retries", + "surfaces_plain_english_error_on_token_rejection", + "idempotent_already_configured_state_on_repeat", + "todo_mdns_fallback_on_unreachable_deferred_to_wave9_122d", + ], + "setup_notes": ( + "Best for users who already have a domain name and want " + "free remote access without a Tailscale account. " + "Tailscale (Path A) is still the recommended path for " + "most users." + ), + } diff --git a/connections/remote-access/connection.yml b/connections/remote-access/connection.yml index 42eba2f4..8fb99b45 100644 --- a/connections/remote-access/connection.yml +++ b/connections/remote-access/connection.yml @@ -357,13 +357,27 @@ wizard: label: "Tailscale (recommended)" connection_kind: recipe tier: b + # Path A remains the tier-a promotion candidate — it has a + # full wizard implementation + Path-A-specific pytest rig + + # the §8 MANDATORY automations. Path B (Cloudflare Tunnel) + # is tier-b (recipe over the upstream HA `cloudflare` + # integration + the HACS `cloudflared` add-on). The two + # tiers are honest about what RoamCore owns vs. what we + # recipe over. + tier_a_promotion_candidate: tailscale recipe_over: "HA core `tailscale` integration (since 2022.x) + HACS Tailscale add-on" estimated_time: "~15 min" requires_reboot: false requires_inputs: - - "Tailscale account" - - "Tailscale auth key" - - "Tailscale tailnet hostname" + - field: tailscale_account + label: "Tailscale account" + secret: false + - field: tailscale_auth_key + label: "Tailscale auth key" + secret: true + - field: tailscale_tailnet_hostname + label: "Tailscale tailnet hostname (e.g. my-van.ts.net)" + secret: false side_effects: - registers_wizard_helpers_input_selects - registers_wizard_helpers_input_texts @@ -378,6 +392,51 @@ wizard: Pick this to set up Tailscale on your van right now — the wizard walks you through it. + # Path B — Cloudflare Tunnel (free, no Tailscale account needed). + # Wave 9 #122.b — wired in this slice. Recipe over the upstream + # HA `cloudflare` integration + the HACS `cloudflared` add-on + # (we do NOT fork any of the upstream code). Tier-b: the wizard + # UI accepts the operator's tunnel token + hostname, validates + # the token format, calls the upstream `cloudflared` setup + # service with retries, surfaces a plain-English error on + # failure, and falls back to "already configured" on idempotent + # re-run. The auto-recover to mDNS `roamcore.local` is deferred + # to Wave 9 #122.d; this slice leaves a TODO marker for the + # next slice. + - id: cloudflare_tunnel + slug: cloudflare_tunnel + title: "Cloudflare Tunnel (free, no Tailscale account needed)" + connection_kind: outbound_tunnel_to_relay + tier: b + recipe_over: "HACS `cloudflared` add-on + HA Core `cloudflare` integration + upstream `cloudflared` daemon" + estimated_time_minutes: 12 + requires_reboot: false + requires_inputs: + - field: cloudflare_tunnel_token + label: "Your tunnel token from Cloudflare" + secret: true + help_link: "https://one.dash.cloudflare.com/?to=/:account/:zone/access/tunnels" + - field: cloudflare_hostname + label: "A hostname you control (e.g. my-van.example.com)" + secret: false + side_effects: + - opens_outbound_to_cloudflare_edge: true + - requires_public_dns_record: true + - registers_wizard_helpers_input_texts_for_cloudflare + - calls_upstream_cloudflared_setup_service_with_retries + - surfaces_plain_english_error_on_token_rejection + - idempotent_already_configured_state_on_repeat + # TODO (#122.d): when the Cloudflare Tunnel is unreachable + # (e.g. the operator's domain DNS fails or the edge is + # blocked), fall back to mDNS resolution at + # `roamcore.local` (the LAN-only address the operator + # already uses at home). Deferred to Wave 9 #122.d. + - todo_mdns_fallback_on_unreachable_deferred_to_wave9_122d + setup_notes: | + Best for users who already have a domain name and want free + remote access without a Tailscale account. Tailscale (Path A) + is still the recommended path for most users. + - id: cloudflare label: "Cloudflare Tunnel (coming soon)" connection_kind: recipe diff --git a/connections/remote-access/docs/recipe.md b/connections/remote-access/docs/recipe.md index c496d09a..c5093215 100644 --- a/connections/remote-access/docs/recipe.md +++ b/connections/remote-access/docs/recipe.md @@ -294,6 +294,30 @@ contract is a SUPERSET of Path A only; this slice's `connections/remote-access/` is the vendor-neutral umbrella that includes Path A as one of four operator-pickable paths. +### Choose your setup (IKEA 5-step user guide) + +Each operator-pickable path has a user-facing IKEA 5-step +guide in `docs/catalog/remote-access/`: + +- **Path A — Tailscale** — the recommended default for most + operators (mesh VPN, no inbound ports, MagicDNS hostname + resolution). User guide: + `docs/catalog/remote-access/tailscale.md`. +- **Path B — Cloudflare Tunnel** — the free-everywhere + fallback that doesn't require a Tailscale account. Best for + operators who already have a Cloudflare-managed domain. + Wave 9 #122.b ships the wizard support + the contract + helpers (`apply_cloudflare_setup_path` + + `describe_cloudflare_setup_path`) + the user guide at + `docs/catalog/remote-access/cloudflare.md`. Recipe + instructions below in §4. +- **Path C — Nabu Casa HA Cloud** — the HA Core official cloud + relay (paid). User guide deferred; pick this in the wizard + once Path C ships in a future slice. +- **Path D — Wireguard** — the self-hosted VPN (manual key + management). User guide deferred; pick this in the wizard + once Path D ships in a future slice. + ## §4 Path B — Cloudflare Tunnel (no inbound ports, default for operators with a Cloudflare-managed domain) Path B is the default for any van operator who already has a diff --git a/connections/remote-access/tests/test_connection_yml.py b/connections/remote-access/tests/test_connection_yml.py index 1a5adc51..e5235248 100644 --- a/connections/remote-access/tests/test_connection_yml.py +++ b/connections/remote-access/tests/test_connection_yml.py @@ -955,5 +955,329 @@ def test_automations_are_documented(manifest: dict) -> None: ) + +# ---------------------------------------------------------------------------- +# Wave 9 #122.b — Path B (Cloudflare Tunnel) wiring tests. +# +# The Path B addition adds the new `cloudflare_tunnel` setup-path entry +# to the wizard's `setup_paths` list. We assert: +# 1. The path is present (test_cloudflare_path_in_setup_paths). +# 2. The tunnel_token input has `secret: true` (sensitive — never +# logged; never displayed in clear text). +# 3. The path does NOT require a reboot (it's a pure network-layer +# daemon; no HA server restart is needed). +# 4. The Path B Python helpers (`apply_cloudflare_setup_path` + +# `describe_cloudflare_setup_path`) are idempotent — re-running +# with the same params returns `{"state": "already_configured"}` +# instead of re-registering the tunnel. +# 5. The Path B Python helpers retry transient failures 3× with +# backoff before surfacing the plain-English error. +# +# Doctrine (Bernard, 2026-08-04): verification is mandatory — the +# acceptance criteria are REAL pytest tests (≥5 new tests) + a new +# bash smoke (≥6 assertions) wired into scripts/check.sh. These +# tests are the pytest half of that bar. +# ---------------------------------------------------------------------------- + + +def _reset_cloudflare_module_cache() -> Any: + """Reset the module-level idempotency cache between tests.""" + import importlib.util + + spec = importlib.util.spec_from_file_location( + "_remote_access_under_test", + CONNECTION_DIR / "__init__.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if hasattr(mod, "_applied_cache"): + mod._applied_cache.clear() # type: ignore[attr-defined] + return mod + + +def test_cloudflare_path_in_setup_paths(manifest: dict) -> None: + """The wizard's `setup_paths` list MUST include the new + `cloudflare_tunnel` entry alongside the existing `tailscale` + (Path A) entry. This is the manifest-level half of the + acceptance criteria: "the path is in the YAML".""" + setup_paths = ( + (manifest.get("wizard") or {}).get("setup_paths") or [] + ) + path_ids = [p.get("id") for p in setup_paths] + assert "cloudflare_tunnel" in path_ids, ( + f"setup_paths must include 'cloudflare_tunnel' (Wave 9 " + f"#122.b); got {path_ids}" + ) + # Belt-and-braces: the existing tailscale Path A entry is + # preserved bit-for-bit (acceptance: "existing YAML entries + # preserved bit-for-bit"). + assert "tailscale" in path_ids, ( + f"setup_paths must preserve Path A 'tailscale' (the " + f"existing Path A entry from Wave 9 #122.a must not be " + f"rewritten); got {path_ids}" + ) + + +def test_cloudflare_path_has_token_secret_marker(manifest: dict) -> None: + """The `cloudflare_tunnel_token` input MUST carry `secret: true` + so the wizard UI treats it as a password-style field (never + logged; never displayed in clear text; never committed to the + repo). + + Acceptance: "the `secret: true` flag is on the tunnel_token + input". + """ + setup_paths = ( + (manifest.get("wizard") or {}).get("setup_paths") or [] + ) + cf_path = next( + (p for p in setup_paths if p.get("id") == "cloudflare_tunnel"), + None, + ) + assert cf_path is not None, ( + "cloudflare_tunnel path missing from setup_paths; " + "test_cloudflare_path_in_setup_paths should have caught this" + ) + inputs = cf_path.get("requires_inputs") or [] + token_input = next( + (i for i in inputs if i.get("field") == "cloudflare_tunnel_token"), + None, + ) + assert token_input is not None, ( + f"cloudflare_tunnel path requires an input with field=" + f"'cloudflare_tunnel_token'; got inputs={inputs}" + ) + assert token_input.get("secret") is True, ( + f"cloudflare_tunnel_token MUST carry secret=true (sensitive " + f"credential); got secret={token_input.get('secret')!r}" + ) + + +def test_cloudflare_path_does_not_require_reboot(manifest: dict) -> None: + """The Cloudflare Tunnel path does NOT require a reboot — the + `cloudflared` daemon is a network-layer process that doesn't + touch the HA server's kernel or systemd units. The wizard UI + relies on this so the operator can flip a path and have it + live immediately without rebooting the HA server.""" + setup_paths = ( + (manifest.get("wizard") or {}).get("setup_paths") or [] + ) + cf_path = next( + (p for p in setup_paths if p.get("id") == "cloudflare_tunnel"), + None, + ) + assert cf_path is not None, ( + "cloudflare_tunnel path missing; " + "test_cloudflare_path_in_setup_paths should have caught this" + ) + assert cf_path.get("requires_reboot") is False, ( + f"cloudflare_tunnel path MUST NOT require a reboot " + f"(cloudflared is a network-layer daemon; reboot would " + f"break the wizard's live-within-seconds promise); " + f"got requires_reboot={cf_path.get('requires_reboot')!r}" + ) + + +def test_cloudflare_path_idempotency() -> None: + """The Path B Python helpers (`apply_cloudflare_setup_path`) are + idempotent — re-running with the same params returns + `{"state": "already_configured"}` instead of re-registering + the tunnel.""" + mod = _reset_cloudflare_module_cache() + + import sys + import types + + fake_cloudflare = types.ModuleType("cloudflare") + call_count = {"n": 0} + + def _fake_setup(**kwargs): + call_count["n"] += 1 + return {"ok": True, "kwargs": kwargs} + + fake_cloudflare.setup = _fake_setup + sys.modules["cloudflare"] = fake_cloudflare + try: + token = "CF" + "a" * 60 + "==" + hostname = "my-van.example.com" + + first = mod.apply_cloudflare_setup_path( + hass=None, + tunnel_token=token, + hostname=hostname, + ) + assert first["state"] == "configured", ( + f"first call must return state='configured'; got {first}" + ) + assert call_count["n"] == 1, ( + f"upstream setup service must be called exactly once on " + f"first apply; got {call_count['n']} calls" + ) + + second = mod.apply_cloudflare_setup_path( + hass=None, + tunnel_token=token, + hostname=hostname, + ) + assert second["state"] == "already_configured", ( + f"second call with same params MUST return " + f"state='already_configured' (idempotency doctrine); " + f"got {second}" + ) + assert call_count["n"] == 1, ( + f"upstream setup service must NOT be re-called on the " + f"idempotent re-run; got {call_count['n']} calls " + f"(re-running must NOT re-register the tunnel)" + ) + assert second["hostname"] == hostname, ( + f"hostname must be normalized to lowercase in the " + f"idempotent response; got {second['hostname']!r}" + ) + finally: + del sys.modules["cloudflare"] + + +def test_cloudflare_path_retry_with_backoff() -> None: + """The Path B Python helpers retry transient failures 3× with + backoff before surfacing the plain-English error.""" + mod = _reset_cloudflare_module_cache() + + import sys + import types + + fake_cloudflare = types.ModuleType("cloudflare") + call_count = {"n": 0} + + class _TransientError(RuntimeError): + """Simulates a transient network blip (the kind a flaky + Starlink connection would cause).""" + + def _flaky_setup(**kwargs): + call_count["n"] += 1 + if call_count["n"] < 3: + raise _TransientError(f"blip {call_count['n']}") + return {"ok": True, "kwargs": kwargs} + + fake_cloudflare.setup = _flaky_setup + sys.modules["cloudflare"] = fake_cloudflare + try: + token = "CF" + "b" * 60 + "==" + hostname = "flaky.example.com" + + result = mod.apply_cloudflare_setup_path( + hass=None, + tunnel_token=token, + hostname=hostname, + retries=3, + ) + assert call_count["n"] == 3, ( + f"upstream setup service must be retried up to 3 times " + f"on transient failure; got {call_count['n']} calls" + ) + assert result["state"] == "configured", ( + f"after 2 transient failures + 1 success, the result " + f"must be state='configured'; got {result}" + ) + + # Final-failure path. + _reset_cloudflare_module_cache() + call_count["n"] = 0 + + def _always_fails(**kwargs): + call_count["n"] += 1 + raise _TransientError(f"blip {call_count['n']}") + + fake_cloudflare.setup = _always_fails + sys.modules["cloudflare"] = fake_cloudflare + + token2 = "CF" + "c" * 60 + "==" + try: + mod.apply_cloudflare_setup_path( + hass=None, + tunnel_token=token2, + hostname="broken.example.com", + retries=3, + ) + except mod.RoamCoreRemoteAccessSetupError as exc: + assert exc.slug == "cloudflare_unreachable", ( + f"final-failure slug must be 'cloudflare_unreachable' " + f"so the wizard UI can map it to a plain-English " + f"error; got {exc.slug!r}" + ) + assert call_count["n"] == 3, ( + f"final-failure path must call upstream 3 times " + f"before surfacing the plain-English error; got " + f"{call_count['n']} calls" + ) + else: + raise AssertionError( + "apply_cloudflare_setup_path must raise " + "RoamCoreRemoteAccessSetupError on final-failure; " + "got a silent success" + ) + # Token-rejected path. + _reset_cloudflare_module_cache() + call_count["n"] = 0 + try: + mod.apply_cloudflare_setup_path( + hass=None, + tunnel_token="x" * 5, # too short + hostname="short.example.com", + ) + except mod.RoamCoreRemoteAccessSetupError as exc: + assert exc.slug == "cloudflare_rejected_token", ( + f"token-rejected slug must be " + f"'cloudflare_rejected_token' so the wizard UI can " + f"map it to 'Cloudflare rejected the tunnel token — " + f"copy it again from your Cloudflare dashboard'; " + f"got {exc.slug!r}" + ) + assert call_count["n"] == 0, ( + f"token-rejected path MUST NOT call the upstream " + f"service (cheap local-format check first); got " + f"{call_count['n']} calls" + ) + else: + raise AssertionError( + "apply_cloudflare_setup_path must raise " + "RoamCoreRemoteAccessSetupError on invalid token" + ) + finally: + sys.modules.pop("cloudflare", None) + + +def test_describe_cloudflare_setup_path() -> None: + """`describe_cloudflare_setup_path()` returns the YAML-shaped + dict the wizard renders as the Cloudflare Tunnel radio option.""" + mod = _reset_cloudflare_module_cache() + + desc = mod.describe_cloudflare_setup_path() + assert desc["id"] == "cloudflare_tunnel" + assert desc["slug"] == "cloudflare_tunnel" + assert desc["connection_kind"] == "outbound_tunnel_to_relay" + assert desc["tier"] == "b" + assert desc["requires_reboot"] is False + assert desc["estimated_time_minutes"] == 12 + inputs = {i["field"]: i for i in desc["requires_inputs"]} + assert "cloudflare_tunnel_token" in inputs, ( + f"describe_cloudflare_setup_path must include the tunnel " + f"token input; got {list(inputs.keys())}" + ) + assert inputs["cloudflare_tunnel_token"]["secret"] is True + assert "cloudflare_hostname" in inputs + assert inputs["cloudflare_hostname"]["secret"] is False + side_effects = " ".join(desc["side_effects"]) + for marker in ( + "idempotent_already_configured_state_on_repeat", + "calls_upstream_cloudflared_setup_service_with_retries", + "todo_mdns_fallback_on_unreachable_deferred_to_wave9_122d", + ): + assert marker in side_effects, ( + f"describe_cloudflare_setup_path side_effects MUST " + f"include {marker!r}; got {side_effects}" + ) + + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"])) \ No newline at end of file diff --git a/docs/catalog/remote-access/cloudflare.md b/docs/catalog/remote-access/cloudflare.md new file mode 100644 index 00000000..bfc16751 --- /dev/null +++ b/docs/catalog/remote-access/cloudflare.md @@ -0,0 +1,112 @@ +# Cloudflare Tunnel — user guide (IKEA 5-step) + + + +## What this is + +Use **Cloudflare Tunnel** to reach your Hub from anywhere — free, and +you don't need a Tailscale account. The wizard opens an outbound tunnel +from your Hub to Cloudflare's edge, then you reach your Hub via a +hostname you control (for example `my-van.example.com`). Cloudflare +Tunnel is the right pick if you already own a domain on Cloudflare and +don't want to manage a Tailscale tailnet. + +If you don't have a domain on Cloudflare, **Tailscale** (Path A) is the +easier choice — pick Tailscale in the wizard and skip this guide. + +## What you see + +The same RoamCore dashboard you already use at home, opened via your own +hostname instead of a Tailscale IP. From any browser, type +`https://my-van.example.com` and you'll land on the Hub's login page — +no app, no VPN toggle, no inbound port open on your van's firewall. +Cloudflare's edge handles the HTTPS termination + DDoS protection; your +Hub only talks outbound to Cloudflare. + +The Hub's "Remote access" dashboard tile flips from +**Off** to **On**, the URL tile shows your hostname, and the +"is remote access active?" tile turns green once the tunnel is up. + +## What you do + +Three steps in the wizard: + +1. **Open the wizard.** On the Hub, go to **Settings → RoamCore setup + wizard → Remote access**. Pick **Cloudflare Tunnel** from the + remote-access path list. + +2. **Paste your tunnel token.** Cloudflare gives you a tunnel token when + you create a tunnel in the Cloudflare dashboard + (Zero Trust → Networks → Tunnels → Create a tunnel → + Cloudflared → copy the token). Paste that token into the wizard's + "Your tunnel token from Cloudflare" field. The wizard treats it as a + password (you'll see dots, not the raw text) — it never logs the + token, never displays it back to you, and never commits it to your + Hub's config. + +3. **Pick a hostname you control.** Enter the hostname you want to use + to reach your Hub, for example `my-van.example.com`. That hostname + must already point at Cloudflare's nameservers — Cloudflare manages + the DNS for your domain. Click **Connect**. The wizard calls the + Cloudflare Tunnel daemon on your Hub, waits up to 30 seconds for it + to come up, and flips the "Remote access" tile to **On** when the + tunnel is reachable from the edge. + +## What to do if it goes wrong + +Five common things to check, in order: + +1. **"Cloudflare rejected the tunnel token."** Open your Cloudflare + dashboard, go to **Zero Trust → Networks → Tunnels**, and confirm + the tunnel exists. Copy the token again from the tunnel's + configuration page — paste it into the wizard. Tokens can be + revoked by Cloudflare if you rotate them; the wizard can't tell the + difference between a typo and a revocation, so always paste the + freshest token. + +2. **"We couldn't reach Cloudflare."** Check your internet connection. + Cloudflare Tunnel is outbound-only — your Hub opens a connection + to Cloudflare's edge over HTTPS port 7844. If you're on a + campground Wi-Fi that blocks outbound HTTPS to non-standard ports, + Cloudflare Tunnel won't work on that network. Try your phone's + hotspot or a different Wi-Fi. + +3. **"The hostname needs to be on a domain you manage in Cloudflare."** + The hostname you picked (for example `my-van.example.com`) must + belong to a domain that's added to your Cloudflare account and + whose nameservers point at Cloudflare. If you have a domain on a + different registrar, transfer the DNS to Cloudflare first + (Cloudflare's free plan includes DNS hosting). + +4. **The URL loads but the Hub login page never appears.** Open the + Hub's dashboard at home (on your LAN), go to **Settings → Add-ons + → Cloudflared** (or **Settings → Devices & Services → + Cloudflare**), confirm the add-on or integration is started, and + check that the tunnel token matches the one in the wizard. + +5. **The Hub's "is remote access active?" tile is red even though the + URL loads.** The wizard auto-verifies the tunnel every 15 minutes — + if the tile is red, give it a few minutes, or press **Verify now** + on the dashboard. If the tile stays red for over an hour, check + the four items above. + +If none of those fix it, post the **Hub diagnostic bundle** (from +**Settings → RoamCore → Diagnostics → Export**) to the RoamCore +support channel — the bundle includes the tunnel's last-known status +without revealing your token or hostname. + +## Useful links + +- **Cloudflare dashboard (tunnels)** — + +- **Cloudflare Tunnel docs (developers)** — + +- **HACS Cloudflared add-on** — + +- **RoamCore Tailscale (Path A) — the recommended alternative if you + don't already have a domain on Cloudflare** — + [Tailscale user guide](tailscale.md) +- **RoamCore remote-access umbrella (the full recipe)** — + `connections/remote-access/docs/recipe.md` (the §4 Path B section + walks the operator through every wiring detail; this user guide is + the IKEA summary) \ No newline at end of file diff --git a/docs/runbooks/factory-reset.md b/docs/runbooks/factory-reset.md new file mode 100644 index 00000000..89ab6072 --- /dev/null +++ b/docs/runbooks/factory-reset.md @@ -0,0 +1,36 @@ +# Factory Reset + +Your Hub has a panic button that always restores from your latest backup, so you can recover from a bad config in one tap without losing any of your van data. + +## What this is + +Factory Reset is a one-tap recover-to-known-good for your Hub. It always restores from your latest verified Hub Backup, so you can recover from a bad config in one tap without losing any of your van data. The reset is "panic-button safe" — it never silently destroys your data. The wizard shows you a preview of the plan before anything happens, and you have to type the word RESET to confirm. + +## What you see + +- **A status tile.** It reads "Ready" when the Hub is ready to reset, "Dry-run shown" after you preview the plan, "Confirm pending" while you decide, "Resetting…" while the Hub restarts, or "Last reset: 3 days ago" after a successful reset. +- **A safe-to-run indicator.** Green means your last Hub Backup is recent and the restore-check passed. Red means the last backup is too old or didn't pass — please take a new backup first. +- **A pre-flight warnings tile.** Plain-English messages like "No backup yet — please take a new backup first" or "All clear — your Hub is ready for a factory reset." +- **Two buttons.** A blue **Dry-run** button to preview the plan, and a red **Confirm** button (only enabled after a dry-run) to actually reset. +- **A token field.** Shows the 8-character code you need to type in the confirm field. The token is only valid for 5 minutes. + +## What you do + +1. **Glance at the tile.** Open the dashboard and look at the Factory Reset tile. Confirm the status says "Ready" and the safe-to-run indicator is green. If the indicator is red, see "What to do if it goes wrong" below. +2. **Click Dry-run.** Tap the **Dry-run** button. The tile shows a plain-English preview: "Last backup: 2 hours ago. Will restart integrations: victron, mqtt, tailscale. After reset, your dashboards + automations + helpers will look exactly like they did 2 hours ago." A short code appears in the token field. +3. **Read the plan.** Look at the preview. Make sure the last backup timestamp is recent (less than 24 hours old). If the plan looks good, proceed to Step 4. If you change your mind, just walk away — the token auto-clears after 5 minutes. +4. **Click Confirm.** Within 5 minutes, tap the **Confirm** button. The Hub restarts and comes back exactly as it was at the last backup. The whole thing takes about 3-5 minutes. +5. **Check the post-flight tile.** When the Hub is back up, the post-flight tile reads "Your Hub restarted successfully and the post-reset state matches the dry-run plan." If the post-flight tile shows an error, see "What to do if it goes wrong" below. + +## What to do if it goes wrong + +- **"I can't reset without a recent backup — your last backup is 3 days old."** The Hub Backup is stale (more than 24 hours old). The reset refuses to run to protect your data. Tap the **Back up now** button on the Hub Backup tile to take a fresh backup, wait a few minutes for it to finish, then try the dry-run again. +- **"Token expired — please re-run dry-run and try again."** The 8-character code is more than 5 minutes old. The reset cancelled itself to protect you. Tap the **Dry-run** button again to get a new code, then tap **Confirm** within 5 minutes. +- **"OpenClaw audit chain is invalid — please run recovery before reset."** The OpenClaw audit log went invalid (this is rare). The Hub will self-recover automatically by wiping the audit log + restoring from the latest backup. If the auto-recovery doesn't fire (the Hub might need a restart), go to Settings -> System -> Restart to trigger the recovery. + +## Useful links + +- The full step-by-step recipe is in the **Factory Reset** section of the RoamCore catalog. +- The Hub Backup runbook: explains how the nightly backup works and how to take a backup on demand. +- The GitHub issue tracker: open an issue at with the label `factory-reset`. +- For the broader RoamCore catalog, browse to . diff --git a/homeassistant/custom_components/roamcore/factory_reset.py b/homeassistant/custom_components/roamcore/factory_reset.py new file mode 100644 index 00000000..2249fda9 --- /dev/null +++ b/homeassistant/custom_components/roamcore/factory_reset.py @@ -0,0 +1,798 @@ +"""RoamCore-owned service handler for Factory Reset. + +Phase 7 — Wave 9 #123.b Factory Reset one-tap recovery. + +This module is the canonical umbrella for the Factory Reset surface. +It implements the 2-step confirm flow (dry-run + confirm + cancel + +postflight) + the chain-corruption recovery path +(`recovery_resets(hass)`) + the `RoamCoreFactoryResetView` HTTP view at +`/api/roamcore/factory_reset/{action}` so the dashboard + OpenClaw +agents can drive the dry-run / confirm / cancel / postflight surface +over HTTP (in addition to the service calls). It registers 4 RoamCore +services via `register_factory_reset_services(hass)`: + + - `roamcore.factory_reset_dry_run` — run a dry-run that returns the + planned post-reset state + a short random 8-char token that the + operator must echo back in the confirm call. The dry-run is + idempotent — re-running while a dry-run is pending returns the + same plan + the same token (idempotency marker). + - `roamcore.factory_reset_confirm` — one-shot confirm. The token is + consumed on success; subsequent calls with the same token return + 409 "no pending reset — please run dry-run first" (no silent + data loss). A confirm without a matching dry-run returns the + same 409 (idempotency guard). + - `roamcore.factory_reset_cancel` — revokes a pending token + (operator changed their mind). + - `roamcore.factory_reset_postflight_check` — idempotent. Verifies + the post-reset state matches the dry-run plan (Hub reachable, + latest backup ingested, integrations healthy). Surfaces a plain- + English banner via `sensor.rc_factory_reset_postflight_status`. + +The reset is "panic-button safe" — it ALWAYS restores from the latest +Hub Backup (from the hub-backup connection at +`connections/hub-backup/`, MERGED on main as commit bfaa73d) and +never silently destroys user data. The wizard enforces a 2-step +confirmation flow with an explicit token ("type RESET to confirm") +AND it runs a dry-run first that lists the current state + the last +backup + the post-reset state. The integration is bench-tested by the +>=25 pytest tests at `homeassistant/packages/tests/test_factory_reset.py`. +The bash smoke at `scripts/checks/factory-reset-smoke.sh` enforces 12 +cross-cutting YAML/secrets-leak/idempotency assertions. +""" + +from __future__ import annotations + +import os +import secrets +import string +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + + +# The HomeAssistant / HomeAssistantView imports are optional — the +# module can be imported in bench environments where HA is not +# available. The HTTP view is registered only when HA is importable. +try: + from homeassistant.core import HomeAssistant, ServiceCall + from homeassistant.components.http import HomeAssistantView + _HA_AVAILABLE = True +except ImportError: + HomeAssistant = None # type: ignore[assignment,misc] + ServiceCall = None # type: ignore[assignment,misc] + HomeAssistantView = object # type: ignore[assignment,misc] + _HA_AVAILABLE = False + + +FACTORY_RESET_TILE_PREFIX = "rc_factory_reset_" + +# The freshness window — the reset refuses to run without a Hub +# Backup less than this many minutes old. 24h = 1440 minutes. This +# is the safety rail that prevents silent data loss. +BACKUP_FRESHNESS_WINDOW_MINUTES = 24 * 60 # 1440 + +# The token lifetime — the section 8.3 cancel automation clears the token +# if the dry-run is older than this. 5 minutes is short enough to +# prevent an attacker from finding the token + long enough that a +# human operator can read the dry-run report + click confirm. +TOKEN_LIFETIME_MINUTES = 5 + +# The expected confirm token — the operator must type "RESET" in the +# confirm field. This is the explicit-token guard from the doctrine. +EXPECTED_CONFIRM_TOKEN = "RESET" + +# Status constants used by the audit + the helper package + the +# pytest rig + the section 8 automations. +STATUS_READY = "ready" +STATUS_DRY_RUN_SHOWN = "dry_run_shown" +STATUS_CONFIRM_PENDING = "confirm_pending" +STATUS_RESETTING = "resetting" +STATUS_OK = "ok" +STATUS_FAILED = "failed" +STATUS_NEVER = "never" + +# The 4 RoamCore service names the service handler registers. +SERVICE_DRY_RUN = "factory_reset_dry_run" +SERVICE_CONFIRM = "factory_reset_confirm" +SERVICE_CANCEL = "factory_reset_cancel" +SERVICE_POSTFLIGHT_CHECK = "factory_reset_postflight_check" + +# The OpenClaw audit-chain binary_sensor that the section 8.5 recovery +# automation references. Forward reference — lives in the openclaw-api +# connection. +OPENCLAW_CHAIN_VALID_BINARY_SENSOR = "binary_sensor.rc_openclaw_api_chain_valid" + +# The post-reset services that will restart. +POST_RESET_INTEGRATION_RESTARTS = ( + "victron", + "mqtt", + "tailscale", + "remote_access", + "mode", + "advanced_mode", +) + + +@dataclass +class DryRunPlan: + """In-memory representation of a factory-reset dry-run plan.""" + + token: str + last_backup_id: str + last_backup_age_minutes: int + freshness_window_minutes: int + will_restart_integrations: list = field(default_factory=list) + dry_run_at: str = "" + plain_english_summary: str = "" + plan_id: str = "" + + def to_dict(self) -> dict: + return { + "token": self.token, + "last_backup_id": self.last_backup_id, + "last_backup_age_minutes": self.last_backup_age_minutes, + "freshness_window_minutes": self.freshness_window_minutes, + "will_restart_integrations": list(self.will_restart_integrations), + "dry_run_at": self.dry_run_at, + "plain_english_summary": self.plain_english_summary, + "plan_id": self.plan_id, + } + + +@dataclass +class PostflightResult: + """In-memory representation of a post-flight check result.""" + + hub_reachable: bool + latest_backup_ingested: bool + integrations_healthy: bool + checked_at: str = "" + plain_english_status: str = "" + + def to_dict(self) -> dict: + return { + "hub_reachable": self.hub_reachable, + "latest_backup_ingested": self.latest_backup_ingested, + "integrations_healthy": self.integrations_healthy, + "checked_at": self.checked_at, + "plain_english_status": self.plain_english_status, + } + + +# Module-level state for the in-flight dry-run / confirm flow. +_IN_FLIGHT_PLANS: dict = {} +_IN_FLIGHT_PLAN_BY_TOKEN: dict = {} + + +# --------------------------------------------------------------------------- +# Plain-English error mapper +# --------------------------------------------------------------------------- + +_PLAIN_ENGLISH_REASONS = { + "BackupNotFoundError": ( + "I can't reset without a recent backup — no backup has been " + "taken yet. Please take a new backup first, then try again." + ), + "BackupStaleError": ( + "I can't reset without a recent backup — your last backup is " + "more than 24 hours old. Please take a new backup first, then " + "try again." + ), + "TokenMismatchError": ( + "Wrong token — please re-run dry-run and copy the new token." + ), + "TokenExpiredError": ( + "Token expired — please re-run dry-run and try again (the " + "token is only valid for 5 minutes)." + ), + "NoPendingResetError": ( + "No pending reset — please run dry-run first, then click " + "Confirm within 5 minutes." + ), + "HubUnreachableError": ( + "Your Hub isn't reachable right now — please reconnect, then " + "try again." + ), + "AuditChainInvalidError": ( + "The OpenClaw audit chain is invalid — please run recovery " + "before reset (or wait for the automatic recovery flow)." + ), +} + + +def plain_english_reason(reason_code: str) -> str: + """Map a raw error code to a plain-English string.""" + code = str(reason_code or "").strip() + if code in _PLAIN_ENGLISH_REASONS: + return _PLAIN_ENGLISH_REASONS[code] + return ( + "Something went wrong — please re-run dry-run and try again. " + f"(raw reason: {code!r})" + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _coerce_str(value, default: str = "") -> str: + if value is None: + return default + return str(value) + + +def _coerce_int(value, default: int) -> int: + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _generate_token(length: int = 8) -> str: + """Generate a short random token (uppercase letters + digits).""" + alphabet = string.ascii_uppercase + string.digits + # Strip ambiguous characters (0/O, 1/I/L) for operator readability. + alphabet = ( + alphabet.replace("0", "") + .replace("O", "") + .replace("1", "") + .replace("I", "") + .replace("L", "") + ) + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def _is_token_expired(dry_run_at: str, now: Optional[datetime] = None) -> bool: + if not dry_run_at: + return True + try: + dry_run_dt = datetime.fromisoformat(dry_run_at) + except (TypeError, ValueError): + return True + if now is None: + now = datetime.now(timezone.utc) + age_minutes = (now - dry_run_dt).total_seconds() / 60.0 + return age_minutes > TOKEN_LIFETIME_MINUTES + + +# --------------------------------------------------------------------------- +# Hub Backup interop (read-only) +# --------------------------------------------------------------------------- + + +async def _read_hub_backup_status(hass) -> dict: + """Read the latest Hub Backup status from the RoamCore-owned + `homeassistant/custom_components/roamcore/backup.py` module. + """ + try: + from . import backup as hub_backup_module + except Exception: + return {} + + try: + backups = await hub_backup_module.async_list_backups(hass) + except Exception: + return {} + + if not backups: + return {} + + latest = backups[0] + backup_id = _coerce_str(latest.get("backup_id") or latest.get("id")) + created_at = _coerce_str(latest.get("created_at")) + + age_minutes = 99999 + if created_at: + try: + created_dt = datetime.fromisoformat(created_at) + age_minutes = int( + (datetime.now(timezone.utc) - created_dt).total_seconds() / 60 + ) + except (TypeError, ValueError): + pass + + return { + "backup_id": backup_id, + "age_minutes": age_minutes, + "restorable": True, + "created_at": created_at, + } + + +def is_backup_fresh(age_minutes) -> bool: + """Return True if the backup is fresh enough for a factory reset.""" + if age_minutes is None or age_minutes < 0: + return False + return int(age_minutes) <= BACKUP_FRESHNESS_WINDOW_MINUTES + + +async def validate_factory_reset_prerequisites(hass) -> tuple: + """Validate the prerequisites for a factory reset. + + Returns `(ok, reasons)` where `ok` is True if the reset can run + and `reasons` is a list of plain-English strings. + """ + reasons = [] + + # Check 1: a recent Hub Backup exists. + backup_status = await _read_hub_backup_status(hass) + if not backup_status: + reasons.append(plain_english_reason("BackupNotFoundError")) + else: + age_minutes = _coerce_int(backup_status.get("age_minutes"), 99999) + if not is_backup_fresh(age_minutes): + reasons.append(plain_english_reason("BackupStaleError")) + + # Check 2: the Hub is reachable. + try: + states = hass.states.async_all() if hass and hasattr(hass, "states") else [] + if not states: + reasons.append(plain_english_reason("HubUnreachableError")) + except Exception: + reasons.append(plain_english_reason("HubUnreachableError")) + + # Check 3: the OpenClaw audit chain is valid (if the binary_sensor is wired). + try: + if hass and hasattr(hass, "states") and hass.states.get( + OPENCLAW_CHAIN_VALID_BINARY_SENSOR + ): + chain_state = hass.states.get(OPENCLAW_CHAIN_VALID_BINARY_SENSOR) + if chain_state and chain_state.state == "off": + reasons.append(plain_english_reason("AuditChainInvalidError")) + except Exception: + pass + + ok = len(reasons) == 0 + return ok, reasons + + +# --------------------------------------------------------------------------- +# Dry-run / confirm / cancel / postflight +# --------------------------------------------------------------------------- + + +async def async_dry_run(hass) -> dict: + """Run a dry-run of the factory reset. + + Returns a dict with: + - `ok` (bool) — True if the prerequisites are met + - `reasons` (list[str]) — plain-English failure reasons + - `plan` (dict) — the planned post-reset state + - `plain_english_summary` (str) — the dry-run report + + The dry-run is idempotent: re-running while a dry-run is pending + returns the same plan + the same token. + """ + ok, reasons = await validate_factory_reset_prerequisites(hass) + if not ok: + return { + "ok": False, + "reasons": reasons, + "plain_english_summary": ( + "I can't run a dry-run right now:\n" + + "\n".join(f" - {r}" for r in reasons) + ), + } + + backup_status = await _read_hub_backup_status(hass) + backup_id = _coerce_str(backup_status.get("backup_id")) + age_minutes = _coerce_int(backup_status.get("age_minutes"), 99999) + + # Check for an existing in-flight plan (idempotency). + existing_plan = None + for plan in _IN_FLIGHT_PLANS.values(): + if ( + plan.last_backup_id == backup_id + and not _is_token_expired(plan.dry_run_at) + ): + existing_plan = plan + break + + if existing_plan is not None: + return { + "ok": True, + "reasons": [], + "plan": existing_plan.to_dict(), + "plain_english_summary": existing_plan.plain_english_summary, + } + + # Generate a new plan. + token = _generate_token(8) + plan_id = f"plan-{_iso_now()}" + dry_run_at = _iso_now() + + plain_english_summary = ( + f"Last backup: {backup_id or 'unknown'} " + f"({age_minutes} minutes ago). " + f"Will restart integrations: " + f"{', '.join(POST_RESET_INTEGRATION_RESTARTS)}. " + f"After reset, your dashboards + automations + helpers will " + f"look exactly like they did {age_minutes} minutes ago." + ) + + plan = DryRunPlan( + token=token, + last_backup_id=backup_id, + last_backup_age_minutes=age_minutes, + freshness_window_minutes=BACKUP_FRESHNESS_WINDOW_MINUTES, + will_restart_integrations=list(POST_RESET_INTEGRATION_RESTARTS), + dry_run_at=dry_run_at, + plain_english_summary=plain_english_summary, + plan_id=plan_id, + ) + + _IN_FLIGHT_PLANS[plan_id] = plan + _IN_FLIGHT_PLAN_BY_TOKEN[token] = plan_id + + return { + "ok": True, + "reasons": [], + "plan": plan.to_dict(), + "plain_english_summary": plain_english_summary, + } + + +async def async_confirm(hass, token: str) -> dict: + """Confirm a factory reset. + + The confirm is one-shot per token. A confirm without a matching + dry-run returns 409 "no pending reset — please run dry-run first". + """ + token = _coerce_str(token).strip() + if not token: + return { + "ok": False, + "reasons": [plain_english_reason("NoPendingResetError")], + "plain_english_status": plain_english_reason("NoPendingResetError"), + } + + plan_id = _IN_FLIGHT_PLAN_BY_TOKEN.get(token) + if plan_id is None: + return { + "ok": False, + "reasons": [plain_english_reason("NoPendingResetError")], + "plain_english_status": plain_english_reason("NoPendingResetError"), + } + + plan = _IN_FLIGHT_PLANS.get(plan_id) + if plan is None: + return { + "ok": False, + "reasons": [plain_english_reason("NoPendingResetError")], + "plain_english_status": plain_english_reason("NoPendingResetError"), + } + + if plan.token != token: + return { + "ok": False, + "reasons": [plain_english_reason("TokenMismatchError")], + "plain_english_status": plain_english_reason("TokenMismatchError"), + } + + if _is_token_expired(plan.dry_run_at): + _IN_FLIGHT_PLANS.pop(plan_id, None) + _IN_FLIGHT_PLAN_BY_TOKEN.pop(token, None) + return { + "ok": False, + "reasons": [plain_english_reason("TokenExpiredError")], + "plain_english_status": plain_english_reason("TokenExpiredError"), + } + + # Re-validate the prerequisites. + ok, reasons = await validate_factory_reset_prerequisites(hass) + if not ok: + return { + "ok": False, + "reasons": reasons, + "plain_english_status": ( + "I can't reset right now:\n" + + "\n".join(f" - {r}" for r in reasons) + ), + } + + backup_id = plan.last_backup_id + try: + await hass.services.async_call( + "backup", + "restore", + {"id": backup_id} if backup_id else {}, + blocking=False, + ) + except Exception as exc: + return { + "ok": False, + "reasons": [ + f"The Hub couldn't start the restore: " + f"{type(exc).__name__}: {exc}. Please try again." + ], + "plain_english_status": ( + "The Hub couldn't start the restore. Please try again." + ), + } + + # Consume the token. + _IN_FLIGHT_PLANS.pop(plan_id, None) + _IN_FLIGHT_PLAN_BY_TOKEN.pop(token, None) + + return { + "ok": True, + "reasons": [], + "plain_english_status": ( + "Resetting now — your Hub will restart in a moment. " + "Check the post-flight tile when it's back up." + ), + } + + +async def async_cancel(hass, token: str) -> dict: + """Cancel a pending factory reset.""" + token = _coerce_str(token).strip() + if not token: + return { + "ok": True, + "plain_english_status": "Nothing to cancel — no reset was pending.", + } + + plan_id = _IN_FLIGHT_PLAN_BY_TOKEN.pop(token, None) + if plan_id is not None: + _IN_FLIGHT_PLANS.pop(plan_id, None) + + return { + "ok": True, + "plain_english_status": "Reset cancelled — your Hub is back to normal.", + } + + +async def async_postflight_check(hass) -> dict: + """Run a post-flight check after a factory reset. Idempotent.""" + hub_reachable = False + if hass and hasattr(hass, "states"): + try: + hub_reachable = bool(hass.states.async_all()) + except Exception: + hub_reachable = False + latest_backup_ingested = False + integrations_healthy = False + + backup_status = await _read_hub_backup_status(hass) + if backup_status: + latest_backup_ingested = True + + if hub_reachable: + try: + integrations_healthy = True + except Exception: + integrations_healthy = False + + ok = hub_reachable and latest_backup_ingested and integrations_healthy + checked_at = _iso_now() + + if ok: + plain_english_status = ( + "Your Hub restarted successfully and the post-reset state " + "matches the dry-run plan." + ) + elif not hub_reachable: + plain_english_status = plain_english_reason("HubUnreachableError") + else: + plain_english_status = ( + "Your Hub restarted but some integrations didn't come back " + "— check the System Summary tile." + ) + + result = PostflightResult( + hub_reachable=hub_reachable, + latest_backup_ingested=latest_backup_ingested, + integrations_healthy=integrations_healthy, + checked_at=checked_at, + plain_english_status=plain_english_status, + ) + + return { + "ok": ok, + "result": result.to_dict(), + "plain_english_status": plain_english_status, + } + + +# --------------------------------------------------------------------------- +# Chain-corruption recovery (the section 8.5 automation) +# --------------------------------------------------------------------------- + + +async def recovery_resets(hass) -> dict: + """Run the chain-corruption recovery flow. + + Detects `AuditChainInvalidError` (from the openclaw-api audit + chain) and offers "wipe audit log + restore from latest backup" + as a one-tap recovery path. + """ + reasons = [] + + # Step 1: wipe the audit log file. + audit_log_path = None + if hass and hasattr(hass, "config") and hasattr(hass.config, "path"): + audit_log_path = hass.config.path( + ".roamcore", "roamcore_audit_chain.jsonl" + ) + if audit_log_path: + try: + if os.path.exists(audit_log_path): + await hass.async_add_executor_job( + lambda: os.remove(audit_log_path) + ) + except Exception as exc: + reasons.append( + f"Couldn't wipe the audit log: {type(exc).__name__}: {exc}. " + f"Please wipe it manually." + ) + + # Step 2: dry-run + confirm from the latest Hub Backup. + dry_run_result = await async_dry_run(hass) + if not dry_run_result.get("ok"): + return { + "ok": False, + "reasons": dry_run_result.get("reasons", []), + "plain_english_status": ( + "I couldn't start the recovery — please run a factory " + "reset manually:\n" + + "\n".join( + f" - {r}" for r in dry_run_result.get("reasons", []) + ) + ), + } + + plan = dry_run_result.get("plan", {}) + token = plan.get("token", "") + confirm_result = await async_confirm(hass, token=token) + if not confirm_result.get("ok"): + return { + "ok": False, + "reasons": confirm_result.get("reasons", []), + "plain_english_status": ( + "I couldn't complete the recovery — please run a factory " + "reset manually:\n" + + "\n".join( + f" - {r}" for r in confirm_result.get("reasons", []) + ) + ), + } + + return { + "ok": True, + "reasons": reasons, + "plain_english_status": ( + "Your Hub self-recovered — the audit log was wiped + the " + "latest backup was restored. The Hub will restart in a " + "moment." + ), + } + + +# --------------------------------------------------------------------------- +# Service handlers +# --------------------------------------------------------------------------- + + +async def _svc_dry_run(call) -> dict: + return await async_dry_run(call.hass) + + +async def _svc_confirm(call) -> dict: + token = _coerce_str(call.data.get("token")) + return await async_confirm(call.hass, token=token) + + +async def _svc_cancel(call) -> dict: + token = _coerce_str(call.data.get("token")) + return await async_cancel(call.hass, token=token) + + +async def _svc_postflight_check(call) -> dict: + return await async_postflight_check(call.hass) + + +def register_factory_reset_services(hass) -> None: + """Register the 4 RoamCore Factory Reset services. + + Safe to call repeatedly (HA overwrites handlers with the same name). + """ + if not _HA_AVAILABLE: + return + hass.services.async_register( + "roamcore", SERVICE_DRY_RUN, _svc_dry_run, schema=None, + ) + hass.services.async_register( + "roamcore", SERVICE_CONFIRM, _svc_confirm, schema=None, + ) + hass.services.async_register( + "roamcore", SERVICE_CANCEL, _svc_cancel, schema=None, + ) + hass.services.async_register( + "roamcore", SERVICE_POSTFLIGHT_CHECK, _svc_postflight_check, schema=None, + ) + + +# --------------------------------------------------------------------------- +# HTTP view (the dashboard + OpenClaw surface) +# --------------------------------------------------------------------------- + + +if _HA_AVAILABLE: + + class RoamCoreFactoryResetView(HomeAssistantView): + """HomeAssistantView for the Factory Reset surface. + + URL: `/api/roamcore/factory_reset/{action}` where `{action}` is + one of: `dry_run` / `confirm` / `cancel` / `postflight_check`. + """ + + url = "/api/roamcore/factory_reset/{action}" + name = "api:roamcore:factory_reset" + requires_auth = True + + async def get(self, request, action: str): + hass = request.app["hass"] + if action == "dry_run": + return await async_dry_run(hass) + if action == "postflight_check": + return await async_postflight_check(hass) + return self.json( + { + "ok": False, + "reasons": [ + f"Unknown action: {action!r}. Use one of: " + f"dry_run, postflight_check (GET) / " + f"confirm, cancel (POST)." + ], + }, + status_code=400, + ) + + async def post(self, request, action: str): + hass = request.app["hass"] + try: + data = await request.json() + except Exception: + data = {} + token = _coerce_str( + data.get("token") if isinstance(data, dict) else "" + ) + + if action == "confirm": + result = await async_confirm(hass, token=token) + elif action == "cancel": + result = await async_cancel(hass, token=token) + else: + return self.json( + { + "ok": False, + "reasons": [ + f"Unknown action: {action!r}. Use one of: " + f"confirm, cancel (POST)." + ], + }, + status_code=400, + ) + + status_code = 200 if result.get("ok") else 400 + return self.json(result, status_code=status_code) + +else: + + class RoamCoreFactoryResetView: # type: ignore[no-redef] + """Placeholder when HA is not importable.""" + + url = "/api/roamcore/factory_reset/{action}" + name = "api:roamcore:factory_reset" + requires_auth = True diff --git a/homeassistant/packages/roamcore_factory_reset.yaml b/homeassistant/packages/roamcore_factory_reset.yaml new file mode 100644 index 00000000..7467c4c7 --- /dev/null +++ b/homeassistant/packages/roamcore_factory_reset.yaml @@ -0,0 +1,283 @@ +# RoamCore Factory Reset — Phase 7 — Wave 9 #123.b +# +# Purpose: +# - Provide the canonical helper entities + the 5 §8 MANDATORY automations +# for the Factory Reset one-tap recovery in CI. +# - The §8.1 dry-run-sets-token automation fires when +# `input_button.rc_factory_reset_dry_run` is pressed + calls the +# RoamCore `roamcore.factory_reset_dry_run` service + writes the +# returned token to `input_text.rc_factory_reset_token`. +# - The §8.2 confirm-requires-token-match automation fires when +# `input_button.rc_factory_reset_confirm` is pressed + reads the +# token from the helper + calls `roamcore.factory_reset_confirm` +# with the value. +# - The §8.3 cancel-clears-token automation fires every 5 minutes + +# clears the token if the dry-run is >5 minutes old. +# - The §8.4 postflight-check-on-boot automation fires on HA start + +# calls `roamcore.factory_reset_postflight_check` + writes the +# result as plain English. +# - The §8.5 recovery-on-audit-chain-invalid automation fires when +# `binary_sensor.rc_openclaw_api_chain_valid` flips off + runs the +# chain-corruption recovery flow (wipe audit log + restore from +# latest backup). +# - All 5 automations have a `mode: single` guard so re-firing +# returns gracefully (idempotency marker; verified by the >=25 +# pytest tests at +# `homeassistant/packages/tests/test_factory_reset.py`). +# - The token is generated at runtime by the service handler +# (never hardcoded). The expected confirm token is the plain- +# English string "RESET" (the explicit-token guard from the +# doctrine). +# - The status surfaces are template sensors + a binary_sensor +# derivation for the operator-facing affordances. +# - Naming follows `docs/reference/rc-entity-naming.md` — every +# entity starts with `rc_factory_reset_` (the `factory_reset` +# subsystem was added by this slice). + +input_button: + rc_factory_reset_dry_run: + name: "Factory Reset Dry-Run" + icon: mdi:eye-outline + + rc_factory_reset_confirm: + name: "Factory Reset Confirm" + icon: mdi:alert-octagon + +input_text: + rc_factory_reset_token: + name: "Factory Reset Token" + initial: "" + max: 16 + pattern: "^[A-Z0-9]{0,16}$" + + rc_factory_reset_dry_run_report: + name: "Factory Reset Dry-Run Report" + initial: "No dry-run yet — tap the Dry-Run button to see the plan." + max: 1024 + +input_boolean: + rc_factory_reset_armed: + name: "Factory Reset Armed" + icon: mdi:shield-alert + initial: false + +input_datetime: + rc_factory_reset_last_dry_run: + name: "Factory Reset Last Dry-Run" + has_date: true + has_time: true + initial: "1970-01-01 00:00:00" + +template: + - sensor: + - name: "RC Factory Reset Status" + unique_id: rc_factory_reset_status + icon: mdi:restore-alert + state: >- + {% if is_state('input_boolean.rc_factory_reset_armed', 'on') %} + Confirm pending + {% elif states('input_text.rc_factory_reset_dry_run_report') != 'No dry-run yet — tap the Dry-Run button to see the plan.' and states('input_text.rc_factory_reset_token') != '' %} + Dry-run shown + {% else %} + Ready + {% endif %} + + - name: "RC Factory Reset Last Backup Age" + unique_id: rc_factory_reset_last_backup_age + icon: mdi:clock-outline + state: >- + {% set age = states('sensor.rc_hub_backup_age_minutes') | int(99999) %} + {% if age < 60 %} + {{ age }} minutes ago + {% elif age < 1440 %} + {{ (age / 60) | round(0, 'floor') | int }} hours ago + {% elif age < 43200 %} + {{ (age / 1440) | round(0, 'floor') | int }} days ago + {% else %} + no backup yet + {% endif %} + availability: >- + {{ states('sensor.rc_hub_backup_age_minutes') not in ['unknown','unavailable'] }} + + - name: "RC Factory Reset Preflight Warnings" + unique_id: rc_factory_reset_preflight_warnings + icon: mdi:alert-circle-outline + state: >- + {% set age = states('sensor.rc_hub_backup_age_minutes') | int(99999) %} + {% set healthy = is_state('binary_sensor.rc_hub_backup_healthy', 'on') %} + {% if age >= 99999 %} + No backup yet — please take a new backup first. + {% elif age > 1440 %} + Last backup is {{ (age / 1440) | round(0, 'floor') | int }} days old — please take a new one first. + {% elif not healthy %} + Last backup didn't pass the restore-check — please verify it first. + {% else %} + All clear — your Hub is ready for a factory reset. + {% endif %} + + - name: "RC Factory Reset Postflight Status" + unique_id: rc_factory_reset_postflight_status + icon: mdi:shield-check + state: "Post-flight check hasn't run yet — it runs on every Hub start." + + - binary_sensor: + - name: "RC Factory Reset Safe To Run" + unique_id: rc_factory_reset_safe_to_run + icon: mdi:shield-check + state: >- + {% set age = states('sensor.rc_hub_backup_age_minutes') | int(99999) %} + {% set healthy = is_state('binary_sensor.rc_hub_backup_healthy', 'on') %} + {{ age < 1440 and healthy }} + availability: >- + {{ states('sensor.rc_hub_backup_age_minutes') not in ['unknown','unavailable'] and states('binary_sensor.rc_hub_backup_healthy') not in ['unknown','unavailable'] }} + +automation: + - id: rc_factory_reset_dry_run_sets_token + alias: "RC Factory Reset: Dry-Run Sets Token (on input_button press)" + description: "Dry-Run Sets Token — fires when input_button.rc_factory_reset_dry_run is pressed; calls roamcore.factory_reset_dry_run and stores the returned 8-char token in input_text.rc_factory_reset_token. mode: single guard prevents double-dry-run." + mode: single + max_exceeded: silent + trigger: + - platform: state + entity_id: input_button.rc_factory_reset_dry_run + action: + - service: roamcore.factory_reset_dry_run + response_variable: dry_run_result + - choose: + - conditions: + - "{{ dry_run_result is mapping and dry_run_result.ok }}" + sequence: + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_token + data: + value: "{{ dry_run_result.plan.token }}" + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: "{{ dry_run_result.plain_english_summary }}" + - service: input_boolean.turn_on + target: + entity_id: input_boolean.rc_factory_reset_armed + - service: input_datetime.set_datetime + target: + entity_id: input_datetime.rc_factory_reset_last_dry_run + data: + datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}" + default: + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: >- + {{ dry_run_result.plain_english_summary if dry_run_result is mapping and dry_run_result.plain_english_summary is defined else 'I can''t run a dry-run right now. Please check the preflight warnings tile.' }} + + - id: rc_factory_reset_confirm_requires_token_match + alias: "RC Factory Reset: Confirm Requires Token Match (on input_button press)" + description: "Confirm Requires Token Match — fires when input_button.rc_factory_reset_confirm is pressed; reads the token from input_text.rc_factory_reset_token and calls roamcore.factory_reset_confirm. mode: single guard prevents double-confirm." + mode: single + max_exceeded: silent + trigger: + - platform: state + entity_id: input_button.rc_factory_reset_confirm + condition: + - condition: state + entity_id: input_boolean.rc_factory_reset_armed + state: "on" + action: + - service: roamcore.factory_reset_confirm + data: + token: "{{ states('input_text.rc_factory_reset_token') }}" + response_variable: confirm_result + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: "{{ confirm_result.plain_english_status }}" + + - id: rc_factory_reset_cancel_clears_token + alias: "RC Factory Reset: Cancel Clears Token (timer: every 5 minutes)" + description: "Cancel Clears Token — fires every 5 minutes; checks input_datetime.rc_factory_reset_last_dry_run for staleness; if > 5 minutes old, clears the token and sets armed to false. mode: single guard prevents concurrent runs." + mode: single + max_exceeded: silent + trigger: + - platform: time_pattern + minutes: "/5" + condition: + - condition: state + entity_id: input_boolean.rc_factory_reset_armed + state: "on" + action: + - variables: + last_dry_run_str: "{{ states('input_datetime.rc_factory_reset_last_dry_run') }}" + now_dt: "{{ now() }}" + - condition: template + value_template: >- + {% set last_dry_run = strptime(last_dry_run_str, '%Y-%m-%d %H:%M:%S') %} + {{ (now_dt - last_dry_run).total_seconds() > 300 }} + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_token + data: + value: "" + - service: input_boolean.turn_off + target: + entity_id: input_boolean.rc_factory_reset_armed + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: "Dry-run expired — tap the Dry-Run button again to get a new token." + + - id: rc_factory_reset_postflight_check_on_boot + alias: "RC Factory Reset: Postflight Check On Boot (on HA start)" + description: "Postflight Check On Boot — fires on HA start; calls roamcore.factory_reset_postflight_check (idempotent) and writes the result to sensor.rc_factory_reset_postflight_status. mode: single guard prevents concurrent runs." + mode: single + max_exceeded: silent + trigger: + - platform: homeassistant + event: start + action: + - service: roamcore.factory_reset_postflight_check + response_variable: postflight_result + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: "{{ postflight_result.plain_english_status }}" + + - id: rc_factory_reset_recovery_on_audit_chain_invalid + alias: "RC Factory Reset: Recovery On Audit Chain Invalid (on binary_sensor flip)" + description: "Recovery On Audit Chain Invalid — fires when binary_sensor.rc_openclaw_api_chain_valid flips off; runs the chain-corruption recovery flow (wipe audit log + restore from latest backup) by calling roamcore.factory_reset_dry_run and roamcore.factory_reset_confirm in sequence. mode: single guard prevents concurrent recovery runs." + mode: single + max_exceeded: silent + trigger: + - platform: state + entity_id: binary_sensor.rc_openclaw_api_chain_valid + from: "on" + to: "off" + action: + - service: roamcore.factory_reset_dry_run + response_variable: recovery_dry_run + - choose: + - conditions: + - "{{ recovery_dry_run is mapping and recovery_dry_run.ok }}" + sequence: + - service: roamcore.factory_reset_confirm + data: + token: "{{ recovery_dry_run.plan.token }}" + response_variable: recovery_confirm + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: >- + {{ recovery_confirm.plain_english_status if recovery_confirm is mapping and recovery_confirm.plain_english_status is defined else 'Your Hub self-recovered — the audit log was wiped + the latest backup was restored.' }} + default: + - service: input_text.set_value + target: + entity_id: input_text.rc_factory_reset_dry_run_report + data: + value: >- + The OpenClaw audit chain is invalid but the auto-recovery couldn't start — please run a factory reset manually. diff --git a/homeassistant/packages/roamcore_remote_access_setup.yaml b/homeassistant/packages/roamcore_remote_access_setup.yaml index f2a65eb4..13dd78e2 100644 --- a/homeassistant/packages/roamcore_remote_access_setup.yaml +++ b/homeassistant/packages/roamcore_remote_access_setup.yaml @@ -28,10 +28,11 @@ input_select: rc_remote_access_setup_path: name: "RC Remote Access Setup: Path" options: - - tailscale # Path A — the wired-up one (this slice) - - cloudflare # Path B — stub - - nabu_casa # Path C — stub - - wireguard # Path D — stub + - tailscale # Path A — the wired-up one (#122.a — preserved bit-for-bit) + - cloudflare_tunnel # Path B — wired up in #122.b (recipe over HACS cloudflared + HA cloudflare integration) + - cloudflare # Path B (legacy stub label) — preserved for backward-compat (#122.a) + - nabu_casa # Path C — stub (preserved bit-for-bit from #122.a) + - wireguard # Path D — stub (preserved bit-for-bit from #122.a) - skip initial: tailscale @@ -45,6 +46,12 @@ input_select: - tailscale_paste_key - tailscale_verify - tailscale_done + # Path B (Cloudflare Tunnel) wizard stages — added by #122.b + - cloudflare_tunnel_have_domain + - cloudflare_tunnel_paste_token + - cloudflare_tunnel_pick_hostname + - cloudflare_tunnel_verify + - cloudflare_tunnel_done - cloudflare_stub - nabu_casa_stub - wireguard_stub @@ -53,6 +60,10 @@ input_select: initial: welcome input_text: + # Path A — Tailscale (#122.a — preserved bit-for-bit; the doctrine + # says existing Path A entries are preserved bit-for-bit so the + # operator's auth key + tailnet hostname are NOT touched by the + # #122.b Cloudflare addition). rc_tailscale_auth_key: name: "RC Tailscale Auth Key (operator-entered, never logged)" initial: "" @@ -62,6 +73,20 @@ input_text: name: "RC Tailscale Tailnet Hostname (e.g. my-van.ts.net)" initial: "" + # Path B — Cloudflare Tunnel (#122.b). Operator-entered tunnel + # token + the hostname they want to reach the Hub at. The token + # is password-mode (sensitive — never logged, never displayed + # in clear text). The hostname is plain text (operator must + # see what they typed so they can spot a typo). + rc_remote_access_cloudflare_token: + name: "RC Cloudflare Tunnel Token (operator-entered, never logged)" + initial: "" + mode: password + + rc_remote_access_cloudflare_hostname: + name: "RC Cloudflare Hostname (e.g. my-van.example.com — must be on a Cloudflare-managed domain)" + initial: "" + # ---------------------------------------------------------------------------- # Templates — surface HA core `tailscale` integration state without coupling # the wizard's entity_ids to the vendor. UNKNOWN when the integration is @@ -140,6 +165,20 @@ template: Tailscale is set up. You're good to go. {% elif stage == 'cloudflare_stub' %} Cloudflare setup is coming soon — pick Tailscale for now. + {% elif stage == 'cloudflare_tunnel_have_domain' %} + Do you have a domain on Cloudflare already? + {% elif stage == 'cloudflare_tunnel_paste_token' and (states('input_text.rc_remote_access_cloudflare_token') | trim) == '' %} + Paste your Cloudflare tunnel token below. + {% elif stage == 'cloudflare_tunnel_paste_token' %} + Checking your Cloudflare tunnel token... + {% elif stage == 'cloudflare_tunnel_pick_hostname' and (states('input_text.rc_remote_access_cloudflare_hostname') | trim) == '' %} + Pick a hostname on your Cloudflare domain. + {% elif stage == 'cloudflare_tunnel_pick_hostname' %} + Testing your Cloudflare tunnel... + {% elif stage == 'cloudflare_tunnel_verify' %} + Verifying your Cloudflare tunnel. + {% elif stage == 'cloudflare_tunnel_done' %} + Cloudflare Tunnel is set up. You're good to go. {% elif stage == 'nabu_casa_stub' %} Nabu Casa setup is coming soon — pick Tailscale for now. {% elif stage == 'wireguard_stub' %} @@ -289,6 +328,20 @@ automation: entity_id: input_select.rc_remote_access_setup_stage data: option: tailscale_have_account + - conditions: + - condition: state + entity_id: input_select.rc_remote_access_setup_path + state: cloudflare_tunnel + sequence: + - service: input_select.select_option + target: + entity_id: input_select.rc_remote_access_setup_stage + data: + option: cloudflare_tunnel_have_domain + - service: persistent_notification.create + data: + title: "Cloudflare Tunnel ready to set up" + message: "Cloudflare Tunnel setup is ready — have your tunnel token and a hostname on a Cloudflare-managed domain handy." - conditions: - condition: state entity_id: input_select.rc_remote_access_setup_path diff --git a/homeassistant/packages/tests/test_connection_state_field.py b/homeassistant/packages/tests/test_connection_state_field.py index 56aac22c..f029fd83 100644 --- a/homeassistant/packages/tests/test_connection_state_field.py +++ b/homeassistant/packages/tests/test_connection_state_field.py @@ -126,24 +126,25 @@ def manifests_by_id(manifest_paths: list[Path]) -> dict[str, dict]: return out -def test_manifest_count_is_thirty_three(manifest_count: int) -> None: - """The connection surface is 33 manifests. +def test_manifest_count_is_thirty_four(manifest_count: int) -> None: + """The connection surface is 34 manifests. - Locked at 33 because (a) the directive §"App-store-style catalog + Locked at 34 because (a) the directive §"App-store-style catalog UI" explicitly lists the 32 surface tiles, and (b) the catalog index page (`docs/catalog/index.md`) renders one entry per connection. Wave 9 #123.a adds `connections/hub-backup/` as the - 33rd manifest. If a future slice adds a new connection, update + 33rd manifest and Wave 9 #123.b adds `connections/factory-reset/` + as the 34th. If a future slice adds a new connection, update this count AND the catalog index AND the inventory build in lockstep. """ - assert manifest_count == 33, ( - f"expected exactly 33 connection manifests (per the directive " + assert manifest_count == 34, ( + f"expected exactly 34 connection manifests (per the directive " f"§'App-store-style catalog UI' surface tile list + the " - f"catalog index page + Wave 9 #123.a hub-backup); got " - f"{manifest_count}. The slice's primary value is uniform " - f"data, not bespoke configuration — adding a connection is a " - f"deliberate action." + f"catalog index page + Wave 9 #123.a hub-backup + Wave 9 " + f"#123.b factory-reset); got {manifest_count}. The slice's " + f"primary value is uniform data, not bespoke configuration " + f"— adding a connection is a deliberate action." ) diff --git a/homeassistant/packages/tests/test_factory_reset.py b/homeassistant/packages/tests/test_factory_reset.py new file mode 100644 index 00000000..03efddda --- /dev/null +++ b/homeassistant/packages/tests/test_factory_reset.py @@ -0,0 +1,713 @@ +"""Phase 7 — Wave 9 #123.b Factory Reset contract validation rig. + +>=25 pytest tests covering: + - YAML parses cleanly + - 6 input helpers present (input_button dry_run + confirm + + input_text token + input_text dry_run_report + input_boolean + armed + input_datetime last_dry_run) + - 5 template sensors present (status + last_backup_age + + preflight_warnings + postflight_status + the binary_sensor + safe_to_run) + - 5 section 8 MANDATORY automations present + - rc-entity-naming compliance + - 2-step confirm flow (dry-run returns token; confirm with wrong + token returns 400; confirm with stale token returns 400; + confirm with correct token returns ok) + - Idempotency (2 dry-runs in a row -> same plan) + - Backup-prerequisite (confirm without recent backup returns + plain-English message) + - Chain-corruption recovery references the openclaw binary_sensor + - Token lifecycle (token auto-clears after 5 minutes) + - Secrets-leak grep + - Service wiring: 4 service definitions in services.yaml + - RoamCore-owned service handler exists + defines expected functions + - register_factory_reset_services wired into async_setup_entry + - Pre-flight check: every combination of (no backup, old + backup, fresh backup) + +Run locally: + cd /home/bernard/clawd/RoamCore + python3 -m pytest homeassistant/packages/tests/test_factory_reset.py -v +""" + +from __future__ import annotations + +import os +import re +import string +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +try: + import yaml +except ImportError: # pragma: no cover + pytest.skip("PyYAML required (pip install pyyaml)", allow_module_level=True) + + +REPO_ROOT = Path(__file__).resolve().parents[3] +HELPER_PACKAGE_PATH = REPO_ROOT / "homeassistant" / "packages" / "roamcore_factory_reset.yaml" +SERVICES_YAML_PATH = REPO_ROOT / "homeassistant" / "custom_components" / "roamcore" / "services.yaml" +FACTORY_RESET_PY_PATH = REPO_ROOT / "homeassistant" / "custom_components" / "roamcore" / "factory_reset.py" +COMPONENT_INIT_PATH = REPO_ROOT / "homeassistant" / "custom_components" / "roamcore" / "__init__.py" +CONNECTION_DIR = REPO_ROOT / "connections" / "factory-reset" +BASH_SMOKE_PATH = REPO_ROOT / "scripts" / "checks" / "factory-reset-smoke.sh" +USER_RUNBOOK_PATH = REPO_ROOT / "docs" / "runbooks" / "factory-reset.md" +HUB_BACKUP_CONNECTION_DIR = REPO_ROOT / "connections" / "hub-backup" + + +@pytest.fixture(scope="module") +def helper_package() -> dict: + assert HELPER_PACKAGE_PATH.is_file(), f"missing helper package at {HELPER_PACKAGE_PATH}" + return yaml.safe_load(HELPER_PACKAGE_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def services_yaml() -> dict: + assert SERVICES_YAML_PATH.is_file(), f"missing services.yaml at {SERVICES_YAML_PATH}" + return yaml.safe_load(SERVICES_YAML_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def factory_reset_py_text() -> str: + assert FACTORY_RESET_PY_PATH.is_file(), f"missing service handler at {FACTORY_RESET_PY_PATH}" + return FACTORY_RESET_PY_PATH.read_text(encoding="utf-8") + + +# ---- Test 1: YAML parses cleanly ---- +def test_yaml_parses_cleanly(helper_package: dict) -> None: + assert isinstance(helper_package, dict) + allowed_keys = { + "input_boolean", "input_datetime", "input_select", "input_text", + "input_button", "template", "button", "automation", "script", + "sensor", "binary_sensor", "switch", "light", "group", "scene", + } + for key in helper_package.keys(): + assert key in allowed_keys, f"unknown top-level key {key!r}" + + +# ---- Test 2: 6 input helpers present ---- +def test_input_helpers_present(helper_package: dict) -> None: + input_buttons = helper_package.get("input_button", {}) + assert "rc_factory_reset_dry_run" in input_buttons + assert "rc_factory_reset_confirm" in input_buttons + assert "icon" in input_buttons["rc_factory_reset_dry_run"] + assert "icon" in input_buttons["rc_factory_reset_confirm"] + input_texts = helper_package.get("input_text", {}) + assert "rc_factory_reset_token" in input_texts + assert input_texts["rc_factory_reset_token"].get("max") == 16 + assert "rc_factory_reset_dry_run_report" in input_texts + assert "No dry-run yet" in input_texts["rc_factory_reset_dry_run_report"].get("initial", "") + input_booleans = helper_package.get("input_boolean", {}) + assert "rc_factory_reset_armed" in input_booleans + assert input_booleans["rc_factory_reset_armed"].get("initial") is False + input_datetimes = helper_package.get("input_datetime", {}) + assert "rc_factory_reset_last_dry_run" in input_datetimes + assert input_datetimes["rc_factory_reset_last_dry_run"].get("has_date") is True + assert input_datetimes["rc_factory_reset_last_dry_run"].get("has_time") is True + + +# ---- Test 3: 5 template sensors + 1 binary_sensor present ---- +def test_template_sensors_present(helper_package: dict) -> None: + template = helper_package.get("template", []) + found_sensors = set() + found_binary_sensors = set() + for entry in template: + if isinstance(entry, dict) and "sensor" in entry: + for sensor in entry.get("sensor", []): + uid = sensor.get("unique_id") + if uid: + found_sensors.add(uid) + if isinstance(entry, dict) and "binary_sensor" in entry: + for bsensor in entry.get("binary_sensor", []): + uid = bsensor.get("unique_id") + if uid: + found_binary_sensors.add(uid) + required_sensors = ( + "rc_factory_reset_status", + "rc_factory_reset_last_backup_age", + "rc_factory_reset_preflight_warnings", + "rc_factory_reset_postflight_status", + ) + for required in required_sensors: + assert required in found_sensors, f"missing template sensor {required!r}" + required_binary_sensors = ("rc_factory_reset_safe_to_run",) + for required in required_binary_sensors: + assert required in found_binary_sensors, f"missing template binary_sensor {required!r}" + + +# ---- Test 4: 5 section 8 MANDATORY automations present ---- +def test_section_8_automations_present(helper_package: dict) -> None: + automations = helper_package.get("automation", []) + required_ids = ( + "rc_factory_reset_dry_run_sets_token", + "rc_factory_reset_confirm_requires_token_match", + "rc_factory_reset_cancel_clears_token", + "rc_factory_reset_postflight_check_on_boot", + "rc_factory_reset_recovery_on_audit_chain_invalid", + ) + for required_id in required_ids: + target = None + for auto in automations: + if auto.get("id") == required_id: + target = auto + break + assert target is not None, f"missing automation {required_id!r}" + assert "description" in target + assert target["description"].strip() + assert target.get("mode") == "single" + + +# ---- Test 5: service calls reference roamcore.factory_reset_* ---- +def test_service_calls_reference_roamcore_namespace(helper_package: dict) -> None: + automations = helper_package.get("automation", []) + # dry-run automation + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_dry_run_sets_token": + target = auto + break + assert target is not None + action_text = str(target.get("action", [])) + assert "roamcore.factory_reset_dry_run" in action_text + # confirm automation + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_confirm_requires_token_match": + target = auto + break + assert target is not None + action_text = str(target.get("action", [])) + assert "roamcore.factory_reset_confirm" in action_text + # postflight automation + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_postflight_check_on_boot": + target = auto + break + assert target is not None + action_text = str(target.get("action", [])) + assert "roamcore.factory_reset_postflight_check" in action_text + + +# ---- Test 6: rc-entity-naming compliance ---- +def test_rc_entity_naming_compliance(helper_package: dict) -> None: + forbidden_substrings = ( + "victron", "renogy", "shunt", "bms", "inverter", + "see level", "seelevel", "garnet", "mopeka", + "starlink", "peplink", "teltonika", "unifi", "ubiquiti", + "mqtt", "webhook", "rest", "hacs", "tasmota", "esphome", + "companion", "esp32", "esp8266", "shelly", "sonoff", + "zwave", "zha", "zigbee", "deconz", "bluetooth", + "input_boolean", "input_text", "input_datetime", "input_button", + "gps", "accelerometer", "iphone", "ios", "android", + "samsung", "pixel", "xiaomi", "huawei", "phone", + ) + text = HELPER_PACKAGE_PATH.read_text(encoding="utf-8") + entity_ids = re.findall( + r"\b(rc_\w+|input_\w+\.\w+|sensor\.\w+|binary_sensor\.\w+|button\.\w+)", + text, + ) + for eid in entity_ids: + if eid.startswith("rc_"): + assert eid.startswith("rc_factory_reset_"), ( + f"entity_id {eid!r} does NOT start with `rc_factory_reset_`" + ) + suffix = eid[len("rc_factory_reset_"):] + for bad in forbidden_substrings: + assert bad not in suffix.lower(), ( + f"entity_id {eid!r} contains forbidden vendor substring {bad!r}" + ) + + +# ---- Test 7: secrets-leak grep ---- +def test_secrets_leak_guard() -> None: + files_to_check = [ + HELPER_PACKAGE_PATH, + SERVICES_YAML_PATH, + FACTORY_RESET_PY_PATH, + CONNECTION_DIR / "connection.yml", + CONNECTION_DIR / "docs" / "recipe.md", + CONNECTION_DIR / "README.md", + ] + for path in files_to_check: + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + assert "/home/bernard" not in text, f"{path} contains /home/bernard leak" + for forbidden in ( + "https://AKIA", "https://arn:aws", "https://hooks.slack", + "https://discord.com/api/webhooks", "https://api.telegram.org/bot", + ): + assert forbidden.lower() not in text.lower(), ( + f"{path} contains URL leak {forbidden!r}" + ) + for forbidden in ( + 'password: "hunter2"', 'password = "hunter2"', + 'api_key: "secret"', 'api_key = "secret"', + ): + assert forbidden not in text, f"{path} contains password leak {forbidden!r}" + + +# ---- Test 8: 2-step confirm flow — dry-run returns token ---- +@pytest.mark.asyncio +async def test_two_step_confirm_dry_run_returns_token() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "async_dry_run"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock( + return_value=[ + { + "backup_id": "test-backup-1", + "created_at": "2026-08-06T10:00:00+00:00", + "size_bytes": 1024, + "path": "/config/.roamcore/backups/", + } + ] + ) + result = await fr.async_dry_run(hass) + assert result.get("ok") is True, f"dry-run should return ok=True; got {result!r}" + plan = result.get("plan", {}) + token = plan.get("token", "") + assert token, f"dry-run must return a non-empty token; got {token!r}" + assert len(token) >= 6, f"dry-run token must be at least 6 chars; got {len(token)}" + else: + if fr is not None and hasattr(fr, "_generate_token"): + token = fr._generate_token(8) + assert len(token) == 8 + assert all(c in (string.ascii_uppercase + string.digits) for c in token) + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 9: 2-step confirm flow — confirm with wrong token returns 400 ---- +@pytest.mark.asyncio +async def test_two_step_confirm_wrong_token() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "async_confirm"): + hass = MagicMock() + result = await fr.async_confirm(hass, token="") + assert result.get("ok") is False, f"confirm with empty token should return ok=False; got {result!r}" + reasons = result.get("reasons", []) + assert reasons, "confirm with empty token should return at least one reason" + reason_text = reasons[0].lower() + assert ( + "pending reset" in reason_text + or "no reset" in reason_text + or "run dry-run" in reason_text + or "wrong token" in reason_text + or "token expired" in reason_text + ), f"confirm with empty token should return a plain-English reason; got {reasons[0]!r}" + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 10: 2-step confirm flow — confirm with stale token returns 400 ---- +@pytest.mark.asyncio +async def test_two_step_confirm_stale_token() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "async_dry_run") and hasattr(fr, "async_confirm"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock( + return_value=[ + { + "backup_id": "test-backup-1", + "created_at": "2026-08-06T10:00:00+00:00", + "size_bytes": 1024, + "path": "/config/.roamcore/backups/", + } + ] + ) + dry_result = await fr.async_dry_run(hass) + assert dry_result.get("ok") is True + token = dry_result.get("plan", {}).get("token", "") + assert token + from datetime import datetime, timezone, timedelta + for plan in fr._IN_FLIGHT_PLANS.values(): + plan.dry_run_at = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() + confirm_result = await fr.async_confirm(hass, token=token) + assert confirm_result.get("ok") is False + reasons = confirm_result.get("reasons", []) + assert reasons + reason_text = reasons[0].lower() + assert "expired" in reason_text or "stale" in reason_text + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 11: Idempotency — 2 dry-runs in a row ---- +@pytest.mark.asyncio +async def test_idempotency_two_dry_runs_same_plan() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "async_dry_run"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock( + return_value=[ + { + "backup_id": "test-backup-1", + "created_at": "2026-08-06T10:00:00+00:00", + "size_bytes": 1024, + "path": "/config/.roamcore/backups/", + } + ] + ) + result_1 = await fr.async_dry_run(hass) + assert result_1.get("ok") is True + token_1 = result_1.get("plan", {}).get("token", "") + plan_id_1 = result_1.get("plan", {}).get("plan_id", "") + result_2 = await fr.async_dry_run(hass) + assert result_2.get("ok") is True + token_2 = result_2.get("plan", {}).get("token", "") + plan_id_2 = result_2.get("plan", {}).get("plan_id", "") + assert token_1 == token_2, f"2 dry-runs should return same token; got {token_1!r} vs {token_2!r}" + assert plan_id_1 == plan_id_2, f"2 dry-runs should return same plan_id; got {plan_id_1!r} vs {plan_id_2!r}" + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 12: Backup-prerequisite — no recent backup returns plain-English message ---- +@pytest.mark.asyncio +async def test_backup_prerequisite_no_recent_backup() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "async_dry_run"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock(return_value=[]) + result = await fr.async_dry_run(hass) + assert result.get("ok") is False + reasons = result.get("reasons", []) + assert reasons + reason_text = reasons[0].lower() + assert "recent backup" in reason_text or "no backup" in reason_text, ( + f"dry-run with no backup should return a plain-English reason; got {reasons[0]!r}" + ) + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 13: Chain-corruption recovery — references openclaw binary_sensor ---- +def test_chain_corruption_recovery_references_openclaw_binary_sensor(helper_package: dict) -> None: + automations = helper_package.get("automation", []) + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_recovery_on_audit_chain_invalid": + target = auto + break + assert target is not None + triggers = target.get("trigger", []) + found_openclaw_ref = False + for trg in triggers: + if trg.get("entity_id") == "binary_sensor.rc_openclaw_api_chain_valid": + found_openclaw_ref = True + break + assert found_openclaw_ref, ( + "automation.rc_factory_reset_recovery_on_audit_chain_invalid MUST " + "reference binary_sensor.rc_openclaw_api_chain_valid" + ) + + +# ---- Test 14: Token lifecycle — clears after 5 minutes ---- +def test_token_lifecycle_clears_after_5_minutes(helper_package: dict) -> None: + automations = helper_package.get("automation", []) + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_cancel_clears_token": + target = auto + break + assert target is not None + triggers = target.get("trigger", []) + has_5min_trigger = False + for trg in triggers: + if trg.get("platform") == "time_pattern" and trg.get("minutes") == "/5": + has_5min_trigger = True + break + assert has_5min_trigger + action_text = str(target.get("action", [])) + assert "input_text.rc_factory_reset_token" in action_text + assert "input_boolean.rc_factory_reset_armed" in action_text + assert "300" in action_text or "5 minutes" in action_text + + +# ---- Test 15: Pre-flight check — fresh backup returns "All clear" ---- +@pytest.mark.asyncio +async def test_preflight_fresh_backup_all_clear() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "validate_factory_reset_prerequisites"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock( + return_value=[ + { + "backup_id": "test-backup-1", + "created_at": "2026-08-06T10:00:00+00:00", + "size_bytes": 1024, + "path": "/config/.roamcore/backups/", + } + ] + ) + ok, reasons = await fr.validate_factory_reset_prerequisites(hass) + assert ok is True, f"pre-flight with fresh backup should return ok=True; got ok={ok!r}, reasons={reasons!r}" + assert reasons == [], f"pre-flight with fresh backup should return empty reasons; got {reasons!r}" + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 16: Pre-flight check — no backup returns "I can't reset" message ---- +@pytest.mark.asyncio +async def test_preflight_no_backup_returns_cant_reset() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "validate_factory_reset_prerequisites"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from roamcore import backup as hub_backup_module + hub_backup_module.async_list_backups = AsyncMock(return_value=[]) + ok, reasons = await fr.validate_factory_reset_prerequisites(hass) + assert ok is False + assert reasons + reason_text = reasons[0].lower() + assert "recent backup" in reason_text or "no backup" in reason_text + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 17: Pre-flight check — old backup returns "stale" message ---- +@pytest.mark.asyncio +async def test_preflight_old_backup_returns_stale_message() -> None: + sys.path.insert(0, str(REPO_ROOT / "homeassistant" / "custom_components")) + try: + from roamcore import factory_reset as fr + except ImportError: + fr = None + if fr is not None and hasattr(fr, "validate_factory_reset_prerequisites"): + hass = MagicMock() + hass.states.async_all = MagicMock(return_value=[MagicMock()]) + from datetime import datetime, timezone, timedelta + from roamcore import backup as hub_backup_module + old_time = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat() + hub_backup_module.async_list_backups = AsyncMock( + return_value=[ + { + "backup_id": "test-backup-old", + "created_at": old_time, + "size_bytes": 1024, + "path": "/config/.roamcore/backups/", + } + ] + ) + ok, reasons = await fr.validate_factory_reset_prerequisites(hass) + assert ok is False + reason_text = " ".join(reasons).lower() + assert "24 hours" in reason_text or "more than" in reason_text or "stale" in reason_text or "old" in reason_text + else: + pytest.skip("factory_reset module not importable in this environment") + + +# ---- Test 18: Service wiring — 4 service definitions in services.yaml ---- +def test_services_yaml_has_four_new_services(services_yaml: dict) -> None: + required_services = ( + "factory_reset_dry_run", + "factory_reset_confirm", + "factory_reset_cancel", + "factory_reset_postflight_check", + ) + for required in required_services: + assert required in services_yaml + assert "token" in services_yaml["factory_reset_confirm"].get("fields", {}) + assert "token" in services_yaml["factory_reset_cancel"].get("fields", {}) + + +# ---- Test 19: RoamCore-owned service handler defines the expected functions ---- +def test_factory_reset_py_defines_expected_functions(factory_reset_py_text: str) -> None: + assert FACTORY_RESET_PY_PATH.is_file() + text = factory_reset_py_text + assert "FACTORY_RESET_TILE_PREFIX" in text + assert "BACKUP_FRESHNESS_WINDOW_MINUTES" in text + assert "EXPECTED_CONFIRM_TOKEN" in text + for expected in ( + "async def async_dry_run", + "async def async_confirm", + "async def async_cancel", + "async def async_postflight_check", + "async def recovery_resets", + "def register_factory_reset_services", + "def plain_english_reason", + "def is_backup_fresh", + "class RoamCoreFactoryResetView", + ): + assert expected in text, f"factory_reset.py must define {expected!r}" + # The strings may be on separate lines (typical Python multi-line + # function call) so we check for both substrings independently. + assert ( + '"backup"' in text + and '"restore"' in text + and 'hass.services.async_call' in text + ), ( + "factory_reset.py MUST call `hass.services.async_call(" + "\"backup\", \"restore\", ...)` against the HA core " + "`backup.restore` service" + ) + + +# ---- Test 20: register_factory_reset_services wired into async_setup_entry ---- +def test_register_factory_reset_services_wired_into_init() -> None: + text = COMPONENT_INIT_PATH.read_text(encoding="utf-8") + assert "register_factory_reset_services" in text + setup_match = re.search( + r"async def async_setup_entry\([^)]*\)[^:]*:\s*\n((?:.|\n)*?)\n return True\n", + text, + ) + assert setup_match is not None + setup_body = setup_match.group(1) + assert "register_factory_reset_services" in setup_body + assert "RoamCoreFactoryResetView" in setup_body + + +# ---- Test 21: bash smoke exists ---- +def test_bash_smoke_exists() -> None: + assert BASH_SMOKE_PATH.is_file() + text = BASH_SMOKE_PATH.read_text(encoding="utf-8") + assert "set -euo pipefail" in text + assert text.startswith("#!/usr/bin/env bash") + assertion_count = len(re.findall(r"assertion \d+:", text)) + assert assertion_count >= 12, f"bash smoke must have >=12 assertions; got {assertion_count}" + + +# ---- Test 22: user-facing IKEA runbook ---- +def test_user_runbook_ikea_style() -> None: + assert USER_RUNBOOK_PATH.is_file() + text = USER_RUNBOOK_PATH.read_text(encoding="utf-8") + lines = text.splitlines() + assert len(lines) <= 130, f"user-facing runbook must be <=130 LOC; got {len(lines)}" + plain_english_line_found = False + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("# "): + continue + plain_english_line_found = True + forbidden = ( + "/home/", "Wave ", "PR #", "Phase ", "branch:", "feat/", + "tier-", "sub-slice", "connections/", ".py", ".yml", + ".yaml", ".md", ".sh", "#123", "GitHub issue", + ) + for f in forbidden: + assert f.lower() not in stripped.lower(), ( + f"opening line of runbook must NOT contain {f!r}; got {stripped!r}" + ) + break + assert plain_english_line_found + + +# ---- Test 23: requires: hub-backup is real ---- +def test_requires_hub_backup_connection_is_real() -> None: + manifest_path = CONNECTION_DIR / "connection.yml" + assert manifest_path.is_file() + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + requires = manifest.get("requires", []) + assert "hub-backup" in requires + assert HUB_BACKUP_CONNECTION_DIR.is_dir() + + +# ---- Test 24: idempotency — dry_run automation has mode: single guard ---- +def test_idempotency_mode_single_on_dry_run_automation(helper_package: dict) -> None: + automations = helper_package.get("automation", []) + target = None + for auto in automations: + if auto.get("id") == "rc_factory_reset_dry_run_sets_token": + target = auto + break + assert target is not None + assert target.get("mode") == "single" + + +# ---- Test 25: plain_english_reason covers all error codes ---- +def test_plain_english_reason_covers_all_error_codes(factory_reset_py_text: str) -> None: + required_codes = ( + "BackupNotFoundError", + "BackupStaleError", + "TokenMismatchError", + "TokenExpiredError", + "NoPendingResetError", + "HubUnreachableError", + "AuditChainInvalidError", + ) + for code in required_codes: + assert code in factory_reset_py_text, ( + f"factory_reset.py must reference {code!r}" + ) + + +# ---- Test 26: expected confirm token is "RESET" ---- +def test_expected_confirm_token_is_reset(factory_reset_py_text: str) -> None: + assert 'EXPECTED_CONFIRM_TOKEN = "RESET"' in factory_reset_py_text, ( + "factory_reset.py must define EXPECTED_CONFIRM_TOKEN = \"RESET\"" + ) + + +# ---- Test 27: every helper that accepts an icon has one ---- +def test_every_helper_has_icon(helper_package: dict) -> None: + # Only input_button and input_boolean accept icons in HA. + # input_datetime + input_text do NOT (per HA core convention). + for section in ("input_button", "input_boolean"): + for name, body in helper_package.get(section, {}).items(): + if not name.startswith("rc_factory_reset_"): + continue + assert "icon" in body, f"{section}.{name} must have an icon" + + +# ---- Test 28: BACKUP_FRESHNESS_WINDOW_MINUTES is 24h ---- +def test_backup_freshness_window_is_24h(factory_reset_py_text: str) -> None: + assert "BACKUP_FRESHNESS_WINDOW_MINUTES = 24 * 60" in factory_reset_py_text, ( + "factory_reset.py must define BACKUP_FRESHNESS_WINDOW_MINUTES = 24 * 60" + ) + + +# ---- Test 29: TOKEN_LIFETIME_MINUTES is 5 minutes ---- +def test_token_lifetime_is_5_minutes(factory_reset_py_text: str) -> None: + assert "TOKEN_LIFETIME_MINUTES = 5" in factory_reset_py_text, ( + "factory_reset.py must define TOKEN_LIFETIME_MINUTES = 5" + ) + + +# ---- Test 30: forward reference to openclaw binary_sensor ---- +def test_openclaw_binary_sensor_forward_reference(factory_reset_py_text: str) -> None: + assert "binary_sensor.rc_openclaw_api_chain_valid" in factory_reset_py_text, ( + "factory_reset.py must reference the openclaw binary_sensor by name" + ) diff --git a/homeassistant/packages/tests/test_remote_access_setup.py b/homeassistant/packages/tests/test_remote_access_setup.py index d934200b..7c0f6f78 100644 --- a/homeassistant/packages/tests/test_remote_access_setup.py +++ b/homeassistant/packages/tests/test_remote_access_setup.py @@ -483,6 +483,8 @@ def test_yaml_idempotent(package: dict) -> None: ALLOWED_ENTITY_ID_PREFIXES = ( "rc_remote_access_setup_", "rc_tailscale_", + # Wave 9 #122.b — Path B (Cloudflare Tunnel) helpers + "rc_remote_access_cloudflare_", ) @@ -539,8 +541,14 @@ def test_every_path_option_routed(package: dict) -> None: path_select = _helpers_by_entity_id(package, "input_select").get("rc_remote_access_setup_path") assert path_select is not None, "missing rc_remote_access_setup_path" path_options = set(path_select.get("options") or []) - assert path_options == {"tailscale", "cloudflare", "nabu_casa", "wireguard", "skip"}, ( - f"path options must match the slice spec; got {path_options}" + # After #122.b the wizard supports 6 paths (Path A + Path B wired + + # Path B legacy stub + Path C stub + Path D stub + skip). + assert path_options == { + "tailscale", "cloudflare_tunnel", "cloudflare", + "nabu_casa", "wireguard", "skip", + }, ( + f"path options must match the slice spec (#122.a + #122.b); " + f"got {path_options}" ) autos = _automations(package) routing = next( @@ -556,3 +564,167 @@ def test_every_path_option_routed(package: dict) -> None: assert path in action_text, ( f"path option {path!r} is missing from path_pick_routing automation" ) + + + +# ---------------------------------------------------------------------------- +# Wave 9 #122.b — Path B (Cloudflare Tunnel) wiring tests. +# +# The Path B addition extends `rc_remote_access_setup_path` with the +# new `cloudflare_tunnel` option + adds the two operator-entered +# input_text helpers (`rc_remote_access_cloudflare_token` in +# password-mode + `rc_remote_access_cloudflare_hostname`). +# +# Acceptance criteria (per the slice spec): +# - `test_cloudflare_appears_in_path_choice` — the new path option +# is in the input_select choices. +# - `test_cloudflare_password_field_uses_password_mode` — the +# tunnel-token helper is `mode: password` (sensitive — never +# logged; never displayed in clear text). +# - `test_path_a_inputs_preserved_bit_for_bit` — the existing +# Path A inputs (`rc_tailscale_auth_key` + +# `rc_tailscale_tailnet_hostname`) are unchanged (the #122.b +# doctrine says Path A is preserved bit-for-bit). +# - `test_cloudflare_setup_automation_idempotency` — the +# cloudflare_tunnel routing branch has idempotency markers +# (does NOT clear the token on routing; routes to the +# `cloudflare_tunnel_have_domain` stage; surfaces a +# persistent_notification with the user-facing message). +# ---------------------------------------------------------------------------- + + +def test_cloudflare_appears_in_path_choice(package: dict) -> None: + """The wizard's `rc_remote_access_setup_path` input_select MUST + include the new `cloudflare_tunnel` option (Wave 9 #122.b + Path B) alongside the existing 5 options.""" + helpers = _helpers_by_entity_id(package, "input_select") + path_select = helpers.get("rc_remote_access_setup_path") + assert path_select is not None, "missing rc_remote_access_setup_path" + options = set(path_select.get("options") or []) + assert "cloudflare_tunnel" in options, ( + f"cloudflare_tunnel must be in path options (Wave 9 #122.b " + f"Path B); got {options}" + ) + + +def test_cloudflare_password_field_uses_password_mode(package: dict) -> None: + """The new `rc_remote_access_cloudflare_token` input_text MUST + be `mode: password` (sensitive — never logged; never + displayed in clear text; never committed to the repo). The + hostname helper stays plain text (the operator must see what + they typed so they can spot a typo).""" + helpers = _helpers_by_entity_id(package, "input_text") + token = helpers.get("rc_remote_access_cloudflare_token") + hostname = helpers.get("rc_remote_access_cloudflare_hostname") + assert token is not None, ( + "missing rc_remote_access_cloudflare_token helper (Wave 9 #122.b Path B)" + ) + assert hostname is not None, ( + "missing rc_remote_access_cloudflare_hostname helper (Wave 9 #122.b Path B)" + ) + assert token.get("mode") == "password", ( + f"rc_remote_access_cloudflare_token MUST be mode: password " + f"(sensitive); got mode={token.get('mode')!r}" + ) + # The hostname helper is plain text so the operator can spot + # typos when reading back what they typed. This is intentional — + # the hostname is a DNS name, NOT a credential. + assert hostname.get("mode") != "password", ( + f"rc_remote_access_cloudflare_hostname must NOT be password " + f"mode (it's a DNS name, not a credential); got mode=" + f"{hostname.get('mode')!r}" + ) + + +def test_path_a_inputs_preserved_bit_for_bit(package: dict) -> None: + """The existing Path A (Tailscale) inputs MUST be unchanged + bit-for-bit by the #122.b slice. This is the explicit + acceptance criterion: "the existing `rc_tailscale_auth_key` + etc. inputs are unchanged". + + We assert: + - The two input_text helpers exist (the operator's auth key + + tailnet hostname) — neither has been renamed + neither + has been replaced. + - `rc_tailscale_auth_key` is still `mode: password`. + - `rc_tailscale_tailnet_hostname` is NOT password mode. + - The `initial` values are still empty strings (so the + operator's past-typed keys are NOT leaked into the new + YAML — the wizard always asks the operator to re-enter + the auth key, by design). + - The names are still the canonical operator-facing strings. + """ + helpers = _helpers_by_entity_id(package, "input_text") + auth_key = helpers.get("rc_tailscale_auth_key") + hostname = helpers.get("rc_tailscale_tailnet_hostname") + assert auth_key is not None, ( + "Path A: rc_tailscale_auth_key MUST be preserved bit-for-bit " + "(acceptance criterion); got None" + ) + assert hostname is not None, ( + "Path A: rc_tailscale_tailnet_hostname MUST be preserved " + "bit-for-bit (acceptance criterion); got None" + ) + # The mode + initial + name must all match the #122.a values + # exactly. Any drift here is a regression of the Path A + # contract. + assert auth_key.get("mode") == "password", ( + f"Path A: rc_tailscale_auth_key.mode MUST stay 'password' " + f"(sensitive); got {auth_key.get('mode')!r}" + ) + assert auth_key.get("initial") == "", ( + f"Path A: rc_tailscale_auth_key.initial MUST stay empty " + f"(operator-entered, never committed); got " + f"{auth_key.get('initial')!r}" + ) + assert "Auth Key" in (auth_key.get("name") or ""), ( + f"Path A: rc_tailscale_auth_key.name MUST still mention " + f"'Auth Key'; got {auth_key.get('name')!r}" + ) + assert "Tailnet Hostname" in (hostname.get("name") or ""), ( + f"Path A: rc_tailscale_tailnet_hostname.name MUST still " + f"mention 'Tailnet Hostname'; got {hostname.get('name')!r}" + ) + assert hostname.get("initial") == "", ( + f"Path A: rc_tailscale_tailnet_hostname.initial MUST stay " + f"empty; got {hostname.get('initial')!r}" + ) + + +def test_cloudflare_setup_automation_idempotency(package: dict) -> None: + """The cloudflare_tunnel branch in the path_pick_routing + automation MUST be idempotent (the routing branch must NOT + clear the operator's tunnel token + must route to the + `cloudflare_tunnel_have_domain` stage + must surface a + persistent_notification with a user-facing message).""" + autos = _automations(package) + routing = next( + (a for a in autos if a.get("id") == "rc_remote_access_setup_path_pick_routing"), + None, + ) + assert routing is not None, "missing rc_remote_access_setup_path_pick_routing automation" + action_text = yaml.safe_dump(routing.get("action") or [], default_flow_style=False) + # The cloudflare_tunnel routing branch must reference the new + # wizard stage + a user-facing persistent_notification. + assert "cloudflare_tunnel_have_domain" in action_text, ( + f"path_pick_routing MUST route cloudflare_tunnel to " + f"cloudflare_tunnel_have_domain stage; got action_text=" + f"{action_text}" + ) + # Idempotency: re-routing on the same path must not clear + # the operator's input_text helpers. The routing automation + # itself does not touch input_text (the operator stays in + # control of the token + hostname fields), so we assert the + # routing branch contains no `input_text.set_value` action. + assert "input_text.set_value" not in action_text, ( + f"path_pick_routing MUST NOT call input_text.set_value " + f"(would clear operator secrets); got action_text=" + f"{action_text}" + ) + # The cloudflare_tunnel branch must surface a + # persistent_notification with a user-facing title. + assert "Cloudflare Tunnel" in action_text, ( + f"path_pick_routing cloudflare_tunnel branch MUST surface a " + f"Cloudflare Tunnel notification; got action_text=" + f"{action_text}" + ) diff --git a/scripts/check.sh b/scripts/check.sh index 08ce3f1f..8c824a8f 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -76,6 +76,7 @@ run_if_present "scripts/checks/advanced-mode-smoke.sh" "Advanced mode: s run_if_present "scripts/checks/automation-apply-smoke.sh" "Automation apply: smoke check" run_if_present "scripts/checks/mode-builder-smoke.sh" "Mode builder: smoke check" run_if_present "scripts/checks/remote-access-setup-smoke.sh" "Remote access setup wizard (Tailscale Path A): smoke check" +run_if_present "scripts/checks/cloudflare-path-smoke.sh" "Remote access setup wizard (Cloudflare Tunnel Path B): smoke check" # Connection manifest smokes live under connections//tests/. We probe # for the well-known names so the chain picks them up automatically once diff --git a/scripts/checks/cloudflare-path-smoke.sh b/scripts/checks/cloudflare-path-smoke.sh new file mode 100755 index 00000000..b1a9c665 --- /dev/null +++ b/scripts/checks/cloudflare-path-smoke.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# scripts/checks/cloudflare-path-smoke.sh +# +# Wave 9 #122.b — Phase 6 Cloudflare Tunnel (Path B). Repo-local +# verification of the Path B addition to the wizard: the new +# `cloudflare_tunnel` setup-path entry in +# connections/remote-access/connection.yml + the new +# `rc_remote_access_cloudflare_*` helpers in +# homeassistant/packages/roamcore_remote_access_setup.yaml + +# the new tests in tests/test_connection_yml.py + +# the user-facing IKEA doc at docs/catalog/remote-access/cloudflare.md. +# +# Mirrors the convention in scripts/checks/.sh: +# - bash strict mode (set -euo pipefail) +# - repo-local only (no live HA / Proxmox / OpenWrt calls) +# - 6+ plain-English assertions covering: YAML parses, both +# Path A + Path B appear in path choices, secret: true marker +# on the Cloudflare token, rc-entity-naming compliance, +# pytest test count ≥4 for the new Cloudflare tests, secrets- +# leak grep on the new YAML + Python files. +# - plain-English summary at exit 0 / non-zero exit +# +# Doctrine (Bernard, 2026-08-04): must not fail + super intuitive + +# critical infrastructure. This script is a defensive guard that +# catches regressions before they land on main. +# +# Idempotent — safe to run repeatedly. +# +# Usage: +# bash scripts/checks/cloudflare-path-smoke.sh +# +# Exit codes: +# 0 all 6+ assertions passed (PASS) +# 1 one or more assertions failed (FAIL — see summary above) +# +# Wired into scripts/check.sh as a `run_if_present` step in the +# core-only chain (next to the other connection smokes). + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +MANIFEST="$ROOT_DIR/connections/remote-access/connection.yml" +PACKAGE="$ROOT_DIR/homeassistant/packages/roamcore_remote_access_setup.yaml" +PYTEST_REMOTE="$ROOT_DIR/connections/remote-access/tests/test_connection_yml.py" +PYTEST_PACKAGE="$ROOT_DIR/homeassistant/packages/tests/test_remote_access_setup.py" +IKEA_DOC="$ROOT_DIR/docs/catalog/remote-access/cloudflare.md" + +fail=0 +pass=0 + +note_pass() { printf ' \033[1;32m✓\033[0m %s\n' "$1"; pass=$((pass+1)); } +note_fail() { printf ' \033[1;31m✗\033[0m %s\n' "$1"; fail=$((fail+1)); } + +echo +echo "▶ Cloudflare Path B (Wave 9 #122.b): file presence" + +for f in "$MANIFEST" "$PACKAGE" "$PYTEST_REMOTE" "$PYTEST_PACKAGE" "$IKEA_DOC"; do + if [ -f "$f" ]; then + note_pass "file exists: ${f#$ROOT_DIR/}" + else + note_fail "missing file: ${f#$ROOT_DIR/}" + fi +done + +# If any file is missing, abort early — the remaining assertions +# would all fail and produce noisy output. +if [ "$fail" -gt 0 ]; then + echo + echo "Summary" + echo "=======" + printf ' PASS: %d\n' "$pass" + printf ' FAIL: %d\n' "$fail" + printf '\n\033[1;31m✗ cloudflare path smoke FAILED (missing file(s))\033[0m\n' + exit 1 +fi + +echo +echo "▶ Cloudflare Path B: YAML parses (manifest + package)" + +if python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then + note_pass "manifest YAML parses (connection.yml)" +else + note_fail "manifest YAML parse error — see python3 output above" +fi + +if python3 -c "import yaml; yaml.safe_load(open('$PACKAGE'))" 2>/dev/null; then + note_pass "package YAML parses (roamcore_remote_access_setup.yaml)" +else + note_fail "package YAML parse error — see python3 output above" +fi + +echo +echo "▶ Cloudflare Path B: both Path A (tailscale) + Path B (cloudflare_tunnel) in path choices" + +path_check=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +opts = (data["input_select"]["rc_remote_access_setup_path"]["options"]) or [] +expected_paths = {"tailscale", "cloudflare_tunnel"} +missing = expected_paths - set(opts) +if missing: + print(f"MISSING: {sorted(missing)}") + sys.exit(1) +PYEOF +) || true +if [ -z "$path_check" ]; then + note_pass "both Path A (tailscale) and Path B (cloudflare_tunnel) appear in input_select.rc_remote_access_setup_path options" +else + note_fail "path-choice gap: $path_check" +fi + +echo +echo "▶ Cloudflare Path B: secret: true marker on the Cloudflare tunnel token" + +secret_check=$(python3 - "$MANIFEST" "$PACKAGE" <<'PYEOF' +import sys, yaml +manifest = yaml.safe_load(open(sys.argv[1])) +package = yaml.safe_load(open(sys.argv[2])) + +# (1) Manifest setup_paths entry: requires_inputs cloudflare_tunnel_token.secret +cf_paths = [p for p in (manifest.get("wizard", {}).get("setup_paths") or []) + if p.get("id") == "cloudflare_tunnel"] +if not cf_paths: + print("MISSING_CF_PATH") + sys.exit(1) +cf_path = cf_paths[0] +token_input = next((i for i in cf_path.get("requires_inputs", []) + if i.get("field") == "cloudflare_tunnel_token"), None) +if token_input is None: + print("MISSING_TOKEN_INPUT") + sys.exit(1) +if not token_input.get("secret") is True: + print(f"MANIFEST_SECRET={token_input.get('secret')!r}") + sys.exit(1) + +# (2) Package helper: input_text.rc_remote_access_cloudflare_token.mode +helpers = package.get("input_text", {}) or {} +helper = helpers.get("rc_remote_access_cloudflare_token") +if helper is None: + print("MISSING_HELPER") + sys.exit(1) +if helper.get("mode") != "password": + print(f"PKG_MODE={helper.get('mode')!r}") + sys.exit(1) +PYEOF +) || true +if [ -z "$secret_check" ]; then + note_pass "secret: true marker present on cloudflare_tunnel_token (manifest) + mode: password on rc_remote_access_cloudflare_token (package)" +else + note_fail "secret marker missing: $secret_check" +fi + +echo +echo "▶ Cloudflare Path B: rc-entity-naming compliance (every new entity starts with rc_remote_access_)" + +naming_check=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +package = yaml.safe_load(open(sys.argv[1])) + +allowed_prefixes = ("rc_remote_access_", "rc_cloudflare_", "rc_tailscale_", "rc_setup_") +violations = [] + +# Check the package's helpers (input_select / input_text / input_boolean) +# — every entity_id MUST start with one of the allowed prefixes. +for kind in ("input_select", "input_text", "input_boolean", "input_number", "input_datetime"): + for eid in (package.get(kind) or {}).keys(): + if not any(eid.startswith(p) for p in allowed_prefixes): + violations.append(f"PKG:{kind}.{eid}") + +if violations: + for v in violations: + print(f" VIOLATION: {v}") + sys.exit(1) +PYEOF +) || true +if [ -z "$naming_check" ]; then + note_pass "every new entity_id complies with docs/reference/rc-entity-naming.md" +else + note_fail "rc-naming violations found" + echo "$naming_check" +fi + +echo +echo "▶ Cloudflare Path B: pytest test count for the new Cloudflare tests ≥5" + +test_count=$(grep -cE '^def test_cloudflare_path_in_setup_paths\(|^def test_cloudflare_path_has_token_secret_marker\(|^def test_cloudflare_path_does_not_require_reboot\(|^def test_cloudflare_path_idempotency\(|^def test_cloudflare_path_retry_with_backoff\(|^def test_describe_cloudflare_setup_path\(|^def test_cloudflare_appears_in_path_choice\(|^def test_cloudflare_password_field_uses_password_mode\(|^def test_path_a_inputs_preserved_bit_for_bit\(|^def test_cloudflare_setup_automation_idempotency\(' "$PYTEST_REMOTE" "$PYTEST_PACKAGE" 2>/dev/null | awk -F: '{sum += $2} END {print sum}') +if [ -n "$test_count" ] && [ "$test_count" -ge 5 ]; then + note_pass "found $test_count new Cloudflare-related tests (≥5 required by the slice spec)" +else + note_fail "found ${test_count:-0} new Cloudflare tests; need ≥5" +fi + +echo +echo "▶ Cloudflare Path B: secrets-leak grep on the new YAML + Python files" + +# Search for hardcoded Cloudflare tunnel tokens / real hostnames +# in the new files. The grep must NOT match the placeholder +# patterns (``, `my-van.example.com`, +# `one.dash.cloudflare.com`). +SECRETS_LEAK=$(grep -E '(eyJhI[A-Za-z0-9+/=]{40,}|CF[A-Za-z0-9]{40,}==|tskey-[A-Za-z0-9_-]{10,})' "$MANIFEST" "$PACKAGE" "$PYTEST_REMOTE" "$PYTEST_PACKAGE" "$IKEA_DOC" 2>/dev/null | grep -v '^Binary' || true) +if [ -z "$SECRETS_LEAK" ]; then + note_pass "no hardcoded tokens / secrets leaked in new files (placeholders allowed)" +else + note_fail "SECRET PATTERN FOUND in new files — operator credentials MUST NOT be committed" + echo "$SECRETS_LEAK" +fi + +echo +echo "Summary" +echo "=======" +printf ' PASS: %d\n' "$pass" +printf ' FAIL: %d\n' "$fail" + +if [ "$fail" -gt 0 ]; then + printf '\n\033[1;31m✗ cloudflare path smoke FAILED\033[0m\n' + exit 1 +fi + +printf '\n\033[1;32m✓ cloudflare path smoke PASSED\033[0m\n' +exit 0 \ No newline at end of file diff --git a/scripts/checks/factory-reset-smoke.sh b/scripts/checks/factory-reset-smoke.sh new file mode 100755 index 00000000..8bc6c5ba --- /dev/null +++ b/scripts/checks/factory-reset-smoke.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# Factory Reset — Phase 7 — Wave 9 #123.b — smoke check +# +# 12 bash assertions covering: +# 1. connection manifest present + parses as YAML +# 2. tier-a markers present (real RoamCore-owned integration code at factory_reset.py) +# 3. tier-a markers + `requires: hub-backup` listed in connection.yml +# 4. YAML package parses cleanly +# 5. every required input helper present (5 inputs) +# 6. every template sensor present (5 sensors) +# 7. every §8 automation present (5 automations named) +# 8. service wiring: 4 service definitions in services.yaml +# 9. rc-entity-naming: every entity_id starts with rc_factory_reset_ +# 10. secrets-leak: grep returns no matches for hardcoded URLs/passwords +# 11. service-definition YAML parse: 4 new services, each with a `name` field +# 12. OpenClaw audit dependency: the recovery automation references +# binary_sensor.rc_openclaw_api_chain_valid +# +# Mirrors the convention in scripts/checks/.sh: +# - bash strict mode (set -euo pipefail) +# - repo-local only (no live HA / Proxmox calls) +# - plain-English summary at exit 0 / non-zero exit +# +# Usage: +# bash scripts/checks/factory-reset-smoke.sh +# +# Exit codes: +# 0 all 12 assertions PASS +# 1 one or more assertions FAIL +# +# Wired into scripts/check.sh as a `run_if_present` step. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +PASS_COUNT=0 +FAIL_COUNT=0 + +assert_pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf '\033[1;32m✓\033[0m %s\n' "$1" +} + +assert_fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf '\033[1;31m✗\033[0m %s\n' "$1" + if [ -n "${2:-}" ]; then + printf ' reason: %s\n' "$2" + fi +} + +python3 - <<'PYEOF' +import os, re, sys + +REPO_ROOT = os.getcwd() +PASS = 0 +FAIL = 0 + +def pass_(msg): + global PASS + PASS += 1 + print(f"\033[1;32m✓\033[0m {msg}") + +def fail_(msg, reason=""): + global FAIL + FAIL += 1 + print(f"\033[1;31m✗\033[0m {msg}") + if reason: + print(f" reason: {reason}") + +import yaml + +# ---- Assertion 1: connection manifest present + parses as YAML ---- +manifest_path = os.path.join(REPO_ROOT, "connections/factory-reset/connection.yml") +if os.path.isfile(manifest_path): + try: + with open(manifest_path, encoding="utf-8") as f: + manifest = yaml.safe_load(f) + if isinstance(manifest, dict): + pass_("assertion 1: connection manifest exists + parses as YAML") + else: + fail_("assertion 1: connection manifest parses but is not a dict", "") + except Exception as e: + fail_("assertion 1: connection manifest fails to parse as YAML", str(e)) +else: + fail_("assertion 1: connection manifest missing", f"expected at {manifest_path}") + +# ---- Assertion 2: tier-a markers present (real RoamCore-owned integration code at factory_reset.py) ---- +factory_reset_py_path = os.path.join(REPO_ROOT, "homeassistant/custom_components/roamcore/factory_reset.py") +if os.path.isfile(factory_reset_py_path): + with open(factory_reset_py_path, encoding="utf-8") as f: + factory_reset_py_text = f.read() + expected_markers = ( + "FACTORY_RESET_TILE_PREFIX", + "async def async_dry_run", + "async def async_confirm", + "async def async_cancel", + "async def async_postflight_check", + "async def recovery_resets", + "def register_factory_reset_services", + "def plain_english_reason", + "def is_backup_fresh", + "class RoamCoreFactoryResetView", + "BACKUP_FRESHNESS_WINDOW_MINUTES", + "EXPECTED_CONFIRM_TOKEN", + ) + missing = [m for m in expected_markers if m not in factory_reset_py_text] + if not missing: + pass_("assertion 2: tier-a markers present (real RoamCore-owned integration code at factory_reset.py)") + else: + fail_("assertion 2: tier-a markers missing from factory_reset.py", f"missing: {missing}") +else: + fail_("assertion 2: factory_reset.py missing", f"expected at {factory_reset_py_path}") + +# ---- Assertion 3: tier-a markers + `requires: hub-backup` listed in connection.yml ---- +if os.path.isfile(manifest_path): + requires = manifest.get("requires", []) if isinstance(manifest, dict) else [] + if "hub-backup" in requires: + pass_("assertion 3: tier-a markers + `requires: hub-backup` listed in connection.yml") + else: + fail_("assertion 3: `requires: hub-backup` not listed", f"requires={requires!r}") + if isinstance(manifest, dict) and manifest.get("tier") == "a": + pass + else: + fail_("assertion 3: tier is not 'a'", f"tier={manifest.get('tier')!r}") +else: + fail_("assertion 3: cannot check `requires: hub-backup` (manifest missing)", "") + +# ---- Assertion 4: YAML package parses cleanly ---- +helper_package_path = os.path.join(REPO_ROOT, "homeassistant/packages/roamcore_factory_reset.yaml") +if os.path.isfile(helper_package_path): + try: + with open(helper_package_path, encoding="utf-8") as f: + package = yaml.safe_load(f) + if isinstance(package, dict): + pass_("assertion 4: YAML package parses cleanly") + else: + fail_("assertion 4: YAML package parses but is not a dict", "") + except Exception as e: + fail_("assertion 4: YAML package fails to parse", str(e)) +else: + fail_("assertion 4: helper package missing", f"expected at {helper_package_path}") + +# ---- Assertion 5: every required input helper present (5 inputs) ---- +if os.path.isfile(helper_package_path): + required_helpers = ( + ("input_button", "rc_factory_reset_dry_run"), + ("input_button", "rc_factory_reset_confirm"), + ("input_text", "rc_factory_reset_token"), + ("input_text", "rc_factory_reset_dry_run_report"), + ("input_boolean", "rc_factory_reset_armed"), + ("input_datetime", "rc_factory_reset_last_dry_run"), + ) + missing_helpers = [] + for section, name in required_helpers: + section_dict = package.get(section, {}) + if name not in section_dict: + missing_helpers.append(f"{section}.{name}") + if not missing_helpers: + pass_("assertion 5: every required input helper present (6 inputs)") + else: + fail_("assertion 5: missing required helpers", f"missing: {missing_helpers}") +else: + fail_("assertion 5: cannot check helpers (helper package missing)", "") + +# ---- Assertion 6: every template sensor present (5 sensors) ---- +if os.path.isfile(helper_package_path): + required_sensors = ( + "rc_factory_reset_status", + "rc_factory_reset_last_backup_age", + "rc_factory_reset_preflight_warnings", + "rc_factory_reset_postflight_status", + "rc_factory_reset_safe_to_run", + ) + found_sensors = set() + template = package.get("template", []) + for entry in template: + if isinstance(entry, dict) and "sensor" in entry: + for sensor in entry.get("sensor", []): + uid = sensor.get("unique_id") + if uid: + found_sensors.add(uid) + if isinstance(entry, dict) and "binary_sensor" in entry: + for bsensor in entry.get("binary_sensor", []): + uid = bsensor.get("unique_id") + if uid: + found_sensors.add(uid) + missing_sensors = [s for s in required_sensors if s not in found_sensors] + if not missing_sensors: + pass_("assertion 6: every template sensor present (5 sensors)") + else: + fail_("assertion 6: missing required template sensors", f"missing: {missing_sensors}") +else: + fail_("assertion 6: cannot check sensors (helper package missing)", "") + +# ---- Assertion 7: every §8 automation present (5 automations named) ---- +if os.path.isfile(helper_package_path): + automations = package.get("automation", []) + automation_ids = [auto.get("id") for auto in automations if isinstance(auto, dict)] + required_automation_ids = ( + "rc_factory_reset_dry_run_sets_token", + "rc_factory_reset_confirm_requires_token_match", + "rc_factory_reset_cancel_clears_token", + "rc_factory_reset_postflight_check_on_boot", + "rc_factory_reset_recovery_on_audit_chain_invalid", + ) + missing_automations = [a for a in required_automation_ids if a not in automation_ids] + if not missing_automations: + pass_("assertion 7: every §8 automation present (5 named)") + else: + fail_("assertion 7: missing §8 automations", f"missing: {missing_automations}") +else: + fail_("assertion 7: cannot check automations (helper package missing)", "") + +# ---- Assertion 8: service wiring: 4 service definitions in services.yaml ---- +services_yaml_path = os.path.join(REPO_ROOT, "homeassistant/custom_components/roamcore/services.yaml") +if os.path.isfile(services_yaml_path): + try: + with open(services_yaml_path, encoding="utf-8") as f: + services = yaml.safe_load(f) + if not isinstance(services, dict): + services = {} + required_services = ( + "factory_reset_dry_run", + "factory_reset_confirm", + "factory_reset_cancel", + "factory_reset_postflight_check", + ) + missing_services = [s for s in required_services if s not in services] + if not missing_services: + pass_("assertion 8: service wiring: 4 service definitions in services.yaml") + else: + fail_("assertion 8: missing service definitions", f"missing: {missing_services}") + except Exception as e: + fail_("assertion 8: services.yaml fails to parse", str(e)) +else: + fail_("assertion 8: services.yaml missing", f"expected at {services_yaml_path}") + +# ---- Assertion 9: rc-entity-naming: every entity_id starts with rc_factory_reset_ ---- +if os.path.isfile(helper_package_path): + text = open(helper_package_path, encoding="utf-8").read() + rc_entity_ids = re.findall(r"\brc_\w+", text) + rc_entity_ids = sorted(set(rc_entity_ids)) + # Filter to ONLY factory_reset entities (the helper package may + # reference foreign entities from other connections like + # hub-backup + openclaw-api — those are NOT factory_reset + # violations, they are intentional cross-references). + factory_reset_ids = [eid for eid in rc_entity_ids if eid.startswith("rc_factory_reset_")] + non_compliant = [eid for eid in factory_reset_ids if not eid.startswith("rc_factory_reset_")] + if not non_compliant: + pass_(f"assertion 9: rc-entity-naming: every factory_reset entity_id starts with rc_factory_reset_ ({len(factory_reset_ids)} factory_reset entities; {len(rc_entity_ids) - len(factory_reset_ids)} foreign cross-references)") + else: + fail_("assertion 9: rc-entity-naming violation", f"non-compliant entities: {non_compliant}") +else: + fail_("assertion 9: cannot check rc-entity-naming (helper package missing)", "") + +# ---- Assertion 10: secrets-leak: grep returns no matches for hardcoded URLs/passwords ---- +files_to_check = [ + helper_package_path, + services_yaml_path, + factory_reset_py_path, + manifest_path, + os.path.join(REPO_ROOT, "connections/factory-reset/docs/recipe.md"), + os.path.join(REPO_ROOT, "connections/factory-reset/README.md"), + os.path.join(REPO_ROOT, "connections/factory-reset/__init__.py"), + # NOTE: the pytest rig itself is NOT checked here (it contains + # the forbidden-pattern strings as test data; checking it would + # cause a self-reference false positive — the bash smoke handles + # this via the files_to_check list inside the Python heredoc). +] +files_to_check = [f for f in files_to_check if os.path.isfile(f)] +forbidden_url_patterns = ( + r"https://AKIA", + r"https://arn:aws", + r"https://hooks\.slack", + r"https://discord\.com/api/webhooks", + r"https://api\.telegram\.org/bot", +) +forbidden_password_patterns = ( + r'password:\s*"hunter2"', + r'password\s*=\s*"hunter2"', + r'api_key:\s*"secret"', + r'api_key\s*=\s*"secret"', +) +forbidden_user_paths = ( + "/home/bernard", + "/home/user", + "/home/admin", +) +leak_files = [] +for path in files_to_check: + text = open(path, encoding="utf-8").read() + for pattern in forbidden_url_patterns + forbidden_password_patterns + forbidden_user_paths: + if re.search(pattern, text, re.IGNORECASE): + leak_files.append((path, pattern)) +if not leak_files: + pass_(f"assertion 10: secrets-leak: no hardcoded URLs/passwords/{len(forbidden_user_paths)} user paths ({len(files_to_check)} files checked)") +else: + fail_("assertion 10: secrets-leak detected", f"matches: {leak_files}") + +# ---- Assertion 11: service-definition YAML parse: 4 new services, each with a `name` field ---- +if os.path.isfile(services_yaml_path): + try: + with open(services_yaml_path, encoding="utf-8") as f: + services = yaml.safe_load(f) + if not isinstance(services, dict): + services = {} + new_services = ( + "factory_reset_dry_run", + "factory_reset_confirm", + "factory_reset_cancel", + "factory_reset_postflight_check", + ) + all_present = all(s in services for s in new_services) + all_have_name = all( + services.get(s, {}).get("name") for s in new_services + ) + if all_present and all_have_name: + pass_("assertion 11: service-definition YAML parse: 4 new services + parses cleanly + each has a `name` field") + else: + missing = [s for s in new_services if s not in services] + no_name = [s for s in new_services if s in services and not services[s].get("name")] + reason_parts = [] + if missing: + reason_parts.append(f"missing services: {missing}") + if no_name: + reason_parts.append(f"services missing `name` field: {no_name}") + fail_("assertion 11: service definitions incomplete", "; ".join(reason_parts)) + except Exception as e: + fail_("assertion 11: services.yaml fails to parse", str(e)) +else: + fail_("assertion 11: services.yaml missing", f"expected at {services_yaml_path}") + +# ---- Assertion 12: OpenClaw audit dependency: the recovery automation references binary_sensor.rc_openclaw_api_chain_valid ---- +if os.path.isfile(helper_package_path): + automations = package.get("automation", []) + target = None + for auto in automations: + if isinstance(auto, dict) and auto.get("id") == "rc_factory_reset_recovery_on_audit_chain_invalid": + target = auto + break + if target is not None: + triggers = target.get("trigger", []) + found_openclaw_ref = False + for trg in triggers: + if trg.get("entity_id") == "binary_sensor.rc_openclaw_api_chain_valid": + found_openclaw_ref = True + break + if found_openclaw_ref: + pass_("assertion 12: OpenClaw audit dependency: the recovery automation references binary_sensor.rc_openclaw_api_chain_valid") + else: + fail_("assertion 12: recovery automation does not reference binary_sensor.rc_openclaw_api_chain_valid", "trigger entity_id mismatch") + else: + fail_("assertion 12: recovery_on_audit_chain_invalid automation missing", "") +else: + fail_("assertion 12: cannot check openclaw reference (helper package missing)", "") + +print() +print(f"PASS: {PASS} / FAIL: {FAIL}") +sys.exit(0 if FAIL == 0 else 1) +PYEOF + +PYTHON_EXIT=$? + +if [ "$PYTHON_EXIT" -eq 0 ]; then + echo "OK: all 12 factory-reset assertions passed" + exit 0 +else + echo "FAIL: factory-reset smoke check failed (see Python output above for details)" + exit 1 +fi diff --git a/scripts/checks/remote-access-setup-smoke.sh b/scripts/checks/remote-access-setup-smoke.sh index 7dd6e78b..505ea937 100755 --- a/scripts/checks/remote-access-setup-smoke.sh +++ b/scripts/checks/remote-access-setup-smoke.sh @@ -76,10 +76,13 @@ echo echo "▶ Remote access setup wizard: rc-entity-naming pre-check" # Every entity_id in the package MUST start with rc_remote_access_setup_ or rc_tailscale_ +# (Wave 9 #122.b — Path B adds rc_remote_access_cloudflare_* helpers, +# which are allowed too: the wizard UI uses them to collect the operator's +# tunnel token + hostname for the Cloudflare Tunnel path.) naming_violations=$(python3 - "$PACKAGE" <<'PYEOF' import sys, yaml data = yaml.safe_load(open(sys.argv[1])) -allowed = ("rc_remote_access_setup_", "rc_tailscale_") +allowed = ("rc_remote_access_setup_", "rc_tailscale_", "rc_remote_access_cloudflare_") violations = [] for kind in ("input_select", "input_text", "input_boolean", "input_number", "input_datetime"): for eid in (data.get(kind) or {}).keys():