Skip to content

v3.0.0#29

Merged
chenliuyun merged 53 commits intomainfrom
feat/v3.0
Apr 24, 2026
Merged

v3.0.0#29
chenliuyun merged 53 commits intomainfrom
feat/v3.0

Conversation

@chenliuyun
Copy link
Copy Markdown
Collaborator

@chenliuyun chenliuyun commented Apr 24, 2026

Summary

  • Breaking: remove destructive: boolean field; use safetyTier enum instead
  • Breaking: drop policy schema v0.1 support; v0.2 is now the only supported schema
  • Breaking: buildStatusSyncChildArgs no longer takes openclawToken; pass via env OPENCLAW_TOKEN
  • Add switchbot install / uninstall — one-command setup with preflight, rollback, and 4-backend keychain abstraction (macOS / Linux / Windows / file)
  • Add rules engine: MQTT, cron, webhook triggers; time_between / device_state / all/any/not conditions; per-rule throttle; audit log; hot-reload via SIGHUP / sentinel file
  • Add switchbot auth keychain subcommand group (describe, get, set, delete, migrate)
  • Add switchbot policy subcommand group (validate, new, migrate, diff, add-rule) with v0.2 schema
  • Add switchbot rules subcommand group (suggest, lint, list, run, reload, tail, replay, webhook-rotate-token)
  • Add switchbot status-sync subcommand group (run, start, stop, status) — OpenClaw MQTT bridge
  • Expand MCP server: plan_suggest, plan_run, audit_query, audit_stats, policy_diff, policy_validate, policy_new, policy_migrate
  • Expand doctor: policy section, keychain backend detection, quota check, catalog-schema parity, MCP check
  • Fix events receiver 413 race on Windows (replace connection: close with req.resume())
  • Fix status-sync start output to show both stdout and stderr log paths
  • Fix auth import: reject JSON arrays as credential source
  • Add smoke tests for status-sync CLI
  • Update README: correct policy schema docs, project layout, test counts, MCP tool list for v3.0

Migration Guide

destructive field removal

- if (spec.destructive) { ... }
+ if (spec.safetyTier === 'destructive') { ... }

Policy v0.1 → v0.2

Run on CLI ≤ 2.15 before upgrading to v3.0:

switchbot policy migrate

v3.0 rejects v0.1 files outright with a migration hint.

status-sync token

- buildStatusSyncChildArgs({ openclawUrl, openclawToken, openclawModel })
+ buildStatusSyncChildArgs({ openclawUrl, openclawModel })
+ // pass token via env: OPENCLAW_TOKEN=<token>

Test Plan

  • npm test — 1765/1765 passing
  • CI matrix covers macOS / Linux / Windows (keychain backends)
  • Smoke tests for status-sync CLI
  • Pre-release hardening: Windows TCP fix, token-in-env, state file validation, process-dead check after kill

chenliuyun added 30 commits April 22, 2026 22:54
First vertical slice of Phase 2 policy tooling: load a policy.yaml,
validate it against the embedded v0.1 JSON Schema, and report errors
with accurate YAML line/col positions suitable for editor jump-to.

Added:
- src/policy/schema/v0.1.json — schema copied from openclaw-switchbot-
  skill. CI-enforced sync will land in Day 5.
- src/policy/schema.ts — versioned loader with caching.
- src/policy/load.ts — YAML parsing with yaml@2 LineCounter; typed errors
  for file-not-found and YAML-syntax failures; env + flag + default path
  resolution.
- src/policy/validate.ts — Ajv2020 validator, JSON-Pointer to YAML
  node walker, human messages, and hint table for the most common
  mistakes (wrong version const, alias pattern, never_confirm-contains-
  destructive, missing version).

