Skip to content

feat(home-assistant): add WebSocket-only registry and automation actions - #227

Merged
l1shen merged 4 commits into
oomol-lab:mainfrom
reopenpilot:feat-home-assistant-websocket
Jul 30, 2026
Merged

feat(home-assistant): add WebSocket-only registry and automation actions#227
l1shen merged 4 commits into
oomol-lab:mainfrom
reopenpilot:feat-home-assistant-websocket

Conversation

@reopenpilot

Copy link
Copy Markdown
Contributor

Summary

  • add openGuardedWebSocket in src/core/guarded-websocket.ts, so provider egress that is not a fetch is held to the same SSRF policy
  • extract the per-hop check behind createGuardedFetch as assertGuardedEgressUrl, and make both transports use it
  • add five Home Assistant actions that the REST API cannot serve: registries, related-item search, device automation capabilities, script execution, and config validation
  • document the WebSocket egress rule in AGENTS.md

Problem

The Home Assistant provider exposes entity states but not the structure behind them. /api/states returns light.living_room and nothing that says which device, area, or floor it belongs to, so "turn off the lights downstairs" is a string-matching guess rather than a query.

Those registries are only reachable over Home Assistant's WebSocket API. So are related-item search, per-device automation capabilities, multi-step script execution, and trigger/condition/action validation. None of them have a REST equivalent.

Egress guard

This is the part worth review attention.

Every non-fetch egress in this repository so far targets a host fixed in code: the IMAP/SMTP runtime resolves its hosts from a per-provider allowlist (netease_mail) or a literal (qq_mail), and the Pushover realtime socket uses a constant wss:// URL. None of them needed a guard, which is why src/core did not have one.

A self-hosted Home Assistant is the first case where a non-fetch transport connects to a credential-supplied host, which is exactly the case AGENTS.md says the DNS resolved-address check must cover.

Rather than add a second, narrower check for WebSockets, this extracts the hop check that createGuardedFetch already performs — assertPublicHttpUrl on the literal, then resolved-address validation — into assertGuardedEgressUrl, and has both transports call it. ws/wss targets are validated as their http/https equivalents and then connected over the normalized ws form. allowPrivateNetwork and skipDnsValidation behave as they do for fetch.

Two limits are inherent to the transport and match what the fetch guard can offer: the constructor re-resolves the hostname itself, so low-TTL rebinding remains possible, and WebSocket handshakes have no redirects to revalidate. Both are stated in the JSDoc.

Runtime support

Node 22+ and workerd both expose a client WebSocket constructor, so this is one code path rather than a Node-only capability. Verified on workerd with wrangler dev and the repository's compatibility settings rather than from documentation:

{ "hasConstructor": "function", "constructed": true, "readyState": 0,
  "hasAddEventListener": "function", "hasSend": "function",
  "hasClose": "function", "hasNodeDns": "function" }

node:dns being present under nodejs_compat means the resolved-address check applies on Cloudflare too, consistent with the note added in #135.

The provider is therefore not marked nodeOnly; doing so would also drop the eight REST actions that work on Cloudflare today. Home Assistant authenticates in-band, with the token in a message rather than a header, so nothing here needs a constructor that can set request headers.

Reachability is a separate matter from the guard: Workers cannot route to private addresses, so a LAN instance remains a Node/Docker/Fly deployment concern regardless of OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. AGENTS.md now says so.

Actions

Action WebSocket commands
get_registries config/{entity,device,area,floor,label}_registry/list
search_related search/related
list_device_automations device_automation/{trigger,condition,action}/list
execute_script execute_script
validate_config validate_config

All five are one-shot commands, so they map onto the existing request/response executor model unchanged.

Subscription commands are deliberately excluded. The action model has no streaming, and every Home Assistant subscription that carries an initial snapshot already has a one-shot equivalent — render_template is a subscription whose first message equals POST /api/template, and subscribe_entities starts from what /api/states already returns. What remains is increment-only (subscribe_events, subscribe_trigger, logbook/event_stream), which a bounded listen window would serve badly. That needs a webhook design, not an action.

get_registries sends all five registry commands over one connection. The handshake costs two round trips before any command can be sent, so batching matters more here than it would over HTTP.