Build:
- scripts/copy-assets.mjs copies src/policy/schema/*.json to dist/ so
  the schema ships with the npm tarball.
- Both build scripts now chain tsc and the copy step.

Deps: add yaml@^2, ajv@^8, ajv-formats@^3.

Schema note: widened optional top-level blocks (aliases, confirmations,
quiet_hours, audit, automation, cli) and the two array children
(always_confirm, never_confirm) to accept null. YAML keys with every
child commented out parse as null and that's a common authoring state;
the old schema rejected the skill's own example.yaml. quiet_hours now
uses dependentRequired so start/end must either both be set or both
absent. Semver-safe: wider accept set.

Commands (validate/new/migrate) and pretty-printer follow in Day 2-3.
Day 2 of Phase 2. formatValidationResult renders validation errors as:

  policy.yaml:3:20
    3 |   "bedroom light": "not-a-deviceid"
                           ^^^^^^^^^^^^^^^^
  error: /aliases/bedroom light does not match pattern ...
  hint:  paste the deviceId from `switchbot devices list --format=tsv`

  ✗ 1 error in policy.yaml (schema v0.1)

- chalk@5 Chalk({level:0}) for --no-color without Proxy hacks.
- Caret length inferred from the token at col: quoted strings get their
  full span, bare words stop at whitespace/commas/brackets.

Locator refinements in validate.ts:
- additionalProperties errors now jump to the offending key's position
  in the YAML map, not the parent object's start line.
- required and dependentRequired fall back to the parent node's line
  instead of returning nothing when the missing key has no YAML
  representation.
- dependentRequired gets a human phrasing: "when X is set, Y is also
  required".
Adds the `switchbot policy` command group with three subcommands:
- validate [path]: validates policy.yaml against embedded v0.1 schema,
  emits compiler-style errors with line:col + caret + hints
- new [path]: writes annotated starter template to default path or given
  location (fails with exit 5 unless --force)
- migrate [path]: reports schema version status (no-op for v0.1;
  distinguishes already-current / older-but-supported / no-version /
  unsupported)

Ships policy.example.yaml as a dist asset via copy-assets.mjs so the
`new` subcommand can locate the template at runtime.

Exit codes: 0 valid, 1 invalid, 2 file-not-found, 3 yaml-parse,
4 internal, 5 exists (new), 6 unsupported-version (migrate).

Version bump + CHANGELOG land together on Day 5.
Adds 48 tests across three files:

tests/policy/validate.test.ts (22): drives validateLoadedPolicy against
real YAML fixtures — happy path for the shipped template + a minimal
policy, missing-version hint, wrong version const, unknown top-level
key, aliases deviceId pattern, never_confirm blacklist (lock/unlock/
delete*/factoryReset), quiet_hours HH:MM + dependentRequired, audit
retention units, cli.cache_ttl units, and line/col reporting.

tests/policy/load.test.ts (10): loader failure modes (ENOENT, YAML
parse errors with line info), utf-8 BOM / CRLF / Chinese aliases,
and resolvePolicyPath precedence (flag > env > default).

tests/commands/policy.test.ts (16): drives the commander tree via
program.parse and stubs process.exit — `new` writes + refuses to
overwrite (exit 5) + --force; `validate` exit codes 0/1/2/3 plus
JSON envelope shape on success, failure, and file-not-found;
`migrate` all four states (already-current / no-version /
unsupported / missing file).

Suite now: 63 files / 1315 tests (was 60 / 1267).
…(Day 5)

- Bump @switchbot/openapi-cli to 2.8.0 (minor — new command group,
  no breaking changes; adds `policy validate/new/migrate`).
- CHANGELOG: add [2.8.0] section covering motivation (skill silent-
  failure mode), surface (3 subcommands, path resolution order, exit
  codes, new dependencies), and skill-side impact.
- README: add `policy` section to the Commands group with a worked
  example of a lowercased-deviceId error (shows the compiler-style
  diagnostic), plus a ToC entry between `cache` and `completion`.
- CI: new `policy-schema-sync` job fetches the skill repo's
  examples/policy.schema.json via raw.githubusercontent.com and
  diffs it against src/policy/schema/v0.1.json. Fails the build if
  the two drift, so the skill mirror can't lag silently.
…trap

- README test count 692/592 -> 1315 (three call sites)
- agent-bootstrap QUICK_REFERENCE gains a `policy` key so agents see
  `policy validate / new / migrate` alongside the other quick-reach
  commands. Was an oversight when 2.8.0 shipped — the commands exist
  in `capabilities` but weren't in the compact bootstrap payload.
- New docs/policy-reference.md — field-level reference for policy.yaml
  (file location, every top-level block, validation flow, exit codes,
  common-error catalogue, migration notes).
- docs/agent-guide.md gains a "Policy awareness" section between
  safety rails and observability. Tells agents to read the file via
  `switchbot policy validate` rather than parsing YAML directly.
- README.md gains a top-level "Policy" section (scaffold/validate
  flow + rationale), linking to the reference.

Nothing in the policy tooling itself changed — this fills the
documentation gap around the 2.8.0 command group.
…dling

Three new tests, all regression guards:

- agent-bootstrap.test.ts: quickReference must carry every command
  group an agent needs to discover (discovery/action/safety/
  observability/history/meta/policy). Catches future command
  additions that skip the bootstrap surface.
- validate.test.ts: per-block null regression (aliases/confirmations/
  quiet_hours/audit/automation/cli). The previous combined test would
  pass even if only one block's null support regressed; these pin
  each independently.
- load.test.ts: current policy path is not profile-aware — pins the
  behavior so profile-scoped paths, when added, surface with a clear
  failing name and prompt an update to docs/policy-reference.md.