Reference: https://developers.home-assistant.io/docs/api/websocket

Verification

  • npm run fix-check, npm test (574 passing, 20 new)
  • workerd probe above, via wrangler dev
  • end-to-end against a real Home Assistant instance: get_registries returned 160 devices and 7 areas over a single connection with ids 1–5; search_related resolved a device to its area, config entry, integration, and three entities; auth failure mapped to 401; a loopback base URL stayed blocked with the private-network flag enabled

Provider egress that is not a fetch had no shared guard: the existing
non-HTTP transports (imap-smtp, the Pushover realtime socket) all target
hosts fixed in code, so nothing forced the policy for a credential-supplied
host. A self-hosted provider connecting a WebSocket to a user-configured
instance is the first case that needs one.

Extract the per-hop check behind createGuardedFetch as assertGuardedEgressUrl
so both transports enforce one policy instead of two drifting ones, and add
openGuardedWebSocket on top of it. ws/wss targets are validated as their
http/https equivalents, then connected over the normalized ws form.

Node and workerd both expose a client WebSocket constructor, so this is a
single code path rather than a Node-only capability.
Record two rules the WebSocket guard depends on: non-fetch transports reuse
assertGuardedEgressUrl rather than growing their own host check, and the
private-network opt-in governs what the guard permits, not what a deployment
can actually route to.
The REST API serves entity states but not the registries behind them, so an
agent could read light.living_room without learning which device, area, or
floor it belongs to. Those registries, related-item search, per-device
automation capabilities, script execution, and config validation are only
reachable over the WebSocket API.

Add five actions covering that gap. Each is a one-shot command, so it maps
onto the existing request/response executor model without change;
subscription commands are deliberately left out because the action model has
no streaming and every Home Assistant subscription that carries an initial
snapshot already has a one-shot equivalent.

get_registries sends all five registry commands over a single connection.
The handshake costs two round trips before any command can be sent, so
batching matters more here than it would over HTTP.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fdc42c0c-2237-4126-a470-de1036074483

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features
    • Added SSRF-protected WebSocket connectivity with guarded URL validation, DNS checks, scheme mapping, timeouts, and cancellation.
    • Added Home Assistant WebSocket actions for registries, related-item search, device automations, script execution, and configuration validation, including batched command execution.
  • Documentation
    • Updated egress guidance to require guarded WebSocket usage and clarified private-network opt-in vs actual routability.
  • Tests
    • Added comprehensive tests covering URL guarding, DNS outcomes, failure modes, normalization, timeouts, and abort behavior.
  • Refactor
    • Centralized shared guarded egress URL policy logic for HTTP and WebSocket flows.

Walkthrough

The PR centralizes SSRF egress validation and adds openGuardedWebSocket with URL normalization, DNS checks, timeout, abort, and lifecycle handling. Home Assistant gains five WebSocket-only actions with schemas, handler typing, executor wiring, authenticated command batching, message buffering, response decoding, and error mapping. Tests cover URL protection, scheme mapping, runtime failures, connection failures, timeouts, aborts, and socket lifecycle behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Executor
  participant HomeAssistantWebSocketHandlers
  participant runHomeAssistantCommands
  participant HomeAssistantWebSocket
  Executor->>HomeAssistantWebSocketHandlers: invoke WebSocket action
  HomeAssistantWebSocketHandlers->>runHomeAssistantCommands: build commands
  runHomeAssistantCommands->>HomeAssistantWebSocket: open guarded connection
  HomeAssistantWebSocket-->>runHomeAssistantCommands: auth_required
  runHomeAssistantCommands->>HomeAssistantWebSocket: send API key and commands
  HomeAssistantWebSocket-->>runHomeAssistantCommands: command results
  runHomeAssistantCommands-->>HomeAssistantWebSocketHandlers: ordered results
  HomeAssistantWebSocketHandlers-->>Executor: normalized action response
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows the required format and accurately summarizes the WebSocket-only Home Assistant actions.
Description check ✅ Passed The description is clearly related to the WebSocket guard changes and new Home Assistant actions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

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