policy-reference.md gains a small note about the profile gap under
`cli.profile`.

Total tests: 1315 -> 1323.
- src/policy/schema/v0.2.draft.json — not yet wired into the
  validator. Tightens the `automation.rules[]` shape that v0.1 left
  as a loose `array of object`. Every other v0.1 block is unchanged;
  the migration is additive.
- docs/design/phase4-rules-schema.md — explains the rule shape,
  trigger/condition/action $defs, what's in scope vs deferred, and
  the v0.1 -> v0.2 migration plan.

Goal: pin the policy-side contract so the Phase 4 rule engine has a
fixed target to build against.
Pins what `openclaw plugins install clawhub:switchbot` needs to do:

- Nine-step install flow with explicit undo stack for rollback
- Keychain abstraction interface (macOS / Windows / Linux + file
  fallback), naming convention, backend selection rules
- Pre-flight check matrix with pass/warn/fail semantics
- Credential capture (interactive only; no CLI-arg path)
- Skill install strategy — native for Claude Code, recipe-printing
  for other agents
- Uninstall flow (reverse of install, dangerous-default-no prompts)
- Testing strategy (unit / integration / rollback failure injection)
- Open questions (language choice, naming, skill version pinning)

No code yet; this is the target shape the implementation should hit.
Companion to phase4-rules-schema.md. Pins the runtime side:

- Architecture — single foreground process (`switchbot rules run`),
  no daemon, no DB. In-memory state, documented "restart = reset".
- Triggers — mqtt (own connection, peer to `events mqtt-tail`), cron
  (local timezone, no DST cleverness), webhook (localhost only,
  bearer token from keychain).
- Actions — tier gates still apply, `on_error` policy, IR "fire and
  forget" audit semantics.
- Throttle, dry_run, audit replay, hot reload (SIGHUP).
- Performance targets (not gates) for latency, memory, CPU.
- Security — no arbitrary shell, no exposed webhook by default.
- Testing strategy (unit / integration / fuzz / dry-run parity).
- Open questions (subcommand vs sibling package; doctor signal;
  dry-run audit location).

Dependencies on Phase 3 (keychain, install flow) and the v0.2 schema
are explicit. No code yet.
`switchbot doctor` now runs a `policy` check that distinguishes three
outcomes: no policy file (ok, `present: false`), valid file (ok), and
schema/YAML failure (fail, with first error surfaced). Agents can now
read one doctor payload to tell whether a user has configured policy
and whether it's healthy, without an extra `policy validate` call.
Adds a `policyStatus: { present, valid, path, schemaVersion?, errorCount? }`
field so agents get policy health at cold-start without an extra
`switchbot policy validate` call. Payload stays under the 15 KB CI
budget (11.8 KB vs the 11.6 KB baseline).
Agents running the MCP server can now validate, scaffold, and inspect
the migration status of policy.yaml without invoking the CLI subcommands
out-of-band. Each tool mirrors the shape of its CLI counterpart:

- policy_validate (read) — returns {present, valid, errors[]} with
  per-error line/col; surfaces YAML parse failures cleanly.
- policy_new (action) — writes the bundled starter template; guarded
  with a force flag and structured error on accidental overwrite.
- policy_migrate (read) — reports the file's `version` vs the CLI's
  supported set so agents learn when a schema upgrade is pending.

Tool count bumps 11 → 14; strict-input + schema-completeness coverage
extended accordingly.
…ve/rental

Four annotated starter policies that validate against the v0.1 schema.
Each one documents *why* its shape fits a use case (defaults-only,
shared household, solo power-user, short-term rental) rather than
restating field semantics. Destructive actions stay under the
confirmation gate in every file — the schema enforces this, and the
README calls it out explicitly.

Cross-linked from top-level README Policy section and from
docs/policy-reference.md See-also block.
Promotes v0.2.draft.json to an active schema. The validator now picks
per file based on the declared `version`:

- declared "0.1" → v0.1 schema (loose automation.rules)
- declared "0.2" → v0.2 schema (typed rule/trigger/condition/action)
- declared missing → falls back to v0.1 so the "required: version"
  gate still fires with its existing hint
- declared anything else (e.g. "0.9") → short-circuits with a new
  `unsupported-version` error that lists SUPPORTED_POLICY_SCHEMA_VERSIONS

`CURRENT_POLICY_SCHEMA_VERSION` stays at "0.1" — `policy new` keeps
writing 0.1 and users opt into 0.2 via `policy migrate` (C2).

Also fixes a v0.2 schema bug: `condition.additionalProperties: false`
at the parent level would reject every key because the keys actually
live inside the `oneOf` branches.