❤️ Share

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

@reopenpilot
reopenpilot force-pushed the feat-home-assistant-websocket branch from 37a3b50 to b442c9b Compare July 30, 2026 03:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@src/providers/home_assistant/runtime-ws.ts`:
- Around line 117-129: Use one shared deadline for the entire Home Assistant
WebSocket request in the surrounding request flow: derive the remaining budget
before connecting, pass that remaining duration as connectTimeoutMs, and start
the inbox timer with the same deadline rather than resetting the full timeout
after socket creation. Preserve abort handling and ensure the timer still
reports the existing 504 timeout error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 00067566-3bda-4fde-a3b5-0f62d69a1fd8

📥 Commits

Reviewing files that changed from the base of the PR and between 14eb198 and 37a3b50.

📒 Files selected for processing (8)
  • AGENTS.md
  • src/core/guarded-fetch.ts
  • src/core/guarded-websocket.test.ts
  • src/core/guarded-websocket.ts
  • src/providers/home_assistant/actions.ts
  • src/providers/home_assistant/executors.ts
  • src/providers/home_assistant/runtime-ws.ts
  • src/providers/home_assistant/runtime.ts

Comment thread src/providers/home_assistant/runtime-ws.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@src/providers/home_assistant/runtime-ws.ts`:
- Around line 12-16: Use one shared session deadline for the entire Home
Assistant WebSocket flow: derive the connection timeout from a deadline
established before the handshake, then pass only the remaining time to
authentication and command execution instead of starting a fresh
homeAssistantWebSocketTimeoutMs timer after the socket opens. Update the
relevant connection and command-timeout logic while preserving the documented
total budget across handshake, authentication, and all batch commands.
- Around line 130-134: Update the abort handling in the request flow around
onAbort to recheck context.signal?.aborted immediately after registering the
abort listener, invoking onAbort when it is already aborted. Preserve the
existing once-only listener and inbox failure behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ac342af1-a769-4c58-948c-b2a16ec02c11

📥 Commits

Reviewing files that changed from the base of the PR and between 37a3b50 and b442c9b.

📒 Files selected for processing (8)
  • AGENTS.md
  • src/core/guarded-fetch.ts
  • src/core/guarded-websocket.test.ts
  • src/core/guarded-websocket.ts
  • src/providers/home_assistant/actions.ts
  • src/providers/home_assistant/executors.ts
  • src/providers/home_assistant/runtime-ws.ts
  • src/providers/home_assistant/runtime.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • AGENTS.md

Comment thread src/providers/home_assistant/runtime-ws.ts
Comment thread src/providers/home_assistant/runtime-ws.ts
The handshake and the command phase each started a full timeout, so a slow
instance could spend the budget twice and reach roughly sixty seconds against a
documented thirty. Derive both from one deadline.