Baseline tests stay green (1339 → 1345 with 6 new v0.2 cases).
`switchbot policy migrate` now actually upgrades in place. Comments,
anchors, aliases, and block order survive because we mutate the parsed
`yaml.Document` rather than rewriting from `toJSON()`.

Safety: before touching the file we re-serialize, re-parse, and run
the target-version validator. If the migrated document fails schema
validation (e.g. a v0.1 `automation.rules[]` entry that does not fit
the v0.2 rule shape), the command exits 7 `migration-precheck-failed`
and leaves the file untouched with the offending errors.

Flags:
- `--to <version>` lets users pin a target (default: latest supported)
- `--dry-run` previews changes and reports bytes without writing

The MCP `policy_migrate` tool mirrors the CLI: dryRun arg, status
values include migrated / dry-run / precheck-failed, and structured
errors are returned on precheck failure so agents can surface them.

New module:
- `src/policy/migrate.ts` exports `planMigration()` as a pure helper
  (round-trips through source for the clone so callers' Documents
  stay untouched), keyed off a `MIGRATION_CHAIN` of step plans so
  future versions (v0.2 -> v0.3) drop in without reshaping the caller.
Adds a post-ajv validation pass that walks `automation.rules[].then[]`
and flags any command string whose verb matches the destructive
blocklist (`lock`, `unlock`, `deleteWebhook`, `deleteScene`,
`factoryReset`). Produces a `rule-destructive-action` error with
line/col of the offending command scalar and a hint directing the
user to the interactive CLI so the confirmation gate fires.

This invariant can't live in JSON Schema because `command` is a
free-form string — the schema would have to parse each verb slot to
compare it against a blocklist. Keeping the check in `src/rules/
destructive.ts` (new shared module) lets the runtime executor reuse
the same parser in D1, so the guard can't be bypassed by editing YAML
in a way the validator misses but the engine accepts.

Supports both `devices command <id> <verb>` and the aliased forms
`webhooks delete`, `scenes delete` (which resolve to `deleteWebhook`
and `deleteScene` respectively).

Baseline 1351 → 1376 tests (18 for the parser, 7 for the validator
post-hook across destructive-verb matrix).
- doctor test for v0.2 schemaVersion reporting
- mcp policy_validate/migrate: help + descriptions reflect v0.1/v0.2
- README + policy-reference: schema versions subsection
- phase4-rules-schema banner: draft -> active (v0.2)
Introduces the Phase 4 rules engine PoC:

- src/rules/types.ts: TS mirror of v0.2 schema shapes
- src/rules/destructive.ts: already shipped in C3
- src/rules/quiet-hours.ts: shared HH:MM window math
- src/rules/matcher.ts: MQTT classifier + time_between condition
- src/rules/throttle.ts: in-memory gate, keyed by (rule, deviceId)
- src/rules/action.ts: alias resolution, dry_run, destructive refusal
- src/rules/engine.ts: subscribes to MQTT, serialises dispatch to
  avoid throttle races, exposes lintRules() for `rules lint`

Bumps AUDIT_VERSION to 2 and extends AuditEntry.kind with
rule-fire / rule-fire-dry / rule-throttled / rule-webhook-rejected.
Readers stay backwards compatible with v1 entries.

Cron + webhook triggers are recognised but not wired — lint surfaces
them as `trigger-unsupported` so users know the feature is pending
(E1/E2 fill in the runtime). device_state conditions short-circuit
to unsupported with a descriptive audit line (E3 follow-up).

Adds 62 rule-engine tests (quiet-hours/matcher/throttle/action/
engine) on top of the existing 20 destructive-parser tests.
Register `switchbot rules` command group wrapping the engine shipped in
D1:

- `rules lint [path]` — static-check automation.rules (exit 0/1, no MQTT
  or API calls). Reports schema version, per-rule status (ok / error /
  unsupported / disabled), and issue list.
- `rules list [path]` — human or --json summary of each rule's trigger,
  condition count, action count, throttle, and dry_run flag.
- `rules run [path]` — long-running process: fetch MQTT credentials,
  connect, start RulesEngine, wait for SIGINT/SIGTERM. Supports
  --dry-run (force global dry-run), --token/--secret (credential
  override), --max-firings (stop after N fires for tests).

All error paths route through `exitWithError()` (contract enforced by
error-envelope audit test). Exit codes: 0 valid / 1 lint or runtime
failure / 2 file-not-found or missing credentials / 3 YAML parse
error / 4 schema validation failure.

Tests (9): CLI surface for lint (valid, unsupported, missing file,
JSON envelope), list (human, empty, JSON), and run (early exit when
automation.enabled is false). Destructive-command CLI coverage
intentionally lives in tests/rules/engine.test.ts since v0.2 validator
blocks destructive verbs upstream.
A fifth policy example (`examples/policies/automation.yaml`) showing a
working v0.2 `automation.rules[]` block with one rule per trigger
source (mqtt active, cron preview). All three rules set `dry_run:
true` so running the engine against this file never touches real
devices.

Documentation sync:
- `docs/design/phase4-rules.md` — banner `design-only` →
  `PoC shipped (v0.2, MQTT-only preview)`.
- `docs/policy-reference.md` — `automation` section rewritten to
  cover the v0.2 shape: every sub-field, trigger source table
  (active vs preview), condition keyword table, destructive-verb
  guard, and dispatch-queue semantics.
- `README.md` — new `## Rules engine (preview)` subsection under
  Policy, with the migrate → lint → list → run walkthrough.
- `examples/policies/README.md` — index bumped from 4 files to 5 and
  now tags each row with its schema version.

Compact budgets stay well under 15KB (agent-bootstrap --compact:
11823, schema export --compact --used: 10078).
Introduce a cross-platform CredentialStore abstraction with four
backends (macOS security(1), Linux secret-tool, Windows CredRead/
CredWrite via PowerShell + Win32 P/Invoke, and a file fallback that
preserves the existing ~/.switchbot/config.json shape).

selectCredentialStore() picks the OS-native backend first and
degrades to the file backend whenever a native backend is absent or
its CLI isn't on PATH. All backends share the service identifier
com.openclaw.switchbot and the <profile>:<field> account shape so
credentials are portable across machines.

No native bindings: every OS backend shells out to an OS-provided
binary, which keeps npm install toolchain-free. Windows passes the
credential value through an env var rather than argv to keep it out
of process listings.
Add a credential priming cache that runs once per command via a
Commander preAction hook. The hook probes the active credential store
(keychain on supported platforms, file elsewhere) and caches the
result in-process. loadConfig() and tryLoadConfig() consult the cache
after env vars but before the file path.

Precedence:
  1. SWITCHBOT_TOKEN + SWITCHBOT_SECRET env vars
  2. keychain-primed creds (unless --config <path> overrides)
  3. ~/.switchbot/[profiles/<name>|config].json
  4. exit 1 with hint

Failures from the keychain probe are swallowed so the file fallback
is always authoritative; users without a keychain see no behavior
change. No migration of existing file-based creds — that's the
explicit job of the 'auth keychain migrate' subcommand landing in F3.
…grate)

Surface the credential store abstraction added in F1/F2 via user-facing
commands so users can introspect, write, delete, and migrate credentials
to the OS keychain without editing ~/.switchbot/config.json directly.

- auth keychain describe: print active backend + writable flag
- auth keychain get: masked-only summary (length + prefix/suffix); exits
  1 when the active profile has no stored credentials
- auth keychain set: reads token/secret from --stdin-file JSON for
  non-TTY environments; otherwise prompts with echo-off; refuses to
  write to a non-writable backend
- auth keychain delete: requires typing DELETE to confirm unless --yes
- auth keychain migrate: copies ~/.switchbot/config.json (or the
  profile-specific file when --profile is set) into the keychain; the
  source file is kept by default and removed only when --delete-file
  is passed

All subcommands honor the active --profile <name> so multi-account
setups keep keychain entries partitioned. No credential material is
ever printed in plain text.
Surface the credential backend selection from F1/F2 in the two
structured-diagnostic surfaces agents rely on.

doctor:
- checkCredentials() now probes selectCredentialStore() and reports
  { source, backend, backendLabel, writable, profile, message } as an
  object detail. When the active profile has file-based credentials
  but the native keychain backend is writable, the check downgrades
  to 'warn' with a hint to run 'switchbot auth keychain migrate'.
- the human renderer prefers detail.message when present, so the
  existing column layout stays compact.

agent-bootstrap:
- payload gains credentialsBackend: { name, label, writable }.
- quickReference.auth lists the keychain subcommands so agents
  discover them on first orientation.

Webhook bearer token migration referenced in the plan is deferred
until the webhook trigger runtime (E2) lands — doing it now would
require file-backed bootstrap that we'd then rip out.
Pure TypeScript library — no CLI entry — that prepares the ground
for the external Phase 3B "openclaw plugins install clawhub:switchbot"
command. Packaging the orchestration in-repo lets the CLI expose a
stable API the external installer can import and call, without
prematurely deciding the external command's UX.

preflight.ts · runPreflight()
  - node version floor (configurable minimum, default 18)
  - policy.yaml status: absent/valid/invalid (absent is ok — the
    installer will scaffold one; invalid downgrades to warn)
  - credential backend probe (selectCredentialStore + describe)
  - home-directory writability probe using ~/.switchbot
  - returns { checks[], ok } — ok is true unless any check is fail