Also recheck the abort signal after attaching the session listener. It can fire
between the socket opening and the listener attaching, and openGuardedWebSocket
already guards the same race for its own handshake listeners.
@reopenpilot

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@l1shen
l1shen merged commit d0d8e04 into oomol-lab:main Jul 30, 2026
4 checks passed
l1shen pushed a commit that referenced this pull request Jul 31, 2026
… calendars (#239)

## Summary

- add automation, script, and scene config CRUD, reached over REST at
`/api/config/<component>/config/<key>`
- add history, logbook, calendar, and error log actions from Home
Assistant's published REST API
- fix `buildHomeAssistantUrl` dropping the instance base path

Fifteen actions in total, all REST. No WebSocket code is touched.

## Problem

Two separate gaps, both in the REST surface.

**The editable config store was missing entirely.** Home Assistant
serves automation, script, and scene configuration over REST, but at
`/api/config/<component>/config/<key>` rather than under the endpoints
listed in the published REST API reference. The provider could therefore
run an automation but never write one. That endpoint family is absent
from the reference page, which is the most likely reason it was never
covered — the eight actions the provider started with map exactly onto
that page and nothing outside it.

**There was no time dimension.** `/api/states` answers "what is the
temperature now"; nothing answered "how did it get there" or "what
tripped that switch overnight". `history`, `logbook`, `calendars`, and
`error_log` are all on the published reference page and were simply
never implemented.

## Config CRUD

`automation`, `script`, and `scene` differ only in three respects: the
path segment, the input field carrying the key, and whether Home
Assistant validates that key as a slug (`cv.slug` for script,
`cv.string` for automation and scene). One descriptor captures those
differences and parameterises shared read/write/delete handlers, rather
than nine near-identical copies.

The key travels in the path and Home Assistant injects it into the
stored entry itself, so the request body is the bare config object.
`POST` is create-or-update: writing to an unused key creates the entry,
which is how the UI creates automations.

Both endpoints require an admin token, and they only see entries Home
Assistant manages itself — an automation defined in a separate YAML file
or a package is not visible and returns not found. The action
descriptions say so, because a non-admin token fails at the provider
rather than at input validation.

This pairs with `validate_config` from #227 to close the loop an agent
needs for automation work: discover what a device can do, validate a
candidate config, write it, then read the logbook to confirm it fired.
`followUpActions` wires that sequence into the catalog so it is
discoverable rather than something the caller has to know.

## History and diagnostics

`get_history` exposes `minimalResponse` and `noAttributes` because a
full-attribute history across several entities is large enough to matter
for an agent's context budget. Against a real instance, one entity over
24 hours returned 7219 points; the compact form drops each row from the
full state object to `{"state", "last_changed"}`.

Home Assistant reads `minimal_response`, `no_attributes`, and
`skip_initial_state` by presence rather than by value, so `false` omits
the parameter instead of sending `0`. `significant_changes_only` is read
by value and defaults to true, so it is encoded normally.

`get_error_log` needs one caveat: Home Assistant registers that view
only when the instance runs with file logging, so it can report not
found on an otherwise healthy instance. Rather than surface a bare "Not
Found", the handler maps that case to a message explaining why, and the
action description repeats it.

## Base path fix

`normalizeBaseUrl` accepts an instance URL with a path, so a Home
Assistant behind a reverse proxy at `/ha` is a supported configuration.
`buildHomeAssistantUrl` assigned the endpoint path over `url.pathname`,
which dropped that prefix and sent every request to `/api/...` at the
origin instead of `/ha/api/...`.

It now appends to the base path. The WebSocket URL builder added in #227
already did this, so the two transports agree. This is a separate
commit; it predates both PRs, and the fifteen new paths here would have
multiplied it.

## Actions

| Action | Endpoint |
| --- | --- |
| `get_automation_config` / `save_automation_config` /
`delete_automation_config` | `GET`/`POST`/`DELETE
/api/config/automation/config/{id}` |
| `get_script_config` / `save_script_config` / `delete_script_config` |
`GET`/`POST`/`DELETE /api/config/script/config/{key}` |
| `get_scene_config` / `save_scene_config` / `delete_scene_config` |
`GET`/`POST`/`DELETE /api/config/scene/config/{id}` |
| `check_config` | `POST /api/config/core/check_config` |
| `get_history` | `GET /api/history/period/{timestamp}` |
| `get_logbook` | `GET /api/logbook/{timestamp}` |
| `list_calendars` | `GET /api/calendars` |
| `list_calendar_events` | `GET /api/calendars/{entity_id}` |
| `get_error_log` | `GET /api/error_log` |

Reference: https://developers.home-assistant.io/docs/api/rest

## Verification

- `npm run fix-check`, `npm test` (584 passing)
- read paths against a real instance: `check_config` returned valid;
`get_history` returned 7219 points for one entity over 24 hours with the
compact form visibly applied; `get_logbook` returned 4417 entries;
`list_calendars` returned an empty list on an instance with no calendar
integration; `get_error_log` returned the explanatory 404 described
above
- write paths against the same instance, creating and then removing an
`oc_smoke_test` entry in each domain: create returned `ok`, the
read-back showed the key injected into the stored entry, delete returned
`ok`, and each subsequent read reported not found. A non-slug script key
was rejected at the provider before any request. A following scan of
3830 entities found nothing left behind.
@reopenpilot
reopenpilot deleted the feat-home-assistant-websocket branch July 31, 2026 03:14
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.

2 participants