steps.ts · runInstall()
  - each step is { name, execute, undo }
  - runner executes in order; on first failure, walks completed steps
    in reverse and invokes undo on each
  - failures during undo are reported but do NOT abort further
    rollback — the goal is minimum residue, not strict LIFO atomicity
  - stopAfter option lets tests exercise partial-state scenarios
  - returns InstallReport with per-step status (succeeded / failed /
    rolled-back / rollback-failed / skipped) and the failing step name

Not wired into any CLI entry. The external installer (Phase 3B) will
import { runPreflight, runInstall } and compose steps like "npm
install -g", "write credentials to keychain", "symlink skill dir".
README:
- Credentials section adds the keychain lookup step to the priority
  chain (env > keychain > file) and a new "OS keychain (preview)"
  subsection documenting auth keychain describe/set/migrate/get and
  the per-platform backends.
- Bump test count to 1543 (post F-track).

docs/design/phase3-install.md:
- Banner from "design-only" to "partially shipped (3A)" — the
  keychain abstraction, auth subcommands, doctor/bootstrap
  integration, and install orchestrator library all landed in
  v2.8.x. Phase 3B (the external openclaw plugins install command
  and ClawHub registry) remain out of scope for this repo.
Wire up the cron trigger path for the rules engine (E1). Cron rules were
recognised in D1 but flagged as unsupported; this commit makes them live:

- Add croner dependency (pure JS, no native binding) for cron pattern
  parsing and next-run calculation.
- New CronScheduler class (src/rules/cron-scheduler.ts) manages a
  per-rule timer, fires synthetic EngineEvents with source='cron', and
  routes every tick back through the engine's enqueue pipeline so
  condition / throttle / action logic is shared with MQTT rules.
- Engine.start() stands up the scheduler only when at least one cron
  rule exists; stop() clears all timers.
- Lint no longer marks cron as 'trigger-unsupported'; invalid cron
  expressions now surface as a hard 'invalid-cron' error.
- Engine exposes ingestCronForTest() and getCronSchedule() helpers so
  tests can drive the cron path without fake timers where that fit.

Tests: +11 CronScheduler unit tests (register / unregister / duplicate /
invalid expression / fake-timer fire / stop / late-join arm) and +5
engine-level cron tests (dry fire + audit, throttle, invalid schedule,
schedule read-out). Suite moves 1543 -> 1561.
Wire up webhook triggers (E2). Previously the engine recognised
`source: webhook` but flagged it as unsupported; this commit makes
webhook rules bind a local HTTP listener and dispatch through the same
condition/throttle/action pipeline as MQTT and cron.

- New WebhookTokenStore (src/rules/webhook-token.ts): generates a 64-char
  hex bearer on first use, persists it to ~/.switchbot/webhook-token
  with chmod 0600, supports env override via SWITCHBOT_WEBHOOK_TOKEN.
- New WebhookListener (src/rules/webhook-listener.ts): binds 127.0.0.1
  by default on port 18790 (override via --webhook-host/--webhook-port;
  port 0 picks an ephemeral one for tests). Enforces Bearer-token auth
  with timingSafeEqual, caps request bodies at 16 KiB, replies 202 to
  accepted calls and emits audit entries for 401/404 rejections.
- Engine stands up the listener only when at least one webhook rule is
  active; stop() tears it down. Rules with invalid paths now fail lint
  with 'invalid-webhook-path'; only unknown trigger sources remain
  flagged as 'trigger-unsupported'.
- New subcommands: `switchbot rules webhook-rotate-token` rotates the
  on-disk token; `switchbot rules webhook-show-token` prints the
  current token (creating it if absent).

Tests: +8 webhook-token unit tests (create / env override / rotate /
trim / empty env), +9 webhook-listener tests (valid dispatch + audit,
401 missing/wrong bearer, 404 unknown path, 405 non-POST, query +
trailing slash normalisation, duplicate-path guard, constant-time
compare with mismatched length), +2 engine tests (dry-fire via
ingestWebhookForTest, refuse-start without bearer). Suite moves
1561 -> 1580.
chenliuyun added 23 commits April 23, 2026 08:21
Upgrades `evaluateConditions` from "recognised but unsupported" to a
first-class runtime check. `device_state` conditions now resolve their
device (alias → id), fetch live status via `fetchDeviceStatus`, and
compare the requested field with the given operator.

Key points:

- `evaluateConditions` goes async and accepts a `DeviceStatusFetcher`
  via its context. Without a fetcher the condition still reports as
  unsupported (lint / list paths keep working without a live API).
- The engine's `dispatchRule` builds a per-run
  `Map<deviceId, Promise<Status>>` and threads it into the matcher, so
  two rules (or two conditions within one rule) that query the same
  device in the same pipeline run share exactly one API round trip.
  The cache is torn down between pipeline runs — stale status would be
  worse than one extra fetch.
- Ordering operators (`<`, `>`, `<=`, `>=`) coerce strings to numbers so
  shadow payloads like `"22.5"` compare naturally. Equality operators
  stay value-strict (with number coercion) to avoid surprise matches.
- Fetch errors and unresolvable aliases fall into the normal
  "conditions-failed" audit lane with a descriptive reason, rather than
  the unsupported lane — device_state *is* supported; the fetch just
  didn't succeed this time.

Test deltas: matcher.test.ts gains 6 device_state cases (alias resolve,
mismatch, numeric ordering, fetch error, other comparison ops);
engine.test.ts gains 5 end-to-end cases including per-tick dedup and
cross-pipeline cache isolation. Full suite: 1590 passing.
Adds a `switchbot rules reload` subcommand that signals the running
engine to re-read policy.yaml without restarting — needed so operators
can iterate on rules without tearing down the MQTT/webhook listeners.

- `src/rules/pid-file.ts`: write/read/clear `~/.switchbot/rules.pid`
  (0600) + sentinel file helpers + `isPidAlive` / `sighupSupported`.
  Pid clear only deletes when the persisted pid matches ours, so a
  racing supervisor isn't clobbered.
- `RulesEngine.reload(nextAutomation, nextAliases)` diffs rules by
  name: cron entries are re-registered only when schedule changes,
  the webhook listener is kept alive across reloads via
  `WebhookListener.updateRules()`, and throttle state is preserved
  for surviving rule names via `ThrottleGate.retainOnly()`. Refuses
  to reload when the new policy is disabled or fails lint — the old
  ruleset stays live.
- `rules run` writes the pid file on startup, registers a SIGHUP
  handler on Unix and polls a reload sentinel file on Windows, and
  cleans both up on exit.
- `rules reload`: reads the pid, kills(SIGHUP) where supported or
  writes the sentinel on Windows. Exits 2 (usage) when no engine
  is running.

Tests: 9 pid-file tests, 6 engine.reload tests (enabled=false /
lint-fail / atomic swap / throttle preservation / warn-on-new-webhook
/ pre-start refusal), 2 command tests. Full suite 1607/1607 green.
Operators need a way to review what the engine is doing without
tailing the raw audit.log and filtering by hand. Two new subcommands:

- `switchbot rules tail [--follow] [--rule <name>] [--since <dur>]`:
  streams `rule-*` entries from ~/.switchbot/audit.log in a compact
  human line or one-JSON-per-line with `--json`. Without `--follow`,
  prints existing matches and exits; with `-f`, polls the file size
  every 500 ms and appends new matching lines. Truncation / rotation
  is detected and the reader resets from the top.
- `switchbot rules replay [--rule <name>] [--since <dur>]`:
  aggregates rule-* entries per rule.name into fires / dries /
  throttled / errors / errorRate / first+last timestamps. `--json`
  returns the full report; the default tabular view is sorted by
  (fires + dries) desc. Webhook rejections without a rule name are
  bucketed into `webhookRejectedCount`.

Extracted the pure filter / aggregate logic into
`src/rules/audit-query.ts` so it's exercised directly by 10 unit
tests (per-kind filters, custom kinds set, since cutoff, rule name
match, aggregation counters, triggerSource="mixed" when a name spans
sources, webhook-rejected bucketing). 9 CLI tests cover the happy
paths, human vs JSON output, --rule filter, and invalid --since.

Full suite: 1624/1624 green.
…/reload

The rules engine is no longer MQTT-only. Covers the three shipped
triggers, `device_state` condition, `rules reload`, and the
`tail` / `replay` views in the README quickstart; flips the design
doc banner from "PoC shipped (MQTT-only preview)" to "Shipped (v0.2)".
Also syncs the README test count (1315/1543 → 1624) against the
suite as it stands after E1–E5.
Follow-up to the UX-principles audit of the shipped v0.2 policy +
rules-engine surface. Closes five gaps without code churn:

- README Quick start now walks the full 7-step path (install →
  credentials → agent-bootstrap → policy new → events mqtt-tail →
  command --audit-log → doctor) so a first-time reader can self-
  verify the install end-to-end in one session.
- README top matter points to the conversational skill repo (tracked
  as Phase 3B) so agents / users don't get stranded looking for it.
- "Rules engine (preview)" renamed to "Rules engine (v0.2, opt-in)";
  Roadmap gains a line stating v0.2 becomes the default schema in
  v3.0, matching how policy-reference already labels it.
- docs/agent-guide.md documents the catalog-schema doctor check as
  the CLI ↔ agent-bootstrap drift sentinel agents should poll each
  session.
- New examples/quickstart/ with config.env.example, a v0.2
  policy.yaml.example (one alias + quiet hours + one dry-run rule),
  a mqtt-tail.service.example systemd unit, and a step-by-step
  README covering the same 7-step path with verification hooks.

No source code touched; 1624/1624 tests pass unchanged.
Establishes docs/design/roadmap.md as the single source of truth for
Phase 1-4 numbering across this repo and the sibling skill repo. The
skill uses an orthogonal autonomyLevel L1/L2/L3 dimension that points
back here via tracksCliPhase.

- docs/design/roadmap.md — authoritative phase table + autonomy mapping
  + reserved tracks β/γ/δ/ε for post-v2.9.0 work
- docs/ux-principles.md — 10 load-bearing principles the CLI, MCP,
  rules engine, and skill all obey
- docs/phase-1-manual-orchestration.md — frames Phase 1 as the complete
  manual-orchestration contract, not a transitional state
- README.md — skill pointer now links to openclaw-switchbot-skill;
  Roadmap section points at docs/design/roadmap.md; reserved tracks
  β/γ/δ/ε listed with the same labels the roadmap doc uses
Feature release bundling the Phase 2 policy-v0.2 schema, the Phase 4
rules engine (MQTT + cron + webhook triggers, time_between +
device_state conditions, throttle, dry_run, SIGHUP hot reload), Phase
3A keychain support across macOS/Windows/Linux/file fallback, and the
in-repo install orchestrator library.

See CHANGELOG.md for the full breakdown.

- package.json / package-lock.json: 2.8.0 -> 2.9.0
- CHANGELOG.md: new [2.9.0] section covering Added/Changed blocks +
  skill-side impact (skill bumps to 0.3.0 with authority.cli
  >=2.9.0 <3.0.0)
…efault true — v2.14.0

- Add MCP policy_diff tool: read-only structural diff between two policy files
- Add MCP plan_run, audit_query, audit_stats tools (MCP count 17 → 21)
- Add src/policy/diff.ts: deep structural differ with JSOn-patch-style output
- Set rule dry_run default to true in policy schema v0.2
- Expand policy command with diff subcommand
- Update phase-4 design doc to reflect shipped surfaces
…seline — v2.15.0

- Set CURRENT_POLICY_SCHEMA_VERSION to v0.2 (policy new now scaffolds v0.2 by default)
- Add MCP policy_diff contract test asserting CLI/MCP output parity
- Add markdownlint-cli dev dependency + lint:md scripts + .markdownlint.jsonc config
- Normalize markdown table/fence styles across README, roadmap, agent guide
- Restore README Output modes anchor and fix broken table-of-contents links
- Update roadmap backlog with ordered execution queue
BREAKING CHANGES:

1. Remove `destructive: boolean` from all JSON output surfaces
   (schema export, devices describe, agent-bootstrap, catalog_search MCP,
   explain). Use `safetyTier === 'destructive'` instead. Field was
   @deprecated since v2.7 and marked for v3.0 removal throughout.

2. Remove CommandSpec.destructive and CommandSpec.destructiveReason from
   catalog interface. Custom catalog overlays must use safetyTier:
   "destructive" instead.

3. Drop policy schema v0.1 support. v0.1 files now fail validation with
   a clear migration hint (run policy migrate with CLI ≤2.15 first).
   Remove src/policy/schema/v0.1.json.

4. deriveSafetyTier() no longer reads spec.destructive for legacy compat.

Version: 3.0.0
- Remove all assertions on deprecated destructive:boolean field; check safetyTier instead
- Rewrite v0.1 policy tests to expect unsupported-version errors (v0.1 removed in v3.0)
- Fix catalog.test.ts legacy deriveSafetyTier/getCommandSafetyReason tests
- Isolate doctor tests from real policy.yaml via SWITCHBOT_POLICY_PATH in beforeEach
- events: replace connection:close with req.resume() to fix 413 test
  on Windows (TCP RST raced the HTTP response)
- status-sync start: show separate stdout/stderr log paths
- status-sync manager: validate state file is a plain object; check
  process is dead after kill; pass openclawToken via env rather than
  CLI arg; add stderrLog path to spawn options
- status-sync manager tests: align with token-in-env change
- status-sync smoke tests: add smoke test suite for status-sync CLI
- .gitignore: exclude switchbot-skill/ (separate git repo)
- auth: reject JSON arrays passed as import source
@chenliuyun chenliuyun merged commit d5c04a6 into main Apr 24, 2026
9 checks passed
@chenliuyun chenliuyun deleted the feat/v3.0 branch April 24, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant