Compare commits

..

160 Commits

Author SHA1 Message Date
Paulus Schoutsen 1c9285cec8 sandbox/docs: add core-HA touch-surface slide; note unregister hook
Add PRESENTATION.md Slide 11 enumerating the five core-HA seams (from
ARCHITECTURE.md §12) and the no-monkey-patching discipline. Update §12 +
the slide to include the inverse `async_unregister_remote_platform` hook
that the crash-recovery plan added alongside `async_register_remote_platform`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:30:13 -04:00
Paulus Schoutsen 6c894f2649 sandbox: STATUS — plan-review-simplification landing note
Phases 1-5 shipped (drift guard, supported_features hook, dead-code
removal, required Channel codec + JsonCodec relocation, one JSON coercer);
Phase 6 (single-writer hot path) evaluated and deferred per the effort gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:34:45 -04:00
Paulus Schoutsen 839a870b5d sandbox: consolidate client JSON coercers into one helper (Phase 5)
Replace the three drifted client coercers — event_mirror._to_json_safe
(raw set order), entity_bridge._serialise (sorted sets via _iter), and
entry_runner._json_safe — with a single hass_client._json.json_safe built
on HA's json_encoder_default (the as_dict/set/enum-aware single source of
truth), plus a str() fallback so best-effort event data still degrades a
single odd field to a string instead of raising in the bus callback.

entry_runner keeps its empty-result dict guard and delegates the coercion;
the capabilities hash stays stable (compared within one process, so raw
list(set) order suffices). Drops the now-unused Iterable/json_bytes/
json_loads imports. Adds tests/test_json.py round-tripping sets, enums,
as_dict objects, datetimes, the str() fallback, and non-str keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:32:35 -04:00
Paulus Schoutsen ceff350731 sandbox: make Channel codec required, move JsonCodec to test helpers (Phase 4)
Channel.__init__ / from_transport no longer default `codec` to JsonCodec —
it is now a required keyword. Every production construction site already
passes ProtobufCodec; a forgotten codec is now a construction-time error
instead of silently speaking JSON at a protobuf peer.

JsonCodec leaves the production channel.py (both mirrors) and moves to each
side's test helpers: tests/components/sandbox/_helpers.py (HA) and
hass_client/testing/_jsoncodec.py (client). Channel-core tests import it
from there; the two channel.py mirrors stay byte-identical. The now-unused
`import json` is dropped from channel.py and the protocol.py docstring is
updated for the required codec + relocated JSON codec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:25:38 -04:00
Paulus Schoutsen c07c917b52 sandbox: delete dead code — raise_not_proxied + no-op block (Phase 3)
Remove the callerless raise_not_proxied helper (zero callers since the
query RPCs landed), its __all__ entry, and the now-unused NoReturn /
HomeAssistantError imports.

Remove the vestigial `if old_state is not None and entity_id not in
self._pending: pass` no-op block in the client entity_bridge — control
fell through it unchanged — and the now-dead old_state assignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:19:37 -04:00
Paulus Schoutsen 303433a4e6 sandbox: collapse domain-proxy supported_features boilerplate (Phase 2)
Add a `_features_flag: type[IntFlag] | None` class-attr hook on
SandboxProxyEntity and a `_coerce_supported_features` helper that wraps
`description.supported_features` in the domain's IntFlag once, used by both
`__init__` and `sandbox_update_description`. Replace the 17 identical
~10-line `__init__` overrides (light, fan, lock, cover, climate,
media_player, notify, …) with a single `_features_flag = <Domain>EntityFeature`
line each, dropping the now-unused TYPE_CHECKING imports.

The four @final mangled-attribute `sandbox_apply_state` overrides
(button, event, notify, scene) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:17:52 -04:00
Paulus Schoutsen 81f92e770c sandbox: drift guard for hand-mirrored wire modules (Phase 1)
Make channel.py byte-identical across both mirrors (it already matched
modulo docstrings/comments/log capitalization; codec_protobuf.py and
messages.py were already identical). Add check_mirror_drift.sh asserting
all three pairs are byte-identical, wired as a regular prek hook that
fires whenever either copy of a mirrored file changes. Document the
edit-both rule in-file next to the guard.

This retires the ad-hoc 'apply to both mirrors' discipline the earlier
review-follow-up plans had to carry by hand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:14:24 -04:00
Paulus Schoutsen 625780b3c7 sandbox: untrack generated test .storage/sandbox; gitignore it
Plan-4 commits accidentally added generated sandbox store fixtures
(tests/testing_config/.storage/sandbox/built-in/*) written by the test
runtime. origin tracks none of testing_config/.storage; the prior
c3f0abc53c 'stop tracking generated test .storage' untracked a sibling
file but added no ignore rule, so it recurred. Remove these and add a
targeted .gitignore so the test store dir can't be re-committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:09:08 -04:00
Paulus Schoutsen e607b5bb01 sandbox: STATUS — plan-review-flow-fidelity landing note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:07:35 -04:00
Paulus Schoutsen d706683f0d sandbox: survive discovery-sourced config flows (Phase 3)
Discovery flows feed the proxy non-JSON objects — a *ServiceInfo dataclass
(IPv4Address fields, sets) as the first-step user_input and a DiscoveryKey in
context — which dict_to_struct couldn't hold, crashing the flow unhandled
(the except only caught channel errors). The router routes discovery-sourced
flows to the sandbox with no source filter, so this was reachable.

Main side: _to_jsonable walks context + first-step payload into Struct-safe
primitives (dataclasses -> field dicts, IPs -> str, sets -> lists) before
dict_to_struct. Broadened the except to abort cleanly on any unmapped payload
that still trips the marshaller.

Sandbox side: _rehydrate_discovery rebuilds the real DiscoveryKey + the
source's BaseServiceInfo (zeroconf/homekit, ssdp, dhcp, usb, hassio, mqtt) so
async_step_<source> receives the type it expects. Unmapped sources (bluetooth)
leave a dict; reconstruction failures degrade to the dict with the proxy's
clean abort as the outer backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:05:15 -04:00
Paulus Schoutsen df9d414142 sandbox: support async_show_menu + stop leaking flow on unsupported types (Phase 2)
Two fixes in the flow proxy:

1. MENU support end-to-end. async_show_menu is common and marshals like a
   form. Added FlowResult.menu_options (ListValue) + sort to the proto, marshal
   it sandbox-side, and re-issue async_show_menu on main. A dict-form menu
   (id -> label) crosses as ordered [id, label] pairs so labels survive; a
   menu selection is forwarded as the sandbox flow's {"next_step_id": <chosen>}
   navigation choice.

2. Leak fix. The unsupported-result-type branch set _terminated=True before
   aborting, which made async_remove skip the flow_abort RPC — leaving the
   sandbox-side flow in progress and wedging retries on already_in_progress if
   it had set a unique_id. The non-terminal result types (external-step,
   progress) no longer set _terminated, so async_remove reaps the sandbox flow.

Regenerated proto gencode for both mirrors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:00:22 -04:00
Paulus Schoutsen 2350fb5db2 sandbox: carry version/minor_version/options on create_entry (Phase 1)
The proxy CREATE_ENTRY path read only data/title/description and called
async_create_entry, which stamps the proxy class defaults VERSION=1 /
MINOR_VERSION=1 / options={}. A sandboxed flow with VERSION>1 lost its
schema version (spurious migration on next setup) and dropped options.

async_create_entry reads self.VERSION/self.MINOR_VERSION off the *instance*
(config_entries.py async_create_entry), so override the proxy instance's
values from the wire result before the call, and plumb options= through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:53:47 -04:00
Paulus Schoutsen e7e67ac27e sandbox: STATUS — plan-review-client-bridge-fixes landing note
Records the 7-phase client-runtime + wire-fidelity batch: per-phase
summaries, the messages.py both-mirrors byte-identical note, the Phase 7
no-narrowing decision, the Phase 3 correctness-fix approach + plan-5
writer-queue handoff, tests added, and final green verification.
2026-06-12 11:45:15 -04:00
Paulus Schoutsen 666a6c29d5 sandbox: flush shutdown reply before closing the channel (Phase 4)
_handle_shutdown set the shutdown event via call_soon so the reply lands
on the wire first, but call_soon only buys one loop turn — if the reply
write suspended (write-lock contention from unload pushes, drain
backpressure on a large restore_state), run()'s finally closed the channel
and cancelled the in-flight reply task, so main lost restore_state.

Add Channel.drain_inflight(timeout): wait for in-flight inbound handler
tasks (the shutdown reply included) to finish before close, without
cancelling anything. run()'s finally now drains before close(), so the
reply completes its write instead of being cancelled mid-flush.

Write-lock check (Phase 4 bullet 2): _write_lock is a plain mutex held
only across one frame write, so the shutdown reply and the unload-driven
_push_unregister writes serialize — no circular wait, no deadlock.

drain_inflight added to both hand-mirrored channel.py copies. Regression
test stalls the reply's drain and asserts main still receives it (verified
it fails with ChannelClosedError when the drain step is removed).
2026-06-12 11:43:41 -04:00
Paulus Schoutsen 7fcbc82d60 sandbox: restore int types across the Struct wire (Phase 7)
protobuf.Struct stores all numbers as double, so _value_to_py returned
entry.data ints, service_data ints, and the store envelope's version/
minor_version as floats — breaking socket()/isinstance(int)/range(...).

_value_to_py now coerces whole-number floats back to int
(int(v) if v.is_integer() else v); genuinely fractional values (0.5) keep
their float type. Reviewed every struct_to_dict / listvalue_to_list
consumer (flow/entry data, service_data, target, entity_query args/result,
store data, state attributes, capabilities, event_data, translations) —
none needs a whole-number float to stay float, so the global coercion is
safe; no field narrowing required.

Applied byte-identically to BOTH hand-mirrored copies:
  homeassistant/components/sandbox/messages.py
  sandbox/hass_client/hass_client/messages.py
(verified identical via diff). The explicit int32 proto fields (version,
minor_version, supported_features on EntrySetup) bypass the Struct and are
unaffected.
2026-06-12 11:40:22 -04:00
Paulus Schoutsen d2b04f3147 sandbox: drop a failed entry so entry_setup can be retried (Phase 6)
A failed async_setup left the rebuilt ConfigEntry in the sandbox's
config_entries (only unload popped it), so main's later retry of the same
entry_id was rejected with 'entry already loaded'. On both failure paths
(async_setup raised / returned False) the entry is now popped before
returning ok=False, so a re-sent entry_setup starts clean.

Sandbox-side half of plan 1's SETUP_RETRY decision: main shipped honest
SETUP_ERROR + manual reload and remains the only retry driver. The
sandbox-side ConfigEntryNotReady timer is deliberately NOT enabled (the
sandbox hass is never async_started).
2026-06-12 11:38:13 -04:00
Paulus Schoutsen a6aa43a8c9 sandbox: release ApprovedDomains approval on entity unregister (Phase 5)
EntityBridge._register added an approval refcount per registered entity
(approved.add(domain)) but nothing ever decremented it, so a platform
domain stayed approved for the process lifetime — the service/event gate
stayed open for domains with zero owning entities.

Track each entity's contributed domain (_approved_domain) and release it
(_release_approval -> approved.remove) on both unregister paths: the
new_state-is-None removal and the Phase 3 removal-while-pending flush.
Symmetric with the per-entity add; the refcount drops to absent when the
last entity of a domain unregisters. EntryRunner's per-entry
approved.remove(entry.domain) on entry_unload already balances the
per-entry add, so it is unchanged.
2026-06-12 11:37:18 -04:00
Paulus Schoutsen 27db0a80f2 sandbox: flush coalesced state + handle removal mid-register (Phase 3)
_on_state_changed dropped any state_changed while an entity sat in
_pending (register RPC in flight), and the register task pushed only the
snapshot captured at task-creation. A fast second update was lost; a
removal in the window left a ghost proxy on main (the entity wasn't in
_registered yet, so the removal was dropped).

After the register RPC resolves, the task now:
- if a removal raced (new _removed_while_pending flag), unregisters the
  entity it just registered, so main keeps no ghost proxy;
- otherwise re-reads hass.states and pushes a state_changed when the live
  state differs from the registered snapshot, flushing the coalesced gap.

This is the correctness fix only. Plan 5 (simplification) builds a
single-writer queue on top of the entity push path; when it lands it
should subsume this flush into the queue's ordering guarantees (noted in
code).
2026-06-12 11:36:00 -04:00
Paulus Schoutsen 68cf43805e sandbox: register call handlers before sending Ready (Phase 2)
SandboxRuntime.run pushed Ready (manager flips to running, router sends
entry_setup), awaited the warm-load store_load, and only then registered
MSG_ENTRY_SETUP et al. An entry_setup arriving in that window hit
ChannelUnknownType -> SETUP_ERROR.

Now: register every inbound handler first, run the (outbound) warm-load
store_load, then push Ready as the last frame. This both removes the
no-handler race and preserves the warm-load-before-entry_setup invariant
(Ready timing still gates entry_setup until the restore cache is warm).
2026-06-12 11:34:31 -04:00
Paulus Schoutsen 2bea386a75 sandbox: replay services registered during setup (Phase 1)
EVENT_SERVICE_REGISTERED fires synchronously while a service is
registered inside async_setup_entry, but EntryRunner only approves the
domain after async_setup returns, so ServiceMirror dropped those early
registrations with a warning and never replayed them.

ApprovedDomains now fires approve-listeners on the first (absent->present)
add; ServiceMirror subscribes async_sync_domain, which re-mirrors every
already-registered service of the freshly-approved domain (skipping any
already in _mirrored). Covers both the entry-runner approve path and the
entity-bridge per-entity approve path.

EventMirror is left as-is: owned events are transient, so a past event
cannot be replayed (noted in plan Phase 1).
2026-06-12 11:33:33 -04:00
Paulus Schoutsen 86122130ac sandbox: STATUS — plan-review-trust-boundary landing note 2026-06-12 11:23:13 -04:00
Paulus Schoutsen 51c8c066a7 sandbox: reconcile ARCHITECTURE security-posture docs (Phase 8)
All trust-boundary gates this plan proposed shipped (Phases 1-6, none
deferred), so the malicious-sandbox guarantees the architecture asserts now
match enforced reality. Added a one-line 'enforced on main in
bridge.py/channel.py' note for each gate, traceable to code:

* §4  channel read-backpressure shedding (both mirrors)
* §8  register_entity entry ownership + foreign-device-merge refusal
* §8  register_service / fire_event owned-domain gates + core deny-list
* §8  context-cache eviction bound on the resolve path
* §9  store key-length + value/total/key-count quotas
* §11 translation overlay narrowed to requested ∩ returned

No 'Known trust-boundary gaps' subsection is needed — nothing was deferred.
Added a changelog row. README.md/CLAUDE.md make no overstated boundary
claims (only 'isolated subprocesses', accurate), so no softening needed.
2026-06-12 11:20:08 -04:00
Paulus Schoutsen 2738b8c003 sandbox: adversarial forged-frame tests per gate (Phase 7)
One forged-frame test per trust-boundary gate:

* fire_event: core (homeassistant_stop/call_service/state_changed) and
  unowned-domain (zha_event/hue_event) events are dropped, never reach the bus.
* register_service: an unowned domain (persistent_notification) is rejected.
* register_entity: a foreign entry_id (entry.sandbox != group) is rejected;
  a device_info colliding with a foreign entry's device is refused (no merge).
* translation: a forged foreign domain returned alongside the owned one is
  dropped; only the requested ∩ returned survives.
* store_save: an overlong key and an oversized value are rejected; nothing
  hits disk.
* context cache: a flood of distinct unknown context_ids stays bounded by
  _CONTEXT_CACHE_MAX.
* channel backpressure: over the max_queued cap, inbound calls are shed with
  a ChannelOverloaded error and the inflight set stops growing.

make_channel_pair gained max_queued_a/b passthrough for the backpressure test.
2026-06-12 11:17:14 -04:00
Paulus Schoutsen 85401a800a sandbox: bound context-cache + channel-flood memory vectors (Phase 6)
Two unbounded-growth vectors closed:

1. Context cache on resolve. _resolve_context minted a fresh Context per
   unknown context_id but never enforced _CONTEXT_CACHE_MAX (only
   _remember_context did), so a sandbox flooding distinct unknown ids grew
   the cache without bound. Factor a single _store_context() helper used by
   both paths so the cap + expiry-ordering apply uniformly.

2. Channel read backpressure (BOTH mirrors). The reader create_task'd a
   handler per inbound frame; the inflight semaphore caps *running*
   handlers but queued tasks — each pinning a decoded payload up to
   MAX_FRAME_SIZE — grew without bound under a flood. _dispatch now sheds
   over a DEFAULT_MAX_QUEUED cap on inflight handler tasks: inbound calls
   are rejected with a ChannelOverloaded error frame, pushes dropped.
   Responses are always handled inline above the gate, so backpressure
   never starves a reply.

The channel.py edit is applied byte-identically to both hand-mirrored
copies (homeassistant/components/sandbox/channel.py and
sandbox/hass_client/hass_client/channel.py), rebased on top of plan
#1's Channel.close() fix, in this separate commit.

Design note: shed (reject/drop over a bounded cap) rather than block the
reader on the semaphore. Blocking the shared reader would deadlock the
documented nested-call pattern — a handler that issues channel.call() and
awaits its reply through the same reader would stall it once all slots are
held by such handlers (real on the client mirror: a call_service handler
doing a store_save round-trip to main). Shedding bounds memory without
that liveness hazard and stays safe in both mirrors.
2026-06-12 11:13:17 -04:00
Paulus Schoutsen a12d74bb41 sandbox: store server quotas (Phase 5)
_validate_key now caps key length (128, well under NAME_MAX). async_save
caps each value (4 MB) and enforces a per-group dir quota (32 MB total,
256 keys) via _enforce_group_quota before the atomic write, so a
compromised sandbox can no longer exhaust the host disk through the
store-routing channel. Limits are commented, generous-but-finite
constants.

Over-quota writes raise HomeAssistantError → remote-error frame; the
sandbox-side async_store_save already catches ChannelRemoteError and logs
(keeping its in-memory data), so a rejected flush degrades, not crashes.
2026-06-12 11:07:49 -04:00
Paulus Schoutsen f55a43ede0 sandbox: translation returned-domains gate (Phase 4)
The provider overlay (_TranslationCache._async_overlay_sandbox_strings)
splices every domain a sandbox returns, narrowed only by the broad
requested components set — so a compromised sandbox could return strings
for a co-requested victim domain (hue, http) and poison its frontend
strings. async_get_translations now keeps only the requested ∩ returned
intersection (domains & strings.keys()), the set this group was actually
asked to resolve, before handing strings to the cache.
2026-06-12 11:05:50 -04:00
Paulus Schoutsen 3139acb3f1 sandbox: entry/group ownership on register_entity (Phase 3)
_handle_register_entity now requires entry.sandbox == self.group, not
just that the entry_id resolves: a compromised sandbox may only register
entities for entries main routed to *this* group. Without this it could
attach entities and pre-create devices against a victim integration's
config entry.

Also reject a device pre-create that would merge (via shared
identifiers/connections) into a device already owned by a config entry
outside this group — _reject_foreign_device_merge — closing the
device-registry hijack vector. entry.sandbox is set by main at flow
completion, never by the sandbox.

entry_setup/entry_unload are main-initiated (main supplies the entry_id),
and the store server is scoped one-dir-per-channel by construction, so no
further entry_id trust points need gating.

Test entry fixtures across bridge/entity_query/domain_proxies/
crash_recovery/proto_transport updated to tag their entries sandbox="built-in".
2026-06-12 11:05:17 -04:00
Paulus Schoutsen 3723fb9ec9 sandbox: register_service ownership check (Phase 2)
_handle_register_service now requires the service domain to be one this
group owns (same main-side _owned_domains() derivation as the fire_event
gate), so a compromised sandbox can no longer squat
persistent_notification.* or any unclaimed domain.service. Unowned
domains are rejected with a HomeAssistantError → remote-error frame; the
existing refuse-to-clobber-an-existing-handler check is kept.

Existing register_service tests updated to own their mock domains via a
MockConfigEntry(sandbox="built-in").
2026-06-12 11:01:51 -04:00
Paulus Schoutsen 22af8b4293 sandbox: main-side fire_event domain gate (Phase 1)
Enforce on main the same <owned_domain>_ rule the sandbox claimed to:
_handle_fire_event now drops any event that is not in the <domain>_
namespace of a domain this group owns, plus a hard deny-list of core
control-plane events (homeassistant_*, call_service, state_changed, ...)
so an owned domain can never alias a core event.

Owned-domain trust is derived purely from main-side state via the new
_owned_domains() helper (entries with entry.sandbox == self.group plus
registered proxy platform domains) — never from a sandbox-supplied
identifier. The helper is the linchpin reused by the register_service
gate in the next phase.

Drops (never raises) on a forged push, so the dispatch loop is unaffected.
2026-06-12 11:00:10 -04:00
Paulus Schoutsen 15ad329856 sandbox: STATUS — plan-review-crash-recovery landing note
Documents all six phases shipped, the /phx:work→intent deviation, the
SETUP_RETRY→SETUP_ERROR fallback (true retry not feasible from the router
seam), the new EntityComponent.async_unregister_remote_platform public
hook, the channel.py both-mirrors note, test coverage, and final
verification (224 + 87 + 30 passed; drift clean; prek clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:49:51 -04:00
Paulus Schoutsen d16080e9dc sandbox: crash-recovery regression tests (Phase 6)
Guards every fix in the cluster:

- test_crash_recovery.py (new): crash→respawn→re-register (Phase 1),
  sandbox-dies-availability + recovery (Phase 2), unload-while-down
  releases the platform (Phase 4).
- test_manager.py: stop()-during-spawn completes without hang (Phase 3) —
  a _PausingProcess lands stop() while _spawn is suspended so
  self._process is still None.
- test_channel.py: Channel.close() after EOF still closes the transport
  and awaits inflight exactly once, and is idempotent (Phase 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:48:10 -04:00
Paulus Schoutsen 5867dec990 sandbox: fix Channel.close() no-op + honest SETUP_ERROR (Phase 5)
Channel.close() early-returned on `if self._closed: return`. But the read
loop's EOF `finally` already sets `_closed=True` (and cancels, never
awaits, inflight tasks), so a close() after EOF returned immediately —
transport.close() and the inflight gather never ran, leaking the stdin
pipe / unix connection every restart cycle. Split "already closed" (set
_closed, fail pending — idempotent) from "teardown not yet done" (close
transport + await inflight, guarded by a new _close_done flag that runs
exactly once regardless of who set _closed first).

channel.py is hand-mirrored — the identical fix is applied to BOTH copies
(homeassistant/components/sandbox/channel.py and
sandbox/hass_client/hass_client/channel.py).

SETUP_RETRY non-retry: the router runs outside ConfigEntry.async_setup,
so the SETUP_RETRY timer (async_call_later) is never armed for a sandbox
entry — a router-set SETUP_RETRY wedged the entry in a retry state that
never fires (and a later async_setup raised OperationNotAllowed). The
ChannelClosedError-during-entry_setup case now reports SETUP_ERROR
honestly (recoverable via manual reload); ARCHITECTURE.md §5 updated and
a router-driven true retry flagged as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:42:48 -04:00
Paulus Schoutsen d86cd3ea81 sandbox: tear down main-side proxies on unload-while-down (Phase 4)
router.async_unload_entry returned True without any cleanup when the
sandbox/channel was down, leaking the proxy entities and the
EntityComponent platform registration → a later re-setup hit "has
already been setup!".

Extract a shared _async_unload_main_side helper (delegates to
bridge.async_unload_entry, which now uses the Phase 1 public-hook
teardown) and call it on every exit that should release main-side state:
the sandbox-down early return and a ChannelClosedError mid-unload both
skip the (impossible) remote RPC but still remove the proxies + platform.
A live sandbox that refuses the unload (ChannelRemoteError) still returns
False with the proxies left in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:39:27 -04:00
Paulus Schoutsen c89fbe7865 sandbox: bound the shutdown/respawn hangs in manager (Phase 3)
Two paths could hang HA shutdown or wedge a sandbox forever:

- stop() spawn-in-progress race: a stop() that landed inside _spawn read
  self._process as None, terminated nothing, then awaited the supervisor
  forever while the freshly-spawned healthy child ran unsupervised. Fix:
  a post-spawn _stopping check in _run_one_{stdio,unix} kills the child
  stop() missed, and stop()'s await is bounded with a SIGKILL+cancel
  backstop. Terminate logic is extracted into a shared _terminate helper.

- Respawn had no ready-timeout: ready_timeout was only applied in the
  first start(). _supervise_until_exit now bounds the ready handshake on
  every attempt; a child that opens its channel but never signals ready
  is killed and counts against the restart budget instead of leaving the
  sandbox 'starting' forever.

Also: ensure_started no longer hands back a 'starting' zombie — it awaits
readiness (bounded) via the new async_wait_until_ready and raises
SandboxFailedError if the in-flight spawn never becomes running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:38:16 -04:00
Paulus Schoutsen bfc75a6cc1 sandbox: mark proxies unavailable when sandbox dies (Phase 2)
sandbox_set_available() had zero callers, so a dead sandbox's proxies
kept serving their last state — a crashed integration's light read "on"
forever to automations and the UI.

- Add SandboxBridge.async_mark_all_unavailable() (flips every owned proxy
  to unavailable via the existing sandbox_set_available).
- Add a manager on_channel_closed callback fired right after the control
  channel is closed on process exit; __init__ wires it to mark the
  group's live bridge unavailable. Proxies flip back to available on
  respawn through the normal register/state_changed round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:33:23 -04:00
Paulus Schoutsen e29f8e0845 sandbox: tear down old bridge on restart (crash-recovery Phase 1)
Keystone fix for crash/restart recovery: a respawn used to overwrite
data.bridges with a fresh SandboxBridge but never released the old one's
proxy entities or its EntityComponent platform slots, so the first
register_entity after respawn raised ValueError("… has already been
setup!") and every entity for the entry failed permanently.

- Add the public inverse hook EntityComponent.async_unregister_remote_platform
  (mirrors async_register_remote_platform; no private _platforms poke —
  the bridge's old SLF001 poke in async_unload_entry is replaced by it).
- Add SandboxBridge.async_teardown() + a shared _async_teardown_entry
  helper; async_unload_entry now routes through it.
- __init__: on restart, stash the displaced bridge in
  SandboxData.pending_teardown and, when the fresh process goes ready,
  tear it down and re-drive entry_setup (async_schedule_reload) for the
  group's loaded entries. A new manager on_ready callback is the trigger
  (fires on every (re)spawn after MSG_READY); capturing loaded entries
  synchronously keeps a first start from being treated as a respawn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:31:41 -04:00
Paulus Schoutsen c3f0abc53c sandbox: stop tracking generated test .storage; match dev
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:18:20 -04:00
Paulus Schoutsen 6218ba0185 sandbox: add code-review follow-up plans + intro presentation
Capture the 2026-06-12 sandbox code review as actionable artifacts:

- plans/plan-review-INDEX.md + five phased fix plans covering every verified
  finding — crash/restart recovery, trust-boundary hardening, client-side
  races + int->float wire fidelity, config-flow forwarding fidelity, and
  simplification/dedup. Index records execution order and a finding->plan
  coverage map; plans cross-reference each other by name.
- PRESENTATION.md — a 10-slide intro to the sandbox concept (what data
  represents an integration in another instance -> entity/registry/device,
  service control, event listening, action forwarding, entity RPC for
  query-shaped APIs, then sandboxes as ephemeral one-domain isolates).

The documentation findings from the review were fixed directly (prior commit);
the remaining code findings are planned here, not yet implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:29:06 -04:00
Paulus Schoutsen 4ae06444b3 sandbox/docs: consolidate to one architecture doc; drop OVERVIEW + v1
Delete OVERVIEW.md — it duplicated ARCHITECTURE.md at a different altitude and
was the source of every doc-vs-doc disagreement found in review (31-vs-32
proxies, SETUP_RETRY vs SETUP_ERROR, classify-at-entry-setup). ARCHITECTURE.md
is now the single architecture reference; its missing "where to look in the
code" map moved in as §15.

Scrub all v1 references from the live docs: the changelog removal line, the
rename row wording, README's "v1 kept for reference" prose, and CLAUDE's
v1-removal paragraph, Iron-Law cautionary tale, and "v1 removal. DONE" bullet.

Fix ARCHITECTURE drift: crash-budget exhaustion is SETUP_ERROR (not
SETUP_RETRY); classify() runs at flow creation only. Rewrite README.md to a
slim entry point (real stdio:// quick-start, no ws://token/RemoteStore),
re-point CLAUDE.md's OVERVIEW links to ARCHITECTURE.md, and correct CLAUDE.md
to list five core-HA surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:28:55 -04:00
Paulus Schoutsen 907a1f7019 sandbox/docs: add doc-audit research notes (ARCHITECTURE/OVERVIEW vs code)
Dated audit snapshots (2026-06-05) cross-checking every concrete name /
RPC / routing rule / table row in ARCHITECTURE.md and OVERVIEW.md against
the implementation. Kept as research artifacts under plans/research/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:32:03 -04:00
Paulus Schoutsen e1f1a7f91c sandbox: STATUS — plan-query-rpc landing note
Record what shipped per phase (service-path + EntityQuery request/response),
what stays deferred (subscription/push primitive, todo, browse_media
media-source caveat), the deviations (search via async_internal_search_media,
JSON-safe sandbox response, callerless raise_not_proxied retained), and the
green verification summary lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:23:45 -04:00
Paulus Schoutsen 6791c64d59 sandbox/docs: query RPCs are implemented; subscriptions still open
Reflect the shipped request/response query RPCs across the docs: the
server-side query + WS-only mutation entity APIs now answer with real data
(service-path return_response + the generic entity_query RPC), so the
catalogue's status column, ARCHITECTURE §8/§14, OVERVIEW's "still open"
bullet, and the CLAUDE.md follow-up all move from "raises" to "wired". Kept
accurate as still-open: the subscription/push primitive (the */subscribe
one-shot-only rows + todo item-list push) and the media_player.browse_media
media-source caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:22:03 -04:00
Paulus Schoutsen 21788fd815 sandbox: test EntityQuery serialization fidelity + error paths
Round-trip rebuild tests for SearchMedia and Segment (the as_dict /
dataclass-asdict-vs-constructor asymmetry), per-op EntityQuery proxy tests
(media search, release notes, vacuum segments, calendar update/delete) that
assert the rebuilt typed object and the forwarded method + args, and the two
error paths: a sandbox-side ServiceValidationError translating to a
HomeAssistantError on main, and a closed channel degrading to a clean
HomeAssistantError. Client-side coverage for the EntityQuery handler:
method invocation + kwarg passing, unknown entity_id, unknown method, and a
raising method propagating its exception type on the error frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:19:23 -04:00
Paulus Schoutsen e5f2f8f932 sandbox: wire the service-less query ops onto EntityQuery
Replace the remaining raise_not_proxied stubs with EntityQuery forwards +
typed rebuilds, so every query-shaped entity API now answers with real data:

- media_player.async_search_media -> async_internal_search_media (which
  rebuilds the SearchMediaQuery from flat kwargs on the sandbox side, so the
  query crosses as plain JSON); rebuilds SearchMedia, reusing the BrowseMedia
  helper for its result list.
- update.async_release_notes -> async_release_notes (plain str/None).
- vacuum.async_get_segments -> async_get_segments; rebuilds list[Segment].
- calendar.async_update_event / async_delete_event -> the matching WS-only
  entity methods (None result).

The sandbox-side serialisation is the as_dict-aware JSON encoder already
added with the handler, so SearchMedia/BrowseMedia/Segment cross verbatim.
raise_not_proxied is now callerless but kept exported for the still-deferred
subscription/todo-push primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:19:11 -04:00
Paulus Schoutsen 33dab10779 sandbox: add the generic EntityQuery request/response RPC
The fire-and-forget call_service channel can command an entity but can't
ask it a server-side question that has no SupportsResponse service to ride.
Add one generic EntityQuery RPC for those, mirroring the call_service path
end to end (proto -> codec registry -> bridge sender + error translation ->
sandbox handler -> proxy helper):

- proto: EntityQuery {sandbox_entity_id, method, args, context_id} and
  EntityQueryResult {result} (the return wrapped as {"value": ...} so
  scalar/list/None are all representable). Gencode regenerated into both
  _pb2 mirrors; drift guard passes.
- MSG_ENTITY_QUERY constant + REGISTRY entry added to both protocol/messages
  mirrors.
- SandboxBridge.async_entity_query builds the request, remembers the context
  before the id is reduced to a wire value, translates remote/closed errors
  through the existing paths, and unwraps {"value": ...}.
- EntryRunner._handle_entity_query resolves the entity on the private hass,
  invokes the named method with the decoded kwargs, and serialises the return
  through the as_dict-aware JSON encoder; raised HA/voluptuous errors
  propagate as channel error frames so main rebuilds the same shape.
- SandboxProxyEntity._entity_query is the proxy-side companion to
  _call_service.

No proxy op is wired onto it yet — that is the next phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:13:17 -04:00
Paulus Schoutsen 98e63bc133 sandbox: proxy response queries via the call_service path
Wire the three query-shaped entity APIs that have a SupportsResponse
service onto the existing call_service + return_response channel, so a
sandboxed entity answers them with real data instead of raising:

- calendar.async_get_events -> calendar.get_events service, rebuilding
  list[CalendarEvent] from the response (explicit field mapping, ISO
  date/datetime parse — not a **dict splat).
- weather.async_forecast_{daily,hourly,twice_daily} -> weather.get_forecasts
  service; Forecast is a plain TypedDict, returned verbatim.
- media_player.async_browse_media -> media_player.browse_media service,
  rebuilding the recursive BrowseMedia from its frontend-shaped as_dict.

SandboxProxyEntity._call_service grows a return_response flag that decodes
the CallServiceResult response into a dict. The sandbox-side call_service
handler now runs rich service responses (e.g. a BrowseMedia object keyed by
entity_id) through the as_dict-aware JSON encoder before packing the Struct,
yielding the exact wire shape main rebuilds from.

Caveat documented at the browse_media call site: a sandboxed player's browse
surfaces only its own sources; the media_source tree is empty inside the
sandbox (media_source runs on main). Round-trip rebuild unit tests cover the
as_dict-vs-constructor asymmetry first (plan Risk #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:09:33 -04:00
Paulus Schoutsen 6b7d559d8d sandbox/docs: catalogue query-shaped RPC gap + request/response plan
Document the unproxied query/subscribe/WS-only entity APIs, their interim
raise behaviour, and the two missing primitives (request/response +
subscription RPC) in docs/query-shaped-rpcs.md. Add the implementation
plan (plan-query-rpc.md): a generic EntityQuery RPC for the service-less
ops + reuse of the existing call_service return_response path for ops
that have a SupportsResponse service. Note the media_player.browse_media
caveat (no media_source tree inside the sandbox). Cross-reference from
ARCHITECTURE/OVERVIEW/CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:57:12 -04:00
Paulus Schoutsen 623c569807 sandbox: fail loudly on unproxied query-shaped entity APIs; block todo
The server-side query / subscribe / WS-only-mutation entity APIs the
fire-and-forget call_service bridge can't express (calendar listings +
event update/delete, weather forecasts, media browse/search, update
release notes, vacuum segments) previously returned empty/None silently.
Add entity.raise_not_proxied and have those proxy methods raise
HomeAssistantError instead, so the gap fails loudly until a real query
RPC lands.

todo is a special case: its To-do panel reads the sync todo_items
property that also feeds TodoListEntity.state, so it can't be a query at
all. Route it to main via SANDBOX_INCOMPATIBLE_PLATFORMS and drop the
proxy (matching the camera/image precedent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:56:59 -04:00
Paulus Schoutsen 1a72d6658c sandbox/docs: document translation forwarding; fix OVERVIEW drift
Both docs now describe the translation-forwarding subsystem in the body,
not just the goal: live pull (sandbox/get_translations RPC + provider
overlay) and the picker catalog hook.

- OVERVIEW: add a Translation forwarding section + "where to look" row +
  v1-diff row. Fix pre-existing drift: ALWAYS_MAIN is 24 entries across
  three groups (was listed as 6), failed-sandbox setup is SETUP_ERROR
  (not SETUP_RETRY), and the manager runs no periodic ping loop.
- ARCHITECTURE: add §11 Translation forwarding (renumber following
  sections), list translation.py/catalog.py in §2, and correct the core
  touch surface from three to five hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:28:45 -04:00
Paulus Schoutsen 2bb6cac651 sandbox/docs: mention translations in the unified view; drop architecture.html
Translation forwarding (live pull-RPC + catalog provider) now puts the
sandboxed integration's translations on main alongside its entities,
services, and events — note it in the OVERVIEW + ARCHITECTURE goals.
Remove the generated architecture.html; the architecture is published to
a gist instead of carrying a rendered artifact in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:04:08 -04:00
Paulus Schoutsen b7e58d234b sandbox: STATUS — plan-translation-forwarding Phase A landing note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:32:23 -04:00
Paulus Schoutsen 2085b5348d sandbox: A3 — document the catalog-provider HACS contract
Add sandbox/docs/catalog-provider-contract.md describing the display-only
picker catalog hook: the discoverability gap it closes, the
async_register_sandbox_catalog_provider API, and the contract — separate
from the sha-pinned source resolver, name load-bearing, title_translations
optional, no validation, display-only scope, and how it complements the
Phase B live RPC for the cold picker case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:31:03 -04:00
Paulus Schoutsen e95fd93e21 sandbox: A2 — merge catalog into descriptions + title fallback
Wire the A1 catalog hook into the two display paths a sandbox-only custom
falls through today:

- async_get_integration_descriptions (loader.py): append catalog
  descriptors to the custom integration/helper buckets so the add-
  integration picker lists them. On-disk customs carry richer metadata,
  so the disk scan wins on a domain collision.
- _async_get_component_strings (helpers/translation.py): when a domain
  has no on-disk Integration (IntegrationNotFound on main), take its
  "title" from the catalog — a localized title_translations[lang] if
  present, otherwise degrading to the descriptor name.

Tests: catalog entry appears in descriptions with picker name + defaults
+ helper-bucket routing; on-disk custom wins a collision; title fallback
uses title_translations and degrades to name when absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:30:10 -04:00
Paulus Schoutsen 07dcf64357 sandbox: A1 — catalog-provider hook for picker discoverability
Add a separate, display-only catalog hook so a custom integration whose
code lives only in a sandbox (never on main's disk) can be listed and
named in the add-integration picker without spawning a sandbox.

Core (homeassistant/loader.py) owns the registry because core consumes
it: SandboxIntegrationDescriptor, SandboxCatalogProvider,
DATA_SANDBOX_CATALOG_PROVIDERS, async_register_sandbox_catalog_provider,
async_get_sandbox_catalog. This mirrors the Phase B translation-provider
precedent (hook + consumer co-located in core).

homeassistant/components/sandbox/catalog.py re-exports the hook so HACS
registers through a sandbox namespace parallel to the source resolver —
but the catalog stays deliberately separate from the sha-pinned, security-
critical source resolver: it is eager, enumerable and cosmetic only.

Wired into descriptions + title fallback in A2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:27:04 -04:00
Paulus Schoutsen 9da2dfa714 sandbox: STATUS — plan-translation-forwarding Phase B landing note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:19:33 -04:00
Paulus Schoutsen bb32e859f1 sandbox: B4 — main-side translation provider impl + registration
Implement SandboxTranslationProvider and register it into core's translation
hook from async_setup (unregistered on stop). For each requested component it:

- resolves the owning sandbox group — a loaded entry's .sandbox field wins,
  else the live SandboxFlowProxy of a brand-new custom's in-progress flow
  (new sandbox_group accessor on the proxy);
- carves out built-ins (Integration.is_built_in ⇒ main reads its byte-identical
  disk copy, never the wire);
- batches each group's custom domains into one get_translations RPC per
  language (5s timeout), and degrades to empty strings on a down/closed/slow
  channel so the cache-lock overlay never blocks the frontend.

router.async_unload_entry now invalidates a sandboxed entry's cached
translations, so a reload at a new integration-source ref re-pulls fresh
strings on the next fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:17:46 -04:00
Paulus Schoutsen 8142996b08 sandbox: B3 — core translation provider hook + overlay + invalidation
Add a sandbox-agnostic seam to the translation cache, mirroring the
sandbox.sources source-resolver convention:

- async_register_sandbox_translation_provider(hass, provider): a HassKey-backed
  registry with an unregister callback. The provider is awaited inside the
  cache load and returns {language: {domain: raw_strings}} for only the domains
  it owns.
- _TranslationCache._async_load overlays the provider result onto
  translation_by_language_strings after async_get_integrations and before
  _build_category_cache, so sandboxed strings flow through the same flatten /
  English-fallback / loaded machinery as on-disk strings. A custom sandboxed
  domain (IntegrationNotFound on main) thus stops resolving to {}.
- _TranslationCache.async_invalidate + async_invalidate_translations wrapper:
  the first eviction API (translations were never unloaded), called by the
  sandbox when a custom integration is re-fetched at a new ref.

Core never raises on a provider; degrade-to-empty is the provider's contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:12:52 -04:00
Paulus Schoutsen 5ffbe73ae2 sandbox: B2 — get_translations runtime handler + string loader
Register a sandbox/get_translations handler in SandboxRuntime. It loads raw
translation strings for the requested domains from the sandbox's own
filesystem (built-in from the bundled package, custom from the fetched
<config>/custom_components/<domain>) by reusing core's
_async_get_component_strings against the sandbox-private hass — which also
pre-fills 'title' from integration.name. Main cannot run that fallback for a
custom domain because it holds no Integration, so the title must be injected
here. Replies with {language, strings: {domain: raw dict}}.

Tests cover built-in title pass-through, custom title injection, the empty
case, the Struct packing, and the no-flow-runner guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:09:18 -04:00
Paulus Schoutsen af129cb26a sandbox: B1 — get_translations wire protocol
Add the sandbox/get_translations message pair to the control-channel proto
and regenerate the checked-in gencode for both no-cross-import mirrors.
Mirror MSG_GET_TRANSLATIONS in both protocol.py files and register the
message pair in both messages.py REGISTRY copies.

Request {language, domains[]}; result {language, strings: {domain: raw
strings.json dict}} — main batches a group's custom domains into one call;
built-in domains never cross the wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:06:41 -04:00
Paulus Schoutsen 7533080597 sandbox/plans: add translation-forwarding brainstorm + plan
Brainstorm → plan for forwarding a sandboxed integration's translations
into main: live pull-RPC (Phase B) for running integrations + a catalog
provider (Phase A) for picker discoverability. Includes interview,
research notes, scratchpad, and the phased plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 05:54:20 -04:00
Paulus Schoutsen 87c7fb5b46 sandbox: PLAN_RUNNER — brief is just piped, no file references
Rewrote the briefing so it never frames the brief as a file or mentions
the former tempfile handoff: compose it, pipe it straight into the session
(heredoc), claude-screen pastes it as one message. Dropped the file-pipe
example and the "no tempfile dance" aside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:52:23 -04:00
Paulus Schoutsen ccadaf1236 sandbox: PLAN_RUNNER — drop file-handoff framing from briefing
Now that claude-screen pastes multi-line directly, the brief no longer
needs a tempfile + "Read /tmp/X" pointer. Step 1 reads "Compose the brief"
(source it from a heredoc or any scratch file) instead of "Write the
brief"; step 2 shows both heredoc and file pipes. The brief is just stdin,
not a handoff artifact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:50:45 -04:00
Paulus Schoutsen 3fced25724 sandbox: PLAN_RUNNER — STATUS marker now lives under status/
Follow-up to the status/ reorg: the brief's STATUS path and the monitor
until-loop both point at sandbox/status/STATUS-<plan>.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:49:02 -04:00
Paulus Schoutsen 0ae2eef60a sandbox: move STATUS files into status/
Tidy the directory: the 29 per-phase + per-plan landing records
(STATUS-phase-*.md, STATUS-plan-*.md) move out of the sandbox/ root into
sandbox/status/ (git mv, blame preserved). Live current-state docs
(CLAUDE.md, README.md, OVERVIEW.md, FOLLOWUPS.md, architecture.html, the
docker-compose harness comment) now point at status/. Historical records
(the STATUS bodies themselves, plans/*.md, plan.md) keep their original
text by convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:45:38 -04:00
Paulus Schoutsen caa52e2823 sandbox: PLAN_RUNNER — multi-line briefs now paste directly, no tempfile
claude-screen pastes multi-line prompts directly: bracketed-paste markers
keep embedded newlines literal, and the submit \r is sent as a separate
keystroke a beat later (the concatenated \r was what raced the paste and
submitted mid-prompt). Verified live — a 3-line prompt lands as one
message. Doc no longer mentions any file-handoff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:39:07 -04:00
Paulus Schoutsen 5b35d4b20e sandbox: PLAN_RUNNER — claude-screen now auto-handles multi-line briefs
The single-line file-handoff is now built into claude-screen itself (it
detects a newline in the piped prompt, stashes the brief to a tempfile,
and pastes a pointer). So the doc just pipes the brief straight in; the
manual "write to /tmp + echo a single line" dance is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:36:09 -04:00
Paulus Schoutsen fa28e7630a sandbox: PLAN_RUNNER — plans MUST be executed via the phx:work skill
Make explicit that each sub-session steps through its plan with the
phx:work skill (task-by-task with per-step compile/test verification),
not ad-hoc edits. Added as a brief hard rule + a why-this-shape bullet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:31:20 -04:00
Paulus Schoutsen b7e3a36002 sandbox: add PLAN_RUNNER.md — the per-plan sub-session workflow
Documents the loop used to build this batch: write a brief to a tempfile,
spawn a fresh Claude in a screen window via single-line file-handoff, watch
for a STATUS marker, verify independently, push, kill the window. Captures
the gotchas that bit (single-line stdin, prompt-submit confirmation,
prefix-match window names, orchestrator-only push).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:30:32 -04:00
Paulus Schoutsen 1cef20237f sandbox/tests: rename test_phase1_spike_late_additions_pin_to_main
Drop the build-phase scaffolding from the test name; it just verifies ai_task
and image pin to ALWAYS_MAIN. -> test_ai_task_and_image_pin_to_main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:53:32 -04:00
Paulus Schoutsen 3c60b2b1a2 sandbox: trim the compat lane to one runner
The one-shot full cross-sweep that produced the original backlog
(run_compat_full.py + categorize_failures.py + generate_backlog.py) and its
machine-generated outputs (COMPAT_FULL.md/.csv, COMPAT_LATEST.md, COMPAT.csv,
BACKLOG_FAILURES.json) were Phase-16 measurement scaffolding; the gate is long
cleared. Keep the single ongoing runner (run_compat.py) and the two curated
summaries (COMPAT.md, BACKLOG.md). Git-ignore the per-run machine output so it
stops being checked in. Living docs updated; recover the full-sweep tooling
from git history if a fresh tree-wide sweep is ever needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:48:53 -04:00
Paulus Schoutsen 66f96e9438 sandbox/bridge: route the service forwarder through _raw_call_service
The registered-service forwarder (_build_service_forwarder._forward) rebuilt its
own pb.CallService request and duplicated the ChannelRemoteError/
ChannelClosedError translation that _raw_call_service already does. With the
batcher gone, _raw_call_service is the single low-level send helper — have
_forward call it and keep only its response-extraction logic. No behaviour
change (the channel-closed error message is now the shared generic one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:46:09 -04:00
Paulus Schoutsen 11e97c62ea sandbox: remove the Option A/B spike harness
The spike (hass_client/spike/: bridge_a, bridge_b, rig, synthetic_light,
transport + tests/components/sandbox/test_spike.py) was a one-off bake-off to
choose between entity-bridge designs. Option B was chosen and shipped long ago;
nothing in production imports the spike, only its own test did. Delete it.

docs/entity-bridge-decision.md keeps the rationale and the measured numbers as
the decision record, with a note that the harness is recoverable from git
history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:45:07 -04:00
Paulus Schoutsen ecc8384382 sandbox: drop the call-service batcher; keep the first iteration simple
Each proxy entity service call now forwards as its own single
`sandbox/call_service` RPC. The per-loop-tick coalescing batcher
(_CallServiceBatcher / _BatchBucket) added complexity the first iteration
doesn't need, so it is removed; async_call_service calls _raw_call_service
directly. Behaviour is unchanged except a multi-entity area call now pays one
RPC per entity instead of one coalesced RPC.

Coalescing same-tick calls is recorded as a future optimisation in
docs/FOLLOWUPS.md (with the 200-light perf benchmark that validated it). Living
docs updated; the phase-history records are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:21:29 -04:00
Paulus Schoutsen 34d0a533c7 sandbox/bridge: correct misleading 'fire-and-forget' wording
A service call is never fire-and-forget: each batched caller awaits the
coalesced RPC's completion via its future, which resolves with the result or
the raised error, so every caller learns when its call finished. Batching only
shares the *wire* call, not the await; only a response *value* can't be
coalesced (hence the response bypass). Wording-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:04:44 -04:00
Paulus Schoutsen bd90dcd7dc sandbox: drop development-phase references from code
The final deliverable should not carry the scaffolding of the phases it was
built in. Reword comments, docstrings, and generated-output strings that named
build phases (Phase N / T1-T3 / Phase A1-A2) to describe what the code does,
and rename the phase-numbered test files:

  test_phase4_subprocess  -> test_subprocess
  test_phase9_shutdown    -> test_shutdown
  test_phase13_proxies    -> test_domain_proxies
  test_phase14            -> test_schema_and_unload
  test_phase19_devices    -> test_device_registry

Comments/docstrings/filenames only; no logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:48 -04:00
Paulus Schoutsen 43310e8c21 sandbox/bridge: use orjson helper for batch key; bypass batcher for responses
- Replace a stray function-local `import json` (json.dumps behind a bogus
  'keeps json off the integration boot path' noqa) with HA's orjson
  json_bytes_sorted helper for the call-service batch key.
- A response-returning entity call now bypasses the per-tick batcher.
  Coalescing forces every caller in a bucket to share one combined response,
  which is wrong when a caller needs its own value; response calls go out as
  their own single-entity RPC. The batcher is now fire-and-forget only, so its
  dead return_response plumbing is dropped.
- Also removes development-phase references from this file's docstrings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:20 -04:00
Paulus Schoutsen a9781cca14 sandbox: document the current auth design (no credential + context restore)
Rewrite docs/auth-scoping-decision.md to lead with the shipped design: the
sandbox holds no credential and cannot fabricate a Context; main restores
attribution from a TTL cache of contexts it issued and falls back to
user_id=None. The reverted, never-shipped scoped-token mechanism is kept as a
clearly-marked appendix for whenever the sandbox->main websocket lands. Update
the CLAUDE.md pointer to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:08 -04:00
Paulus Schoutsen cf535a086f sandbox: drop V2 naming from living docs
Rename identifiers (SandboxV2Data->SandboxData, DATA_SANDBOX_V2->
DATA_SANDBOX, SandboxV2Error->SandboxError), env vars (SANDBOX_V2_*->
SANDBOX_*), and stale sandbox_v2/ paths in the current-state docs
(OVERVIEW, README, CLAUDE, plan, architecture.html, COMPAT*, docs/*),
and reword prose that named the current sandbox "v2". Historical
STATUS-phase-*/plans/* records are left intact as point-in-time history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:56:28 -04:00
Paulus Schoutsen d20ed216cb sandbox: drop remaining V2 naming from live code and config
The rename sweep missed several identifiers, env vars, and the
pre-commit drift-guard hook (whose entry/files paths still pointed at
the non-existent sandbox_v2/ tree, leaving the hook broken). Rename:

- SandboxV2Data -> SandboxData, DATA_SANDBOX_V2 -> DATA_SANDBOX,
  SandboxV2Error -> SandboxError (+ all references and tests)
- SANDBOX_V2_ERRORS_DIR/TRANSPORT/SOCKET_PATH -> SANDBOX_* env vars
- pre-commit hook id/entry/files: sandbox_v2/proto -> sandbox/proto
- stale sandbox_v2 paths and 'v2' wording in .dockerignore + scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:50:32 -04:00
Paulus Schoutsen afc45ae34b sandbox: drop unreleased Phase-7 scopes back-compat shim
The reverted Phase-7 auth-scoping mechanism never shipped, so no
real auth store carries a legacy "scopes" key. Remove the defensive
pop in AuthStore (RefreshToken is built by explicit field mapping, so
unknown keys are ignored anyway) and its test. Reword the
ConfigEntry.sandbox load comment to state the real reason the key is
optional (non-sandboxed entries omit it) instead of referencing an
unreleased phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:44:26 -04:00
Paulus Schoutsen 83a0c28229 Merge remote-tracking branch 'origin/dev' into sandbox
# Conflicts:
#	pyproject.toml
2026-06-04 14:26:43 -04:00
Paulus Schoutsen 0dd9252d73 sandbox: architecture doc review polish
Review feedback on ARCHITECTURE.md:
- Goal (§1) now names storage alongside setup/flow/entities/services/
  events, and adds a short statelessness line (storage routes to main,
  code is fetched at startup → wipe-and-restart safe).
- Auth (§10) trimmed to describe the current design — no credential, no
  user — instead of narrating the token's removal. The removal history
  lives in the changelog where it belongs.
- Dropped the "(the Iron Law: never monkey-patch private internals)"
  parenthetical in §11; the plain "declared public hook rather than a
  reach into private internals" already carries the point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 06:11:02 -04:00
Paulus Schoutsen fd2c319e1b sandbox: STATUS for plan-auth-context (token + system user gone, context restored)
Landing notes: how the context cache was seeded (forwarder + entity-call
path), the 15-min TTL bound, confirmation the token + system user are fully
gone (greps), test results, and doc updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:39:00 -04:00
Paulus Schoutsen 83cc4d4a07 sandbox: docs for plan-auth-context (token + system user gone, context restored)
Reconcile the architecture docs with the plan-auth-context landing:

- ARCHITECTURE.md §2/§5/§8/§10/§13 + changelog: auth.py removed from the
  component table; the spawn command no longer carries --token; §8 documents
  the implemented context restoration (TTL cache, own-id minting, ULID-trust
  reasoning); §10 rewritten — no token, no system user, the future
  Context-group-attribute note retained.
- OVERVIEW.md: auth comparison row, the spawn-command blocks, the
  EventMirror context paragraph, the auth section (now "no credential" +
  a Context-restoration subsection), and the file-pointer table.
- FOLLOWUPS.md: a plan-auth-context narrative entry; the open follow-ups now
  describe the fresh-credential-when-WS-lands work and the Context group
  attribute idea.
- auth-scoping-decision.md / CLAUDE.md: note the token + system user are now
  also gone (already SUPERSEDED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:37:58 -04:00
Paulus Schoutsen 6206489b5f sandbox: drop unused token + system user, restore context attribution
plan-auth-context Parts A/B/C — a design-review follow-up. The sandbox is
not an authenticated principal inside main and must never be able to author
a Context.

Part A — drop the unused token. The manager minted a per-group system-user
access token and passed it on --token; the runtime stored it
(SandboxRuntime.token) and never used it (no connection back to main to
authenticate). Removed end-to-end: --token argv (manager._default_command),
the token_factory wiring, SandboxRuntime.token field/param + --token CLI
arg, and SANDBOX_TOKEN in the Docker entrypoint / compose / docs.

Part C — drop the per-group system user. auth.py is deleted entirely
(async_issue_sandbox_access_token + async_get_or_create_sandbox_user gone),
along with bridge._async_system_user_id / _system_user_id. A genuinely
sandbox-originated context is now user_id=None — the honest shape, since no
user authored it.

Part B — context-id restoration. The bridge now seeds a context_id→Context
cache at every main→sandbox call-down site: the service forwarder (_forward)
and the proxy entity's service call (async_call_service, which threads the
entity's live Context). _resolve_context returns a cached Context verbatim
for a known id (restoring the original parent_id / user_id), so a
user-initiated action's attribution survives the round-trip. An unknown or
expired id mints a brand-new Context(user_id=None) with main's own trusted
id — never the sandbox-supplied ULID, whose embedded timestamp main cannot
trust (recorder/logbook order by it); the sandbox string is a cache key
only. The cache is bounded by a 15-minute TTL (lazy front-pruning, plus a
sanity count backstop); a miss is always safe.

Tests: known-id restore end-to-end via the forwarder; unknown→fresh with no
adopted id; no-forgery (the wire proto has no parent_id/user_id field); TTL
expiry degrades to a fresh context; spawn argv no longer carries --token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:37:44 -04:00
Paulus Schoutsen 5d5c1eca6e sandbox: refine plan-auth-context Part B — 15-min TTL + ULID trust
User refinement 2026-06-03:
- Bound the context cache by TIME (15-min TTL), not size. Volume is tiny
  (only main→sandbox service-call contexts, echoed back within seconds).
- For an unknown context_id, main must mint a BRAND-NEW Context with its
  OWN id — never adopt the sandbox's id. context_ids are ULIDs with an
  embedded timestamp and main cannot trust the sandbox's clock (a crafted
  ULID could back/forward-date events; recorder/logbook order by it). The
  sandbox-supplied id is only a cache key, never the resulting Context's
  identity. This corrects T2's current Context(id=context_id, ...) for
  unknown ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:23:43 -04:00
Paulus Schoutsen 2ad24f9111 sandbox: lock plan-auth-context Part C — drop the per-group system user
User decision 2026-06-03: drop the sandbox system user entirely;
sandbox-originated contexts use user_id=None (no reason for the sandbox
to be a user right now). Future-work note recorded: a Context with a
group attribute is the better long-term answer for audit attribution,
but needs a core Context field change and waits until it's needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:17:00 -04:00
Paulus Schoutsen 7c0308e60c sandbox: design-review fixes — schema fallback, group ownership, auth/context
From review feedback on the architecture doc:

1. register_service schema serialisation: broaden serialize_schema's
   fallback from `except (ValueError, TypeError)` to any exception (with a
   warning log). An exotic custom validator could raise other types and
   propagate, dropping the whole service registration on main. Now it
   always degrades to schema=None — main registers the service, the
   sandbox validates. Added test_schema_bridge.py covering the broad path.

2. Clarify in ARCHITECTURE.md that MAIN alone decides the sandbox group:
   the group comes from main's classify(), the proxy overwrites
   create_result["sandbox"] with the main-determined value, and the wire
   FlowResult has no group field. The sandbox can shape its own forms but
   cannot influence storage/routing. (Code was already correct; the doc
   wording was loose.)

3. Note the --token is unused (sandbox is not an authenticated principal
   inside HA) and slated to drop; the per-group system user's only live
   use is context attribution, under reconsideration.

4. Document the intended context model: wire carries context_id only;
   main restores parent_id/user_id from a seen-id cache so the sandbox can
   never fabricate attribution; unknown ids get a fresh no-parent context.

Points 3+4 captured as a buildable follow-up: plans/plan-auth-context.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:10:05 -04:00
Paulus Schoutsen ec709db2f4 sandbox: final architecture document (current state + changelog)
A self-contained, final-state architecture reference for the sandbox:
goal, components, routing, protobuf channel + pluggable transports,
lifecycle, config-flow forwarding, statelessness/integration-source,
entity/service/event bridging, store routing, auth, core-HA touch
surface, testing/Docker, and out-of-scope/future work. Changelog at the
bottom summarises the closing batch (contextvar, strip-auth-scopes,
fidelity, lockdown, transport, ephemeral-sources, docker, rename).

Distinct from OVERVIEW.md (source-linked depth) and architecture.html
(phase-by-phase historical artifact) — this is the current-state-only
narrative.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:20:57 -04:00
Paulus Schoutsen 7b8b31afa5 sandbox: STATUS for plan-rename-sandbox (rename complete) 2026-06-03 10:16:54 -04:00
Paulus Schoutsen 9cd52e950e sandbox_v2: docs reconciliation for the rename (Phase E)
- whats-changed.md (batch tracker): added the `sandbox_v2` → `sandbox` rename
  as a TL;DR bullet + a breaking-change entry (pre-release, wipe-and-restart).
- sandbox/CLAUDE.md: reworded the intro to current-state ("the sandbox rewrite,
  formerly sandbox_v2"); clarified that the removed v1 previously occupied the
  same paths the rewrite now lives at.

Historical records (STATUS-*.md, plans/*.md, docs/auth-scoping-decision.md)
keep their sandbox_v2 mentions intact — they document work done against those
paths.
2026-06-03 10:14:52 -04:00
Paulus Schoutsen 5bab9f867b sandbox_v2: drop hassfest IGNORE_INTEGRATIONS_WITH_ERRORS (Phase C)
The IGNORE_INTEGRATIONS_WITH_ERRORS = {"sandbox"} set tolerated v1's hassfest
violations while v2 stabilised. v1 is gone and the renamed integration (former
v2) is hassfest-clean, so the set + the two conditionals consulting it are
removed — keeping it would mask real errors in the renamed `sandbox`.

hassfest validate + generate: 0 invalid integrations, no generated-file changes
(sandbox has no config_flow → absent from config_flows.py; the NO_QUALITY_SCALE
entry was renamed by the Phase B sweep).
2026-06-03 10:10:36 -04:00
Paulus Schoutsen cd02466612 sandbox_v2: sweep identifiers sandbox_v2 → sandbox + regen protobuf
Phase B of plan-rename-sandbox. Mechanical identifier sweep + the structural
fixups the rename forced (tree compiles + both suites pass at the end):

- Bare-token sweep `sandbox_v2` → `sandbox` across all code + current-state
  docs (excluding historical STATUS-*.md, plans/*.md, auth-scoping-decision.md
  and the generated _pb2 gencode). Channel message strings, storage-key
  namespace, client_id prefix, manifest domain, logger names all move.
- Prose sweep `Sandbox v2` → `Sandbox` (covers the `Sandbox v2: ` system-user
  name prefix → `Sandbox: `).
- Protobuf: renamed sandbox_v2.proto → sandbox.proto (package `sandbox`) and
  REGENERATED gencode (sandbox_v2_pb2 → sandbox_pb2) in both mirrors via the
  isolated-venv recipe; removed the old _pb2 files. Drift guard clean.
- Name-collision fix forced by the rename: the client had both the impl module
  `hass_client/sandbox.py` (exports SandboxRuntime) AND the `-m` launcher
  subpackage `hass_client/sandbox_v2/`. Renaming the launcher to `sandbox`
  collides with the impl module, so merged them — sandbox.py is now
  `hass_client/sandbox/__init__.py` (parent-relative imports rewritten to
  absolute `hass_client.*` per ruff TID252) with the launcher's __main__.py
  kept. `python -m hass_client.sandbox` and
  `from hass_client.sandbox import SandboxRuntime` both work.
- Docker entrypoint/compose/docs → `python -m hass_client.sandbox`.
- Client distribution renamed `hass-client-v2` → `hass-client` (import package
  `hass_client` unchanged; matches the egg-info already installed).

Tests green: HA-side 201 passed, client 70 passed. prek clean on changed set.
2026-06-03 10:10:36 -04:00
Paulus Schoutsen 107cb8b38e sandbox_v2: rename directories sandbox_v2 → sandbox (git mv)
Phase A of plan-rename-sandbox. Pure renames via git mv to preserve blame:
  homeassistant/components/sandbox_v2          → homeassistant/components/sandbox
  tests/components/sandbox_v2                   → tests/components/sandbox
  sandbox_v2                                    → sandbox
  sandbox/hass_client/hass_client/sandbox_v2   → sandbox/hass_client/hass_client/sandbox
  sandbox/proto/sandbox_v2.proto               → sandbox/proto/sandbox.proto

The untracked tests/testing_config/.storage/sandbox_v2 dir is a runtime test
artifact (not tracked); left as-is. The tree does NOT import or pass tests
after this commit — Phase B sweeps every sandbox_v2 identifier + regenerates
the protobuf gencode.
2026-06-03 09:43:33 -04:00
Paulus Schoutsen 4e982e34ca sandbox_v2: docker tracker tick + STATUS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:33:52 -04:00
Paulus Schoutsen 1224f16df1 sandbox_v2: test Dockerfile + unix-socket compose harness
Multi-stage python:3.14-slim image that runs the hass_client sandbox
runtime (python -m hass_client.sandbox_v2). Installs homeassistant +
hass_client into a venv; no pre-baked integration requirements (runtime
pip-installs them on demand), no git (codeload tarball fetch), non-root,
no volumes, no healthcheck, env-driven entrypoint via tini. Closes the
pip/egress runtime gap flagged by plan-ephemeral-sources: the container
is where pip + network egress live.

Transport caveat: unix socket (T3) today, websocket (T4) later — not a
remote-ready artifact. The docker-compose.test.yml captures the intended
same-host unix-socket harness but does not run against today's manager
(private mkdtemp socket path + spawn-not-attach model); both gaps are
documented, not hacked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:32:48 -04:00
Paulus Schoutsen 1b1e954a4f sandbox_v2: ephemeral-sources docs + tracker + STATUS
Docs sweep for the stateless-sandbox feature (d4b7aef732): protocol.py
integration_source field, OVERVIEW entry-lifecycle + statelessness
section, CLAUDE.md resolver-hook contract + sha-pin rule + pip/egress
follow-up, architecture.html fetch-before-setup, whats-changed box ticked.

The sub-session wrote these files but didn't land the second commit;
committing them here. STATUS flags two honest follow-ups: tree-vs-ref
verification trusts GitHub's content-addressed codeload URL rather than a
full git-tree-hash; async_process_requirements (pip for custom deps) is
unconfirmed in the bare-HA sandbox — pairs with plan-docker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:25:06 -04:00
Paulus Schoutsen d4b7aef732 sandbox_v2: stateless sandboxes — push integration source on entry_setup
Make sandboxes hold no integration code: main attaches a typed
IntegrationSource to EntrySetup (builtin no-op, or a git source pinned to
an exact commit sha), and the sandbox fetches custom (HACS) code into
<config>/custom_components/<domain> before async_setup.

- proto: IntegrationSource sub-message + EntrySetup.integration_source (10);
  both _pb2 mirrors regenerated.
- core sources.py: registered-resolver hook (async_register_sandbox_source_
  resolver) keeping core HACS-agnostic; builtin short-circuit; a custom
  domain with no resolver raises. Resolver pins ref to a sha (no core I/O).
- router: _entry_setup_payload resolves + sets integration_source.
- client sources.py: codeload-tarball fetch (injectable primitive),
  process-lifetime (url, ref) cache, manifest.json verification; wired into
  entry_runner before setup.
- tests: resolver registry + payload (HA side), tarball fetch/cache/verify
  with local fixtures (client side). No network in any test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:23:33 -04:00
Paulus Schoutsen c92348b931 sandbox_v2: STATUS for transport T3+T5 (effort complete)
T3 (1eaa79d261) + T5 (42560c6cd0) landed. Unix socket transport
(opt-in via SandboxManager(transport="unix"); stdio remains default),
ws:// rejected with NotImplementedError, wire-protocol docs current.
191 + 62 tests green; drift guard clean. T1→T2→T3→T5 complete; T4 (WS)
out of scope. UnixSocketTransport is StreamTransport-over-unix-streams
(no new class). Socket lives in a short tempdir to dodge the ~108-char
sun_path limit; teardown force-closes accepted clients to avoid a
wait_closed() hang.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:11:50 -04:00
Paulus Schoutsen 42560c6cd0 sandbox_v2: transport cleanup + docs (transport T5)
Bring the wire-protocol docs to the current protobuf + pluggable-transport
reality and tick the transport trackers.

* OVERVIEW.md / architecture.html: rewrite the transport row, the spawn
  prose, and the channel section to describe the three-layer
  Channel/Codec/Transport split, ProtobufCodec as the production wire,
  the Ready-frame handshake (no stdout text marker), length-prefixed
  framing, and stdio + unix transports (websocket reserved/future). Drop
  the stale --url ws:// example and JSON-line wording.
* channel.py docstrings (both mirrors): ProtobufCodec is the production
  codec; JsonCodec is the registry-free channel-core test/debug wire.
* protocol.py docstring: messages are typed protobuf (REGISTRY +
  sandbox_v2.proto); the payload shapes listed are the logical contract.
* sandbox.py: SandboxRuntime docstrings note the --url-selected transport
  (stdio default, unix opt-in, ws reserved).
* whats-changed.md: tick the protobuf-wire + typed-handlers boxes (T2
  360e454330) and pluggable-transports box (T3 1eaa79d261).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:10:36 -04:00
Paulus Schoutsen 1eaa79d261 sandbox_v2: unix socket transport (transport T3)
Add an opt-in unix-domain-socket control-channel transport alongside the
default stdio transport. The manager opens a listening unix socket, passes
its path to the subprocess as --url unix://<path>, and the runtime dials
back; the manager is the server. Both transports reuse StreamTransport's
length-prefixed framing, so no dedicated unix transport class is needed.

* Manager: SandboxManager(transport="stdio"|"unix") (default stdio,
  unchanged behavior). _run_one splits into stdio/unix paths sharing a
  _supervise_until_exit helper; the unix path creates the socket in a
  short per-attempt tempdir (sidesteps the ~108-char sun_path limit),
  races accept against early exit, and force-closes lingering accepted
  connections (server.close_clients) so wait_closed cannot hang.
* CommandFactory is now (group, url) -> argv; the manager owns the
  transport and hands the factory the right --url.
* Runtime: --url scheme selects the transport — stdio:// (default /
  absent), unix://<path>, or ws://|wss:// (reserved, rejected with a
  clear not-implemented error). New _transport_scheme + _open_unix_channel.
* Tests: unix round-trip + socket cleanup (core), scheme selection + ws
  rejection + unix round-trip (client); existing factories updated to the
  (group, url) signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:03:36 -04:00
Paulus Schoutsen f03474c029 sandbox_v2: STATUS for transport T2 (protobuf wire shipped)
T2 landed as 360e454330 (64 files, +3762/-1046). Sub-session report:
- default production codec = ProtobufCodec; ~20 handlers + ~69 test
  sites converted atomically; 189 + 53 tests green; prek + drift guard clean.
- 4 reasoned deviations (bare Channel ctor keeps JsonCodec with proto
  built explicitly at production sites; JsonCodec stays registry-free for
  channel-core tests; grpcio-tools out of project deps via throwaway venv;
  sandbox-side context cache deferred until a consumer needs it).
- One gotcha fixed: a test stub returning a plain dict hung the router's
  untimed channel.call under ProtobufCodec — relevant for T3/T5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:28:09 -04:00
Paulus Schoutsen 360e454330 sandbox_v2: protobuf wire + typed handlers (transport T2)
Atomic switch of the control channel from JSON dicts to typed protobuf
messages, completing transport T2 on top of T1's Transport/Codec seam.

- Codec owns the registry: each side builds a type -> (request_cls,
  result_cls) map from its own _proto mirror and constructs
  ProtobufCodec(registry). The concurrency-critical Channel core stays
  fully codec-agnostic; response frames now carry `type` so the stateless
  codec resolves the result class on both encode and decode.

- Proto refinements (locked 2026-06-03): EntityDescription wraps EntityInfo
  (identity: Description + DeviceInfo) and InitialState (state +
  capabilities + attributes); ServiceResponse is a typed envelope inside
  CallServiceResult (proto3 optional, no has_response bool); StateChanged
  is flattened and carries optional context_id; FireEvent carries optional
  context_id. Dynamic fields cross as Struct/ListValue.

- Context security model: the sandbox only ever sends a context_id string;
  parent_id / user_id never cross the wire. Main resolves the id to its own
  authoritative Context via SandboxBridge._resolve_context — reusing a
  cached Context or minting a fresh one attributed to the sandbox system
  user with no parent_id — for state_changed, fire_event and call_service.

- Generated _pb2 mirrors checked into both no-cross-import trees; regen via
  sandbox_v2/proto/generate.sh (isolated venv so the protobuf==6.32.0 pin is
  never bumped). Drift guard wired as a manual-stage prek hook that degrades
  gracefully when uv is absent.

- Default codec is protobuf (manager + runtime channel construction);
  JsonCodec is retained registry-free as the test wire for the channel-core
  tests. protobuf added to the client pyproject + the HA manifest
  requirements; grpcio-tools stays out of the project venv by design.

- ~20 handlers converted to typed messages across bridge.py, entry_runner,
  flow_runner, entity_bridge, service/event mirrors, sandbox_bridge and the
  schema bridge; ~69 test call/push sites translated with no assertion
  loosening (semantics shifts forced by proto presence are commented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:25:58 -04:00
Paulus 43eb0ca426 sandbox_v2: lock T2 proto refinements + reaffirm WS out of scope
User direction 2026-06-03 — capture before T2 launches.

Proto schema changes:
- Group fields the way HA organizes them. EntityDescription (wire) gets
  an EntityInfo sub-message (HA's EntityDescription dataclass fields +
  DeviceInfo) and an InitialState sub-message (initial state +
  capabilities + initial attributes). Nested EntityInfo.Description
  avoids the recursive-name clash.
- ServiceResponse is now a typed message (was Struct in the draft); the
  dynamic payload sits inside it as a Struct field. CallServiceResult
  drops the has_response boolean in favor of proto3 `optional`.
- StateChanged gains an optional context_id (was missing entirely).

Context discipline (security):
- parent_id and user_id are NEVER serialized on outbound messages from
  sandbox. The wire carries context_id only.
- Sandbox keeps a local context_id -> Context cache main populates when
  relevant (e.g. main pushing a state-changed for a context the sandbox
  needs).
- Main resolves context_id to its authoritative Context at dispatch.
  If no such Context exists, main mints one attributed to the sandbox's
  system user (no parent_id) and registers it.

WebSocket transport is now flagged COMPLETELY OUT OF SCOPE for this
effort (was "deferred"). T1's Transport Protocol is shape-compatible
with a future WebSocketTransport, but no WS code/deps/auth surface
lands in this batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 06:55:06 -04:00
Paulus 7c77d915d9 sandbox_v2: lock codec-owns-registry decision in plan-transport
T1's STATUS surfaced one design refinement to ratify before T2 coding:
the type -> (request_cls, result_cls) protobuf registry lives on the
codec, not on Channel.register. Ratified 2026-06-03.

The argument (from T1's sub-session): keeping the pairing in the codec
preserves the plan's stated safety property — the concurrency-critical
Channel core stays codec-agnostic. ProtobufCodec(registry) / JsonCodec(registry)
on each side; Channel.register signature unchanged.

For responses to be decodable without per-call state, the proto Frame
envelope carries `type` on response frames too (already a field; just
populate it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 06:42:56 -04:00
Paulus 0d64a7e484 sandbox_v2: STATUS for plan-transport (T1 shipped; T2-T5 handoff)
T1 (Transport/Codec seam + Ready frame) shipped green at 8389f7ad96.
T2 (protobuf wire + typed handlers) is an atomic big-bang — flipping the
default codec to protobuf and switching ~20 handlers to typed messages
forces ~69 wire-call test sites to convert in lockstep, so it cannot land
in safe green increments the way T1 was designed to. Rather than ram a
big-bang through and risk a broken tree (or silently weakened test
assertions during a 69-site rewrite), this STATUS hands off:

* the cleared codegen toolchain gate + verified recipe (isolated venv,
  grpcio-tools 1.80.0, gencode min-runtime 6.31.1 ⊆ pinned 6.32.0)
* the resolved T2 design — including the response-typing solution the
  plan left implicit (carry `type` on response frames) and a refinement
  to keep the request/result class registry in the codec (Channel core
  stays codec-agnostic) for the parent to approve
* the full T2 file/test work breakdown, and the small T3/T5 shapes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:30:34 -04:00
Paulus 8389f7ad96 sandbox_v2: Transport/Codec seam (transport T1)
Split the control channel into three layers so the wire format and the
byte transport can each be swapped without touching the concurrency-
critical dispatch core:

* Channel — dispatch core (pending map, inflight semaphore, register/
  call/push/close); speaks Frame objects, never raw bytes.
* Codec (Protocol) + JsonCodec — Frame <-> bytes. JsonCodec is
  line-compatible with the old wire shape.
* Transport (Protocol) + StreamTransport — whole frame blobs over a
  reader/writer pair using a 4-byte big-endian length prefix (caps frame
  size at 16 MiB and aborts the channel on overflow). Channel.from_transport
  is the drop-in seam for a future WebSocketTransport.

Replaces the stdout text marker (sandbox_v2:ready) with a MSG_READY
*frame* sent as the channel's first message; the manager registers a
handler for it and flips to "running" on arrival, so stdout now carries
nothing but channel frames. Net behavior identical — still JSON, still
stdio — only the framing and handshake changed.

Both channel.py mirrors and protocol.py mirrors updated in lockstep.
Handshake/marker test assertions updated; added coverage for the
from_transport seam via an in-memory queue transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:23:54 -04:00
Paulus a0732f3e09 sandbox_v2: tick whats-changed + docs sweep + STATUS (fidelity batch close)
Tick the 6 batch boxes in whats-changed.md with their commit SHAs. Refresh
current-state docs the 6 changes affect: OVERVIEW (upsert + registry-event
resend, unique_id prefix, vol.Invalid rebuild, real selectors/sections),
README + architecture.html (--group -> --name run snippets). Add the batch
STATUS file. Historical records (STATUS-phase-*, interview, plan-v1-removal,
FOLLOWUPS) left intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:08:42 -04:00
Paulus f66e7e4034 sandbox_v2: blanket ALWAYS_MAIN for ~18 helpers (fidelity appendix / point 1)
Broad readers (template, group, homekit) and source-entity helpers
(min_max, statistics, trend, threshold, derivative, integration,
utility_meter, filter, mold_indicator, bayesian, generic_thermostat,
generic_hygrostat, switch_as_x, history_stats, proximity) read foreign
entities / registries a sandboxed integration can't see under lockdown,
so pin them to main. prometheus/alert are config_flow:false (YAML-only)
and already stay on main, so they're not added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:05:07 -04:00
Paulus 9480436982 sandbox_v2: lossless data_schema reconstruction (fidelity #4)
reconstruct_schema now rebuilds real Selector and data_entry_flow.section
objects instead of collapsing them to a pass-through validator, so when the
flow manager re-serialises main's schema for the frontend it reproduces the
sandbox's original list verbatim (selectors keep their widget). The
serialize-side _has_data_schema fallback now logs the dropped schema's repr
at warning so the lossy path is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:03:31 -04:00
Paulus c5c7e4adcb sandbox_v2: make register_entity an idempotent upsert (fidelity #6)
Client EntityBridge now listens on EVENT_ENTITY_REGISTRY_UPDATED and
EVENT_DEVICE_REGISTRY_UPDATED and re-describes + re-sends MSG_REGISTER_ENTITY
for tracked entities, guarded by a description hash to avoid event storms.
Main's _handle_register_entity updates the existing proxy in place (refreshing
the mirrored _attr_* fields and the DeviceEntry) instead of adding a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:00:31 -04:00
Paulus 3833290b16 sandbox_v2: prefix proxy entity unique_id with source domain (fidelity #5)
Proxies all register under the shared sandbox_v2 platform_name, so the
entity-registry uniqueness key (domain, "sandbox_v2", unique_id) collided
when two integrations in one group reused a unique_id. Namespace the proxy
unique_id as f"{source_domain}:{unique_id}". None unique_ids stay None.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:49:51 -04:00
Paulus fd05b17a25 sandbox_v2: reconstruct vol.Invalid across the bridge (fidelity #7)
Carry a structured error_data field on the error frame for vol.Invalid /
vol.MultipleInvalid so main rebuilds the real exception with its path
intact instead of flattening to TypeError. Falls back to the class-name
mapping when error_data is absent (older/edge frames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:47:30 -04:00
Paulus 969834845b sandbox_v2: rename CLI flag --group to --name (fidelity #2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:43:14 -04:00
Paulus 8bf3abdc3c sandbox_v2: tick whats-changed for strip-auth-scopes + STATUS marker
Tick the RefreshToken.scopes-removed breaking-change checkbox with the
code-commit SHA (5141f96ebe) and add the landing notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:39:25 -04:00
Paulus 5141f96ebe sandbox_v2: strip RefreshToken.scopes from core; sandbox token goes plain
Phase 7's RefreshToken.scopes + websocket-dispatcher enforcement was
built for a sandbox->main websocket that never shipped, so no code path
ever exercised the scope check end-to-end. Revert the whole mechanism
from core HA and keep the sandbox on a plain system-user token.

Phase A (core revert, lockstep):
- auth/models.py: delete the RefreshToken.scopes field.
- auth/__init__.py + auth/auth_store.py: delete the scopes= parameter
  and the on-disk serialize/deserialize of the scopes key. The load
  path now pops a legacy scopes key silently (option A: no migration,
  no storage-version bump) so pre-existing scoped tokens load fine.
- websocket_api/connection.py: delete self.scopes, the _scope_allows
  helper, and the async_handle enforcement branch.

Phase B (sandbox helper):
- sandbox_v2/auth.py: delete SANDBOX_TOKEN_SCOPES; identify the refresh
  token by the one-token-per-system-user invariant instead of matching
  a scope set. System-user token type is unchanged.

Tests:
- Delete tests/components/websocket_api/test_scopes.py.
- Delete the scoped-token round-trip cases from tests/auth/test_init.py.
- Add a regression test that an on-disk token with a legacy scopes key
  loads without error and drops the field.
- Update sandbox_v2 test_auth assertions to the plain-token contract.

Phase C (docs): mark auth-scoping-decision.md SUPERSEDED; drop the
auth row from the core-HA-modified lists in CLAUDE.md / architecture
.html; rewrite the scoped-auth sections in OVERVIEW.md and
architecture.html; add a re-introduce follow-up in FOLLOWUPS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:38:09 -04:00
Paulus 3bf251eb83 sandbox_v2: tick whats-changed for A2 + STATUS marker
A2 landed (commit 4c85363668). Mark the monkey-patch-removed item done
in the batch landing tracker and check in the sub-session's STATUS report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 04:41:03 -04:00
Paulus 4c85363668 sandbox_v2: delete RemoteStore; route writes via contextvar (Phase A2)
Phase A2 of plan-sandbox-context: remove the module-level `Store`
rebinding now that the `current_sandbox` contextvar (A1) is the single
source of truth for sandbox Store IO.

Load-bearing correctness fix (surfaced by A1's STATUS): the contextvar
save branch moves DOWN from `Store.async_save` to
`Store._async_write_data`. `async_delay_save` and the FINAL_WRITE flush
bypass `async_save` entirely — they funnel through
`_async_handle_write_data` -> `_async_write_data`. While `RemoteStore`
existed it overrode `_async_write_data` and masked this; deleting it
would have silently routed delayed/final-write saves to the sandbox
tempdir. Branching at `_async_write_data` covers async_save,
async_delay_save, and FINAL_WRITE uniformly. The redundant `async_save`
branch is removed.

Deletions:
- `hass_client/remote_store.py` (the subclass + installer)
- `hass_client/tests/test_remote_store.py` (covered by the contextvar
  tests + the new delayed-save regression test)
- the `install_remote_store` call/teardown in `SandboxRuntime.run`
- the explicit `data.store` swap in `_load_restore_state` (the
  contextvar reaches the import-captured `Store` reference)

New regression test `test_delayed_save_flushes_through_bridge` asserts
`async_delay_save` + EVENT_HOMEASSISTANT_FINAL_WRITE route through the
bridge. Docs (CLAUDE.md, OVERVIEW.md, FOLLOWUPS.md, architecture.html)
rewritten around the contextvar.

Tests: 190 core (sandbox_v2 + storage + restore_state) + 50 client all
green; prek clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:39:34 -04:00
Paulus 19adbba726 sandbox_v2: plan batch (contextvar / strip-auth-scopes / rename) + decisions
Three new plans queued ahead of fidelity/transport/ephemeral/docker:
- plan-sandbox-context: replace install_remote_store monkey-patch with a
  current_sandbox ContextVar in homeassistant/helpers/, set by the runtime
  before warm-load. Same primitive will later carry cross-sandbox IR/RF
  calls. Refined via phx:plan; Q1/Q2/Q3 locked (defer IR/RF, A1+A2 split,
  docstring+assertion guard).
- plan-strip-auth-scopes: revert Phase 7's RefreshToken.scopes mechanism
  from core HA. No consumer shipped; on-disk scopes key dropped silently
  on load. Re-introduces when the sandbox->main WS transport lands.
- plan-rename-sandbox (last): rename sandbox_v2 -> sandbox once v1 is fully
  gone, including hassfest IGNORE cleanup.

Decisions locked 2026-06-03:
- builtin lockdown: (a) blanket ALWAYS_MAIN for Category A+B helpers.
- ephemeral-sources resolver: (c) generic resolver hook.

STATUS-plan-sandbox-context-A1.md added (sub-session report). The report
surfaced a correctness prerequisite for A2: async_delay_save and the
FINAL_WRITE flush bypass async_save and go through _async_write_data
directly. A2 must therefore move the contextvar save branch down to
_async_write_data before deleting RemoteStore, or delayed saves would
silently land in the sandbox tempdir. The plan's A2 section now spells
this out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 04:22:40 -04:00
Paulus d0bbd34028 sandbox_v2: route Store IO via current_sandbox contextvar (Phase A1)
Add a `current_sandbox` ContextVar in core HA (homeassistant/helpers/
sandbox_context.py) that `Store.async_load/save/remove` read at call
time to route storage IO to main, replacing the module-level
`Store` rebinding done by `install_remote_store`. Reading the
contextvar inside each IO method is a single source of truth
regardless of how `Store` was imported, so it reaches the helpers
that captured the original `Store` at module load (restore_state,
the registries) — which the rebinding never could.

This is the additive half (Phase A1): the contextvar branch is added
alongside the existing `install_remote_store`, both paths active. The
contextvar branch is the first line of each IO method, so it serves
the IO; `RemoteStore` + the `_load_restore_state` workaround stay until
A2 deletes them once A1 bakes on dev.

- helpers/sandbox_context.py: `current_sandbox` ContextVar + the
  `SandboxBridge` Protocol (store methods only; IR/RF deferred).
- helpers/storage.py: `_async_load_data` fetches the wrapped envelope
  via the bridge when the contextvar is set (migration block unchanged
  — design choice B); `async_save`/`async_remove` early-return through
  the bridge.
- hass_client/sandbox_bridge.py: `ChannelSandboxBridge` implementing the
  three store methods over MSG_STORE_LOAD/SAVE/REMOVE (bodies lifted from
  RemoteStore, incl. the orjson preserialise on save).
- hass_client/sandbox.py: build the bridge and `current_sandbox.set`
  before warm-load + handler registration; assert it was unset first
  (Risk #3); reset the token on teardown.
- hass_client/tests/test_sandbox_bridge.py: the five Phase A1 tests plus
  a direct ChannelSandboxBridge wire-mapping test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:20:04 -04:00
Paulus Schoutsen e4e0fbef54 sandbox_v2: add planning docs for next batch
Plans for the post-Phase-20 work: protocol-fidelity batch (CLI rename,
lossless data_schema, entity unique_id prefixing, idempotent register_entity,
vol.Invalid reconstruction), transport/protobuf rewrite, built-in lockdown +
breakage research, stateless sandboxes (push integration source), test
Dockerfile, and a broadcast "what changed" digest. Includes the brainstorm
interview notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:23:50 -04:00
Paulus Schoutsen 4d0c0e7626 sandbox_v2: remove v1 implementation
The numeric compat gate (Phase 17: 99.67% full sweep, 99.97% v1 baseline)
is met. Removing v1 ahead of the "v2 shipped a stable release" condition,
relying on git history for rollback.

Deletes homeassistant/components/sandbox, tests/components/sandbox, and the
top-level sandbox/ dev dir; regenerates config_flows.py (drops the v1
"sandbox" entry); updates current-state v2 docs (historical STATUS-phase-*
records left intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:16:44 -04:00
Paulus Schoutsen 317afd9739 sandbox_v2: drop unwired share_* surface + design doc (Phase 20)
Phase 7 introduced `SharingConfig` (`share_states`,
`share_entity_registry`, `share_areas`) on the runtime + the matching
`SandboxGroupConfig` on the manager + `--share-*` CLI flags +
`DEFAULT_GROUP_CONFIGS` defaults, intended for a future subscription
consumer that observes main's state stream. The consumer never landed.
~40 LOC of dead surface across five files plus an entire test module
(`test_sharing_config.py`, 7 tests). Carrying unwired flags risks
readers assuming functionality that isn't there — Phase 16's failure
categoriser had to specifically call this out.

Removed:
- `SharingConfig` + `sharing=` constructor param + `__all__` entry
  (`sandbox_v2/hass_client/hass_client/sandbox.py`).
- `--share-states`/`--share-entity-registry`/`--share-areas` argparser
  entries (`__main__.py`).
- `SandboxGroupConfig`, `DEFAULT_GROUP_CONFIGS`, `group_config()`
  accessor, and `--share-*` argv expansion in `_default_command`
  (`homeassistant/components/sandbox_v2/manager.py`).
- `sharing=` parameter on the in-process plugin.
- `test_sharing_config.py` (whole file).
- `test_manager.py` group_config tests.
- Sharing assertions in `test_sandbox_runtime.py`.

Replaced with `sandbox_v2/docs/design-share-states.md` — the contract
for the future consumer: goal, entity_id alignment constraint
(sandbox-side automations referencing `light.kitchen` must see main's
actual entity_id, not whatever the sandbox's local EntityRegistry
would have generated), `share/subscribe_*` mechanism sketch, per-
sandbox allow-list filtering on main, and the open questions
(direction, read-only semantics, device/area mirroring as P19
follow-on, fan-out perf).

`OVERVIEW.md`, `CLAUDE.md`, `docs/FOLLOWUPS.md`, and
`generate_backlog.py`'s `dependencies-not-shared` description all
repoint at the new design doc.

No core HA files touched. 140 + 47 tests passing (hass_client drops
the 7 sharing-config tests; HA-side drops 2 group_config tests).

plan.md updated with Phase 18/19/20 phase blocks +  ticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:20:27 -04:00
Paulus Schoutsen 7270a52be7 sandbox_v2: bridge device_info → main's device_registry (Phase 19)
Sandboxed entities that carry `device_info` now produce matching
`DeviceEntry` rows in main's `device_registry`, linked to the
sandboxed `config_entry_id`. Area assignment now propagates through
HA's standard device → entity inheritance path (Phase 5's entity
bridge alone left the entity registered without a device_id, so the
device_registry was empty for sandboxed integrations).

Sandbox side (`hass_client/entity_bridge.py`):
- `_serialise_device_info` flattens `DeviceInfo`'s TypedDict shapes
  into JSON-safe lists/strings (identifiers/connections as lists of
  two-element lists, via_device as list, entry_type as `StrEnum.value`,
  configuration_url as string).
- `_describe_entity` appends a `device_info` key to the wire payload
  when the entity exposes one.

Main side (`homeassistant/components/sandbox_v2/`):
- `SandboxEntityDescription` gains `device_info` / `device_id` fields.
- `from_payload` runs `_deserialise_device_info` to rebuild typed shapes.
- `_handle_register_entity` pre-creates the `DeviceEntry` via
  `dr.async_get_or_create(config_entry_id=description.entry_id,
  **device_info)`, pins the returned `device.id` on the description.
- Proxy base sets `_attr_device_info` so `EntityPlatform.async_add_entities`
  reuses the same `DeviceEntry` (idempotent on identifiers/connections)
  and wires `entity.device_entry`. No per-domain proxy edit needed —
  all 32 inherit from the base.

No new core HA changes (`device_registry.async_get_or_create` is
already public).

Tests:
- `tests/components/sandbox_v2/test_phase19_devices.py` — six end-to-
  end cases (DeviceEntry creation + entry-id linkage, proxy device_id
  propagation, backwards-compat with payloads omitting device_info,
  area assignment surfacing, invalid device_info rejection, payload
  round-trip).
- `sandbox_v2/hass_client/tests/test_entity_bridge.py` — three new
  cases.

140 + 54 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:19:36 -04:00
Paulus Schoutsen 39dc4c912f sandbox_v2 docs: note cross-sandbox in-process dependency follow-up
ESPHome serial / BLE proxy (and Broadlink-style IR/RF) are coupled
in-process today: setup-time enumeration + send-calls happen via
Python calls/events the bridge doesn't cross. Pure-built-in pairs are
fine (same `built-in` sandbox group); a built-in producer paired with
a custom-integration consumer would split across `built-in`/`custom`
and break.

Captured the constraint + two fix shapes (classifier "co-locate with
X" hint vs extending Phase 6's event mirror beyond `<owned_domain>_*`)
in the three places that track open follow-ups:

- `sandbox_v2/CLAUDE.md` — Open follow-ups
- `sandbox_v2/docs/FOLLOWUPS.md` — Still open
- `sandbox_v2/OVERVIEW.md` — Where the design is still open

IR/RF is the simpler case (one-way command flow, no bidirectional
stream or enumeration) but still needs dedicated cross-sandbox routing
to land the consumer's send-call on the producer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:18:04 -04:00
Paulus Schoutsen b28e6502a3 tests: sandbox_v2 integration tests
Tests for the HA-core side of sandbox_v2 (the client-side
hass_client/tests/ shipped with the previous commit).

134 tests across:
- test_classifier.py — manifest-based routing rules.
- test_router.py — flow create / setup / unload intercepts.
- test_manager.py — subprocess lifecycle + crash/restart + token factory.
- test_proxy_flow.py — `SandboxFlowProxy` + flow marshalling.
- test_channel.py — concurrent channel dispatcher + close semantics.
- test_bridge.py — entity / service / event mirror handlers on main.
- test_phase4_subprocess.py — real-subprocess flow handshake.
- test_phase9_shutdown.py — graceful shutdown + restore_state hand-off.
- test_phase13_proxies.py — parametrised smoke per supported entity domain.
- test_phase14.py — flow schema bridge + unique_id propagation +
  async_unload core hook + perf benchmark.
- test_store.py — `_SandboxStoreServer` path scoping + key validation.
- test_init.py — `SandboxV2Data` shape + integration wiring.
- test_auth.py — sandbox-scoped access token issuance.
- test_testing_plugins.py — in-process + subprocess pytest plugins +
  autotag fixture.
- test_spike.py — Phase 1 entity-bridge spike (Option A vs B).
- test_perf.py — 200-light area-call batching benchmark.
- _helpers.py — shared `make_channel_pair` test helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:55 -04:00
Paulus Schoutsen e3aafaedb1 Add sandbox_v2 client library, docs, and compat sweep tooling
The client-library side of sandbox v2, plus the full architecture +
phase-by-phase narrative + per-failure compat tooling.

`sandbox_v2/hass_client/` is a separate uv-managed Python package that
the HA-core sandbox_v2 integration spawns as a subprocess per sandbox
group. It hosts a private `HomeAssistant`, drives each sandboxed
integration's `ConfigFlow` and `async_setup_entry`, mirrors entity /
service / event registrations back to main over a stdio JSON-line
`Channel`, and routes Store reads/writes through main via `RemoteStore`.

`sandbox_v2/docs/`:
- `entity-bridge-decision.md` — Phase 1 spike: why Option B
  (action-call forwarding via `sandbox_v2/call_service`).
- `auth-scoping-decision.md` — Phase 7: why `RefreshToken.scopes` is
  a generic primitive (vs a sandbox-private subclass).
- `FOLLOWUPS.md` — narrative of Phases 12–17 (concurrent dispatcher,
  28-domain proxy fill-in, flow-schema bridge, baseline compat sweep,
  cross-integration BACKLOG generation, `ConfigEntry.sandbox` field).

Compat sweep tooling:
- `run_compat.py` — Phase 15: v1's 37-integration baseline runner;
  output to `COMPAT.md` (curated) + `COMPAT.csv`.
- `run_compat_full.py` — Phase 16: 807-integration cross-sweep at
  asyncio concurrency=6 (~12 min wall); output to `COMPAT_FULL.md`
  + `COMPAT_FULL.csv`.
- `categorize_failures.py` — regex-rule failure categoriser feeding
  `BACKLOG.md` + `BACKLOG_FAILURES.json`.
- `generate_backlog.py` — auto-draft skeleton for BACKLOG.md.

Headline result (after Phase 17): 99.67% test-level pass rate across
807 integrations; baseline 99.97%. Both clear the 99.5% v1-removal
threshold.

`sandbox_v2/STATUS-phase-{3..18}.md` are the authoritative landing
notes for each phase — every "Things to flag" surfaced is in there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:36 -04:00
Paulus Schoutsen 9f32319481 Add sandbox_v2 integration (HA-core side)
The HA-core side of the sandbox v2 rewrite: routing, lifecycle, flow
forwarding, entity bridging, service/event mirroring, scoped auth,
opt-in data sharing, Store routing, graceful shutdown.

Lives at `homeassistant/components/sandbox_v2/`. Designed alongside the
client library at `sandbox_v2/`; see `sandbox_v2/OVERVIEW.md` for the
full architecture and `sandbox_v2/docs/FOLLOWUPS.md` for the phase-by-
phase narrative.

Built on the core hooks added in the preceding commits:
`ConfigEntries.router` + `ConfigEntry.sandbox` + `RefreshToken.scopes`
+ `EntityComponent.async_register_remote_platform`.

32 domain proxy classes under `entity/` cover every entity domain v2
supports. Bridge translates each proxy method into a
`sandbox_v2/call_service` RPC via a per-loop-tick batcher (coalesces
multi-entity area calls into single RPCs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:41:48 -04:00
Paulus Schoutsen ddd9c5ab61 hassfest: tolerate sandbox v1 errors; add sandbox_v2 to NO_QUALITY_SCALE
Adds an `IGNORE_INTEGRATIONS_WITH_ERRORS` set to hassfest's main loop
so v1 sandbox's pre-existing hassfest gates (CONFIG_SCHEMA, manifest
version, missing services.yaml, mypy signature drift in entity proxies)
don't block validation of the rest of the tree. v1 is being superseded
by sandbox_v2 (see `sandbox_v2/OVERVIEW.md`) — accepting v1's existing
state for now is preferable to either fixing every gate in code that
will be removed, or skipping hooks.

Also adds `sandbox_v2` to `NO_QUALITY_SCALE` (internal integration)
and ships an empty `sandbox_v2/services.yaml` placeholder — `bridge.py`
calls `hass.services.async_register` dynamically per sandboxed
integration; those services are owned by the sandboxed integrations.

`homeassistant/generated/config_flows.py` is regenerated to include
`sandbox` (v1 had drifted out of the registry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:41:18 -04:00
Paulus Schoutsen 4936885598 config_entries + entity_component: hooks for runtime-routed integrations
Three small additive surfaces that the sandbox_v2 integration plugs
into. Each is additive and a no-op when nothing registers against it.

config_entries.py:
- `ConfigEntries.router: ConfigEntryRouter | None` attribute + the
  `ConfigEntryRouter` Protocol. Consulted from three sites:
  `ConfigEntriesFlowManager.async_create_flow`, `ConfigEntries.async_setup`,
  and `ConfigEntries.async_unload`. Returning `None` falls through to
  the existing path.
- `ConfigEntry.sandbox: str | None` optional field. Carries the routing
  tag without polluting `entry.data`. Persisted via `as_dict` /
  `as_storage_fragment` only when non-None; read via `dict.get` so
  pre-existing stored entries load with `sandbox=None`. Mutable via
  `ConfigEntries.async_update_entry(entry, sandbox=)`. `ConfigFlowResult`
  gains a `sandbox` TypedDict key the framework reads at entry
  construction (same plumbing shape as `minor_version` / `options` /
  `subentries`).

entity_component.py:
- `EntityComponent.async_register_remote_platform(config_entry, platform)`
  lets sandbox_v2 attach a pre-built remote `EntityPlatform` without
  re-discovering the local integration. Mirrors `async_setup_entry`'s
  `_platforms[entry_id] = platform` assignment as a public hook.

Tests:
- `MockConfigEntry` picks up a `sandbox=` kwarg threaded through to
  `ConfigEntry.__init__`.
- Six new `test_config_entries.py` cases for the `sandbox` field:
  default-none + omitted-from-storage, persisted-when-set, round-trip,
  absent-from-storage-loads-as-none, async_update_entry-sets-sandbox,
  cannot-be-set-directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:25:54 -04:00
Paulus Schoutsen 67fff835b2 auth: optional scopes on RefreshToken + dispatcher enforcement
Adds an optional `scopes: frozenset[str] | None` attribute to
`RefreshToken` and threads it through `AuthManager.async_create_refresh_token`
and `AuthStore` (sorted list on disk, optional on read — no version bump).

`ActiveConnection` reads scopes off the connecting token and a new
`_scope_allows` helper in the websocket dispatcher rejects out-of-scope
commands with `ERR_UNAUTHORIZED`. Existing unscoped tokens (`scopes is
None`) are unaffected — the gate is a no-op for them.

This is the primitive the sandbox_v2 integration uses to issue
namespace-scoped tokens (`{"sandbox_v2/", "auth/current_user"}`) to
sandbox subprocesses, so a sandbox-resident integration cannot escalate
to the rest of the websocket API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:25:31 -04:00
Paulus Schoutsen 7b19a3a71b Update SANDBOX_COMPAT for newly-installable deps
After 'uv pip install -r requirements_ha.txt' (which pulls in
requirements_all.txt), the integrations previously listed as
'Not Tested (missing dependencies)' import and run:

  - rest: 10/10 pass        (needed xmltodict)
  - logbook: 55/55 pass      (needed sqlalchemy + numpy + turbojpeg)
  - command_line: 7/7 pass
  - trend: 9/9 pass

Promote them into the main pass table; the totals now read 35 of 37
fully pass, 955/957 tests (99.8%).

conversation imports too (hassil was already in pyproject.toml deps
but the report listed it as missing) but 8 of 21 tests fail and the
run deadlocks at tests 20-21 — moved into a new 'Newly runnable, still
investigating' section instead of the pass table.

Add a Setup section pointing at requirements_ha.txt and the pyitachip2ir
macOS caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:12:21 -04:00
Paulus Schoutsen 7994744bea Add requirements_ha.txt to pull in HA Core integration deps
The sandbox client's pyproject.toml only carries the minimal set of
packages needed to run the client library and its own tests. Running
HA Core's per-integration test suites through the sandbox plugin needs
the full integration dependency tree (hassil for conversation,
xmltodict for rest, sqlalchemy+numpy+turbojpeg for logbook, …).

requirements_ha.txt pulls in ../../requirements_all.txt and
../../requirements_test.txt with paths relative to the file, so it
keeps working from any cwd. Comment notes the macOS pyitachip2ir
build caveat and the workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:44:30 -04:00
Paulus Schoutsen e9e5bda3f6 Drop .sh from doc references to the test runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:31:30 -04:00
Paulus Schoutsen 3d807de32d Remove obsolete run_all_sandbox_tests.sh
The shell version required a manually-prepared
/tmp/all_integrations.txt and used a perl-based timeout shim.
run_all_sandbox_tests.py auto-discovers integrations from the core
tests directory and uses subprocess timeouts, so the .sh is no longer
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:45 -04:00
Paulus Schoutsen fa60ef5477 Consolidate sandbox docs: fold ARCHITECTURE.md into OVERVIEW.md
architecture.html already covers system diagrams, flow diagrams, file
structure, websocket API, key classes, and test results, so the prose
deep-dive in ARCHITECTURE.md was largely overlapping. Keep the bits
that weren't already in OVERVIEW.md and drop the rest:

- Startup sequence (host startup, sandbox process startup, host/sandbox
  entity platform setup) as a new section after High-Level Flow.
- The RemoteLightEntity worked example plus the static/dynamic property
  caching rationale, inside Entity Platform Architecture.
- Entity Method Compatibility (which domains already expose async
  wrappers; the cover.toggle gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:42:18 -04:00
Paulus Schoutsen 3046996869 Add sandbox/README.md as the directory's overview
Pointers to OVERVIEW.md, ARCHITECTURE.md, architecture.html, the
test driver scripts, and SANDBOX_COMPAT.md; quick-start for running
the sandbox client and the core test suites through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:59:46 -04:00
Paulus Schoutsen 9930d7dad4 Consolidate sandbox docs and test drivers under core/sandbox/
Move ARCHITECTURE.md, OVERVIEW.md, CLAUDE.md, the architecture HTML,
the test-runner scripts and TEST_RESULTS.csv into this directory next
to the hass_client subtree, so the entire sandbox project lives on the
sandbox branch of core (only the HA integration at
homeassistant/components/sandbox/ stays put for HA's loader).

Adjust the relative paths the moved files used to point at the old
sibling checkouts:
- hass_client/pyproject.toml: uv source homeassistant -> ../..
- run_all_sandbox_tests.{py,sh}: cd into ./hass_client and walk to
  ../../tests/components/ for the core test suites
- analyze_failures.py: write TEST_RESULTS.csv next to the script

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:34:54 -04:00
Paulus Schoutsen e18dd7e906 Add 'sandbox/hass_client/' from commit '8f1a294efecab03343748950da428bd18d92fffe'
git-subtree-dir: sandbox/hass_client
git-subtree-mainline: d12fb7814a
git-subtree-split: 8f1a294efe
2026-05-23 11:32:40 -04:00
Paulus Schoutsen d12fb7814a Replace subscribe_service_calls with explicit register/call/result API
Restructure the sandbox websocket API around three commands instead of
a single event subscription: sandbox/register_service registers a
proxy service on the host that forwards calls into the sandbox,
sandbox/call_service lets the sandbox invoke a host service while
preserving its context, and sandbox/service_call_result returns the
sandbox's response back to the originating host caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen 8e6be68fe3 Remove per-domain platform setup files
These 32 files (light.py, sensor.py, etc.) each only registered an
async_add_entities callback. Now that RemoteHostEntityPlatform adds
proxy entities directly to the EntityComponent, they are dead code.

Also removes the unused register_platform_callback and
AddEntitiesCallback from SandboxEntityManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen c1a71bed25 Add RemoteHostEntityPlatform for sandbox entities
Replace the async_forward_entry_setups + per-domain platform file
approach with RemoteHostEntityPlatform. This EntityPlatform subclass
is added directly to the domain's EntityComponent and manages proxy
entities without needing 32 identical platform files.

The platform is created on-demand when the first entity for a domain
is registered by the sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen ee82ca9677 Support sandbox grouping by string option value
Config entries can now set options["sandbox"] = "group_name" to be
assigned to a named sandbox group. Entries sharing the same group
string run in the same sandbox process. The sandbox config entry
discovers group members via entry.data["group"].

The explicit entries list (entry.data["entries"]) still works for
test infrastructure compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen b51067d37d Refactor sandbox entity proxies into entity/ package
Split the monolithic entity.py (1900 lines) into a per-platform
package structure under entity/. Each domain gets its own file,
making the codebase easier to navigate and extend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen 12f24ac6bf Add device_tracker and todo proxy entity support
Brings total supported platforms to 32. Device tracker supports
both TrackerEntity (GPS) and ScannerEntity (router/BLE).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 6b92011cae Add proxy entity support for 24 additional HA platforms
Implements sandbox proxy entities for: alarm_control_panel, button,
calendar, climate, cover, date, datetime, fan, humidifier, lawn_mower,
lock, media_player, notify, number, remote, select, siren, text, time,
update, vacuum, valve, water_heater, weather.

Total supported platforms: 30 (up from 6).

Each proxy class caches state from sandbox pushes and forwards service
calls back to the sandbox via the existing websocket command channel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen c88253752f Add proxy entity support for all Hue platforms
Adds SandboxBinarySensorEntity, SandboxSensorEntity, SandboxSwitchEntity,
SandboxSceneEntity, and SandboxEventEntity proxy classes. Also adds
device_class and state_class to entity registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 4f43b99540 Add sandbox integration with entity proxy architecture
Implements the sandbox integration that manages config entries running
in isolated processes. Proxy entities on the host forward service calls
to sandbox processes via websocket and cache state pushed back.

Supports entity, device, and area targeting for service calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 8f1a294efe Extract HybridServiceRegistry and improve sandbox error translation
Move HybridServiceRegistry out of runtime.py into its own
sandbox_service_registry.py module, expand the websocket API error
translator to handle ServiceNotSupported and sandbox/call_service, and
extend conftest_sandbox with additional fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:31:00 -04:00
Paulus Schoutsen f07d650de8 Remove per-domain platform setup files
These 32 files (light.py, sensor.py, etc.) each only registered an
async_add_entities callback. Now that RemoteHostEntityPlatform adds
proxy entities directly to the EntityComponent, they are dead code.

Also removes the unused register_platform_callback from
SandboxEntityManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 09:30:59 -04:00
Paulus Schoutsen f494fa2909 Add RemoteClientEntityPlatform for sandbox entity interception
New class that wraps an EntityPlatform on the sandbox side to intercept
async_add_entities calls. When an integration adds entities, they are:
1. Added locally as normal
2. Registered with the host via sandbox/register_entity
3. State changes forwarded to the host
4. Method calls from the host dispatched to local entities

This replaces the post-setup iteration approach in SandboxEntityBridge
with a clean intercept at the async_add_entities boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 09:29:40 -04:00
Paulus Schoutsen b81a221c20 Add Hue and Picnic as tested config-entry integrations
Both pass fully through the real sandbox websocket:
- Philips Hue: 112 tests (lights, sensors, switches, scenes, device
  triggers, services, config flow, diagnostics)
- Picnic: 40 tests (sensors, services, todo)

Validates that the full config entry path works: async_setup_entry,
entity platforms, device registry, mocked HTTP APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 21:02:12 -04:00
Paulus Schoutsen f852c33cf8 Fix host HA teardown and service fallback, expand to 33 integrations
Three fixes:
- Stop host HA explicitly after tests to cancel lingering timers that
  caused verify_cleanup teardown errors (scene, todo, etc.)
- Guard HybridServiceRegistry remote fallback: only try remote for
  services that exist in the remote cache, preventing wrong
  ServiceNotFound errors in nested service calls
- Remove manual INSTANCES.remove; let async_stop handle cleanup

31 of 33 integrations fully pass (878/880 tests, 99.8%).
The 2 remaining failures are pre-existing logbook platform issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 18:18:04 -04:00
Paulus Schoutsen 7b60f912a7 Fix schedule test hangs by detecting freezer fixture and falling back
Tests using pytest-freezer's `freezer.move_to()` hang when a live
websocket is active because time jumps break async heartbeat timers.
Detect the freezer fixture in pytest_runtest_setup and fall back to
the base plugin (no websocket) for those tests.

All 9 input helper integrations now pass (189/189 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 18:05:35 -04:00
Paulus Schoutsen da978415a8 Add sandbox test infrastructure for running core tests through websocket
New pytest plugin (hass_client.testing.conftest_sandbox) that boots a host
HA Core with websocket_api + sandbox integration, creates a sandbox auth
token, and connects a RemoteHomeAssistant to it via a live websocket. This
allows running the full HA Core input_boolean test suite (16/16 tests)
through a real sandbox round-trip.

Key pieces:
- conftest_sandbox.py: pytest plugin that patches async_test_home_assistant
  to create host + sandbox HA instances with real TCP websocket
- conftest.py: adds core/tests to sys.path for test infrastructure imports
- pyproject.toml: point homeassistant dep at local core checkout, add test deps

Usage: pytest -p hass_client.testing.conftest_sandbox \
              ../core/tests/components/input_boolean/test_init.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:54 -04:00
Paulus Schoutsen 64750386cb Add sandbox client and end-to-end tests
SandboxClient connects to HA Core via a sandbox token, fetches assigned
config entries, sets up input helper integrations locally, registers
entities back to the host, pushes state changes, and subscribes to
service call forwarding.

Three e2e tests validate: token/instance creation, state updates, and
unload cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:42 -04:00
Paulus Schoutsen 0c45d006f7 Add sandbox websocket API methods and fix RemoteHomeAssistant.__new__
Add sandbox API methods to HomeAssistantAPI for communicating with HA Core's
sandbox integration: get_entries, update_entry, register/update/remove device,
register/update/remove entity, update_state, and subscribe_service_calls.

Override __new__ on RemoteHomeAssistant to accept extra keyword arguments,
since HomeAssistant.__new__ has a strict (config_dir: str) signature that
rejects the remote_config kwarg in Python 3.14.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:34 -04:00
Paulus Schoutsen cd81c61509 WIP 2026-04-01 09:51:35 -04:00
Paulus Schoutsen 81bca02aed Expand core and helper test compatibility 2026-03-18 12:52:17 +09:00
Paulus Schoutsen cc2428c2b5 Initial hass-client compatibility harness 2026-03-18 11:56:47 +09:00
1659 changed files with 47824 additions and 51450 deletions
+1 -1
View File
@@ -22,4 +22,4 @@ requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true merge=ours
.github/workflows/*.lock.yml linguist-generated=true
+1 -3
View File
@@ -6,7 +6,6 @@
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
# GitHub Copilot & Claude Code Instructions
@@ -51,5 +50,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
-1
View File
@@ -14,4 +14,3 @@ updates:
ignore:
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
- dependency-name: "github/gh-aw-actions/**"
- dependency-name: "github/gh-aw-actions"
+8 -8
View File
@@ -38,7 +38,7 @@ jobs:
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -193,7 +193,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -245,7 +245,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -264,7 +264,7 @@ jobs:
fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -292,7 +292,7 @@ jobs:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -471,7 +471,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -518,7 +518,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -50,24 +50,19 @@ jobs:
check-latest: true
- name: Install script dependencies
run: pip install -r script/check_requirements/requirements.txt
- name: Collect PR diff and head SHA
id: pr
- name: Collect PR diff
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
mkdir -p deterministic
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
HEAD_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')
echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}"
- name: Run deterministic checks
env:
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
python -m script.check_requirements \
--pr-number "${PR_NUMBER}" \
--head-sha "${HEAD_SHA}" \
--diff deterministic/pr.diff \
--output deterministic/results.json
- name: Upload deterministic-results artifact
+89 -317
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
# ___ _ _
# / _ \ | | (_)
# | |_| | __ _ ___ _ __ | |_ _ ___
@@ -14,7 +14,7 @@
# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
#
# This file was automatically generated by gh-aw (v0.79.6). DO NOT EDIT.
# This file was automatically generated by gh-aw (v0.74.4). DO NOT EDIT.
#
# To update this file, edit the corresponding .md file and run:
# gh aw compile
@@ -31,19 +31,20 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@v0.79.6
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6
# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4
# - ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
# - ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa
# - ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46
# - ghcr.io/github/gh-aw-firewall/squid:0.25.46
# - ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388
# - ghcr.io/github/github-mcp-server:v1.0.4
# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f
name: "Check requirements (AW)"
on:
@@ -58,13 +59,15 @@ permissions: {}
concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
run-name: "Check requirements (AW)"
jobs:
activation:
needs: pre_activation
needs:
- extract_pr_number
- pre_activation
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
if: >
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
@@ -73,14 +76,9 @@ jobs:
permissions:
actions: read
contents: read
env:
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
outputs:
comment_id: ""
comment_repo: ""
daily_effective_workflow_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_exceeded == 'true' }}
daily_effective_workflow_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_threshold || '' }}
daily_effective_workflow_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_total_effective_tokens || '' }}
engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
@@ -92,35 +90,33 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Generate agentic run info
id: generate_aw_info
env:
GH_AW_INFO_ENGINE_ID: "copilot"
GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AGENT_VERSION: "1.0.60"
GH_AW_INFO_CLI_VERSION: "v0.79.6"
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_AGENT_VERSION: "1.0.48"
GH_AW_INFO_CLI_VERSION: "v0.74.4"
GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_INFO_EXPERIMENTAL: "false"
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["python"]'
GH_AW_INFO_FIREWALL_ENABLED: "true"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_AWF_VERSION: "v0.25.46"
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
@@ -131,37 +127,18 @@ jobs:
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
- name: Check daily workflow token guardrail
id: daily-effective-workflow-guardrail
if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_ID: "check-requirements"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
await main();
- name: Validate COPILOT_GITHUB_TOKEN secret
id: validate-secret
run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: |
.github
.agents
.antigravity
.claude
.codex
.crush
@@ -172,8 +149,8 @@ jobs:
fetch-depth: 1
- name: Save agent config folders for base branch restoration
env:
GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi"
GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
# poutine:ignore untrusted_checkout_exec
run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- name: Check workflow lock file
@@ -191,7 +168,7 @@ jobs:
- name: Check compile-agentic version
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_COMPILED_VERSION: "v0.79.6"
GH_AW_COMPILED_VERSION: "v0.74.4"
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -214,20 +191,20 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<system>
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<safe-output-tools>
Tools: add_comment, missing_tool, missing_data, noop
</safe-output-tools>
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
<github-context>
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -256,12 +233,12 @@ jobs:
{{/if}}
</github-context>
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF'
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
</system>
{{#runtime-import .github/workflows/check-requirements.md}}
GH_AW_PROMPT_781cf5b2f30d6d93_EOF
GH_AW_PROMPT_198418d99edc7d5b_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -329,15 +306,12 @@ jobs:
include-hidden-files: true
path: |
/tmp/gh-aw/aw_info.json
/tmp/gh-aw/model_multipliers.json
/tmp/gh-aw/models.json
/tmp/gh-aw/aw-prompts/prompt.txt
/tmp/gh-aw/aw-prompts/prompt-template.txt
/tmp/gh-aw/aw-prompts/prompt-import-tree.json
/tmp/gh-aw/github_rate_limits.jsonl
/tmp/gh-aw/base
/tmp/gh-aw/.github/agents
/tmp/gh-aw/.github/skills
if-no-files-found: ignore
retention-days: 1
@@ -345,16 +319,14 @@ jobs:
needs:
- activation
- extract_pr_number
- gate
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: read
pull-requests: read
concurrency:
group: "gh-aw-copilot-${{ github.workflow }}"
queue: max
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_AW_ASSETS_ALLOWED_EXTS: ""
@@ -363,27 +335,24 @@ jobs:
GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements
outputs:
agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
agentic_engine_timeout: ${{ steps.detect-copilot-errors.outputs.agentic_engine_timeout || 'false' }}
checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }}
has_patch: ${{ steps.collect_output.outputs.has_patch }}
inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
inference_access_error: ${{ steps.detect-copilot-errors.outputs.inference_access_error || 'false' }}
mcp_policy_error: ${{ steps.detect-copilot-errors.outputs.mcp_policy_error || 'false' }}
model: ${{ needs.activation.outputs.model }}
model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
model_not_supported_error: ${{ steps.detect-copilot-errors.outputs.model_not_supported_error || 'false' }}
output: ${{ steps.collect_output.outputs.output }}
output_types: ${{ steps.collect_output.outputs.output_types }}
setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
setup-span-id: ${{ steps.setup.outputs.span-id }}
setup-trace-id: ${{ steps.setup.outputs.trace-id }}
unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -392,8 +361,7 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Set runtime paths
id: set-runtime-paths
@@ -404,7 +372,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Create gh-aw temp directory
@@ -438,7 +406,7 @@ jobs:
- name: Checkout PR branch
id: checkout-pr
if: |
github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
github.event.pull_request || github.event.issue.pull_request
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -450,11 +418,11 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
await main();
- name: Install GitHub Copilot CLI
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48
env:
GH_HOST: github.com
- name: Install AWF binary
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
- name: Parse integrity filter lists
id: parse-guard-vars
env:
@@ -470,28 +438,24 @@ jobs:
- name: Restore agent config folders from base branch
if: steps.checkout-pr.outcome == 'success'
env:
GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi"
GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
- name: Restore inline sub-agents from activation artifact
env:
GH_AW_SUB_AGENT_DIR: ".github/agents"
GH_AW_SUB_AGENT_EXT: ".agent.md"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh"
- name: Restore inline skills from activation artifact
env:
GH_AW_SKILL_DIR: ".github/skills"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- name: Download container images
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46 ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388 ghcr.io/github/github-mcp-server:v1.0.4 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f
- name: Generate Safe Outputs Config
run: |
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF'
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -679,21 +643,21 @@ jobs:
* ) DOCKER_SOCK_PATH=/var/run/docker.sock ;;
esac
DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.25'
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.9'
mkdir -p /home/runner/.copilot
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
cat << GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
"type": "stdio",
"container": "ghcr.io/github/github-mcp-server:v1.1.2",
"container": "ghcr.io/github/github-mcp-server:v1.0.4",
"env": {
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
"GITHUB_READ_ONLY": "1",
"GITHUB_TOOLSETS": "repos,pull_requests"
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
},
"guard-policies": {
"allow-only": {
@@ -727,7 +691,7 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -756,48 +720,29 @@ jobs:
run: |
set -o pipefail
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
trap 'rm -f /home/runner/.copilot/settings.json' EXIT
mkdir -p /home/runner/.copilot
printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
touch /tmp/gh-aw/agent-step-summary.md
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
export GH_AW_NODE_BIN
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}"
printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.pythonhosted.org\",\"anaconda.org\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"binstar.org\",\"bootstrap.pypa.io\",\"conda.anaconda.org\",\"conda.binstar.org\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"pip.pypa.io\",\"pypi.org\",\"pypi.python.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"repo.anaconda.com\",\"repo.continuum.io\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["*.pythonhosted.org","anaconda.org","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","binstar.org","bootstrap.pypa.io","conda.anaconda.org","conda.binstar.org","files.pythonhosted.org","github.com","host.docker.internal","pip.pypa.io","pypi.org","pypi.python.org","raw.githubusercontent.com","registry.npmjs.org","repo.anaconda.com","repo.continuum.io","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"auto":["large"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
fi
GH_AW_TOOL_CACHE_MOUNT=""
GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
if [ -d "$GH_AW_TOOL_CACHE" ]; then
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
fi
elif [ -d "/home/runner/work/_tool" ]; then
GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
fi
# shellcheck disable=SC1003
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
-- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
-- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
AWF_REFLECT_ENABLED: 1
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
GH_AW_PHASE: agent
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
GH_AW_TIMEOUT_MINUTES: 20
GH_AW_VERSION: v0.79.6
GH_AW_VERSION: v0.74.4
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
@@ -811,13 +756,12 @@ jobs:
GIT_AUTHOR_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
RUNNER_TEMP: ${{ runner.temp }}
XDG_CONFIG_HOME: /home/runner
- name: Detect agent errors
- name: Detect Copilot errors
id: detect-copilot-errors
if: always()
id: detect-agent-errors
continue-on-error: true
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
run: node "${RUNNER_TEMP}/gh-aw/actions/detect_copilot_errors.cjs"
- name: Configure Git credentials
env:
REPO_NAME: ${{ github.repository }}
@@ -995,11 +939,10 @@ jobs:
- agent
- detection
- extract_pr_number
- gate
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_effective_workflow_exceeded == 'true')
needs.activation.outputs.stale_lock_file_failed == 'true')
runs-on: ubuntu-slim
permissions:
contents: read
@@ -1018,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1027,8 +970,7 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1044,40 +986,6 @@ jobs:
mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Collect usage artifact files
if: always()
continue-on-error: true
run: |
mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
echo "Usage artifact source file status:"
for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
[ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
done
[ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
[ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
[ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
[ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
[ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
[ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
[ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
[ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
[ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
find /tmp/gh-aw/usage -type f -print | sort
- name: Upload usage artifact
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: usage
path: |
/tmp/gh-aw/usage/aw-info.jsonl
/tmp/gh-aw/usage/agent_usage.jsonl
/tmp/gh-aw/usage/detection_usage.jsonl
/tmp/gh-aw/usage/agent/token_usage.jsonl
/tmp/gh-aw/usage/detection/token_usage.jsonl
if-no-files-found: ignore
- name: Process no-op messages
id: noop
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -1085,14 +993,9 @@ jobs:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_NOOP_MAX: "1"
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
GH_AW_WORKFLOW_ID: "check-requirements"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
@@ -1106,7 +1009,6 @@ jobs:
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
@@ -1124,7 +1026,6 @@ jobs:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
@@ -1139,7 +1040,6 @@ jobs:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
@@ -1154,7 +1054,6 @@ jobs:
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_WORKFLOW_ID: "check-requirements"
@@ -1163,11 +1062,7 @@ jobs:
GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }}
GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
@@ -1175,14 +1070,12 @@ jobs:
GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED: ${{ needs.activation.outputs.daily_effective_workflow_exceeded }}
GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_effective_workflow_total_effective_tokens }}
GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD: ${{ needs.activation.outputs.daily_effective_workflow_threshold }}
GH_AW_GROUP_REPORTS: "false"
GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
GH_AW_TIMEOUT_MINUTES: "20"
GH_AW_MAX_EFFECTIVE_TOKENS: "25000000"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
@@ -1201,14 +1094,13 @@ jobs:
permissions:
contents: read
outputs:
aic: ${{ steps.parse_detection_token_usage.outputs.aic }}
detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
detection_success: ${{ steps.detection_conclusion.outputs.success }}
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1217,8 +1109,7 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1236,7 +1127,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1245,7 +1136,7 @@ jobs:
rm -rf /tmp/gh-aw/sandbox/firewall/logs
rm -rf /tmp/gh-aw/sandbox/firewall/audit
- name: Download container images
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46
- name: Check if detection needed
id: detection_guard
if: always()
@@ -1270,11 +1161,7 @@ jobs:
if: always() && steps.detection_guard.outputs.run_detection == 'true'
run: |
mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
rm -f /tmp/gh-aw/agent_usage.json
cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then
echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context."
fi
cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
for f in /tmp/gh-aw/aw-*.patch; do
[ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
@@ -1308,11 +1195,11 @@ jobs:
node-version: '24'
package-manager-cache: false
- name: Install GitHub Copilot CLI
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48
env:
GH_HOST: github.com
- name: Install AWF binary
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46
- name: Execute GitHub Copilot CLI
if: always() && steps.detection_guard.outputs.run_detection == 'true'
continue-on-error: true
@@ -1322,46 +1209,27 @@ jobs:
run: |
set -o pipefail
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
trap 'rm -f /home/runner/.copilot/settings.json' EXIT
mkdir -p /home/runner/.copilot
printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json
touch /tmp/gh-aw/agent-step-summary.md
GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
export GH_AW_NODE_BIN
export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
(umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}"
printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs"
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
fi
GH_AW_TOOL_CACHE_MOUNT=""
GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
if [ -d "$GH_AW_TOOL_CACHE" ]; then
if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
fi
elif [ -d "/home/runner/work/_tool" ]; then
GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
fi
# shellcheck disable=SC1003
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
-- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
-- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
env:
AWF_REFLECT_ENABLED: 1
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
COPILOT_API_KEY: dummy-byok-key-for-offline-mode
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }}
GH_AW_PHASE: detection
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_TIMEOUT_MINUTES: 20
GH_AW_VERSION: v0.79.6
GH_AW_VERSION: v0.74.4
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true
GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
@@ -1374,21 +1242,7 @@ jobs:
GIT_AUTHOR_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
RUNNER_TEMP: ${{ runner.temp }}
XDG_CONFIG_HOME: /home/runner
- name: Parse threat detection token usage for step summary
id: parse_detection_token_usage
if: always()
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_TOKEN_USAGE_SUMMARY_TITLE: Threat Detection Token Usage
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
await main();
- name: Upload threat detection log
if: always() && steps.detection_guard.outputs.run_detection == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -1430,8 +1284,7 @@ jobs:
}
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -1442,7 +1295,6 @@ jobs:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
@@ -1462,77 +1314,6 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
gate:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
- name: Decide whether requirements changed since the last comment
id: gate
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pre_activation:
runs-on: ubuntu-slim
outputs:
@@ -1544,15 +1325,14 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Check team membership for workflow
id: check_membership
@@ -1580,22 +1360,17 @@ jobs:
discussions: write
issues: write
pull-requests: write
timeout-minutes: 45
timeout-minutes: 15
env:
GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
GH_AW_AIC: ${{ needs.agent.outputs.aic }}
GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/check-requirements"
GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
GH_AW_ENGINE_ID: "copilot"
GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
GH_AW_ENGINE_VERSION: "1.0.60"
GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
GH_AW_ENGINE_VERSION: "1.0.48"
GH_AW_WORKFLOW_ID: "check-requirements"
GH_AW_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/check-requirements.md"
outputs:
code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
@@ -1608,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@v0.79.6
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1617,8 +1392,7 @@ jobs:
env:
GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)"
GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }}
GH_AW_INFO_VERSION: "1.0.60"
GH_AW_INFO_AWF_VERSION: "v0.27.2"
GH_AW_INFO_VERSION: "1.0.48"
GH_AW_INFO_ENGINE_ID: "copilot"
- name: Download agent output artifact
id: download-agent-output
@@ -1637,7 +1411,6 @@ jobs:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
@@ -1649,7 +1422,6 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com"
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }}
+252 -306
View File
@@ -6,6 +6,7 @@ on:
permissions:
contents: read
actions: read
issues: read
pull-requests: read
network:
allowed:
@@ -13,7 +14,7 @@ network:
tools:
web-fetch: {}
github:
toolsets: [repos, pull_requests]
toolsets: [default, actions]
min-integrity: unapproved
safe-outputs:
add-comment:
@@ -22,68 +23,8 @@ safe-outputs:
needs:
- extract_pr_number
jobs:
gate:
# Skip the (token-spending) agent when no tracked requirement file changed
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Decide whether requirements changed since the last comment
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -103,7 +44,7 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
steps:
- name: Download deterministic-results artifact
@@ -142,291 +83,296 @@ description: >
# Check requirements (AW)
You are a code-review assistant for Home Assistant. The deterministic
stage already evaluated every check it can and produced an artifact at
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
`needs_agent` checks and post the rendered comment.
You are a code review assistant for the Home Assistant project. The
deterministic stage has already evaluated every check it can on its own
and produced an artifact containing the PR number, per-package check
results, and a pre-rendered comment with placeholders. **Your only job is
to read that artifact, resolve any `needs_agent` checks, and post the
final comment.**
## Step 1 — Read the artifact
## Step 1 — Read the deterministic-stage artifact
Read the JSON directly for the full schema. Key fields:
The deterministic stage uploaded its results to the runner at
`/tmp/gh-aw/deterministic/results.json`.
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
- `rendered_comment` contains, for each `needs_agent` check, two
placeholders to replace:
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
**`security`** check kind uses `☑️` instead of `✅` for the success
case — see its section below for why.
- `{{CHECK_DETAIL:<pkg>:<kind>}}``<icon> <one-line explanation>`
(the bullet's `- **<label>**:` prefix is already rendered; replace
only the placeholder).
The JSON has this shape:
Do not modify other content in `rendered_comment`, do not re-evaluate
deterministic checks, do not add or remove packages. If `needs_agent`
is `false`, emit `rendered_comment` unchanged.
- `pr_number` — the PR being checked. The `add_comment` safe-output is
already targeted at this PR (a pre-job extracts `pr_number` from the
artifact and the workflow wires it into the safe-output config via
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
set `item_number` yourself** — just emit `add_comment` with the
rendered body.
- `needs_agent``true` iff any package's check needs resolution.
- `packages[]` — one entry per changed package. Each entry has:
- `name`, `old_version` (`null` for a newly added package; otherwise the
previous pin), `new_version`, `repo_url`, `publisher_kind`.
- `checks` — a dict keyed by **check kind** (string). Each value has a
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
- `rendered_comment` — the final PR comment body, already rendered. For
every check whose status is `needs_agent` it contains two placeholders
you must replace:
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
in the package's `<details>` block. Replace with
`<icon> <one-line explanation>` (the bullet's leading
`- **<label>**:` is already rendered — replace only the placeholder).
You **must not** modify any other content in `rendered_comment`. Do not
re-evaluate checks that already have a deterministic status. Do not add
or remove packages.
## Step 2 — Resolve each `needs_agent` check
For each `(package, check_kind)` with `status == "needs_agent"`, find
the matching `### Check kind: <check_kind>` section below and follow
it. If no section matches, emit a single `add_comment` with:
For each `package` in `packages`:
```
<!-- requirements-check -->
## Check requirements
For each `(check_kind, result)` in `package.checks` where
`result.status == "needs_agent"`:
❌ Internal error: deterministic artifact contains an unknown check kind
(`<check_kind>` on `<pkg>`).
```
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
section below.
2. **If no matching section exists**: emit a single `add_comment` whose
body is:
Then stop. Do not improvise a verdict.
```
<!-- requirements-check -->
## Check requirements
❌ Internal error: the deterministic artifact contains a check kind
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
instructions for. Update `.github/workflows/check-requirements.md`
to add a matching `## Check kind: <check_kind>` section, or remove
the kind from the deterministic stage.
```
Then stop. **Do not improvise** a verdict for an unknown check kind.
3. Otherwise, follow the instructions in that section. They tell you
which icon (✅/⚠️/❌) and one-line explanation to produce.
## Step 3 — Post the comment
Replace every placeholder with the resolved value and emit
`rendered_comment` via `add_comment`. Preserve the leading
`<!-- requirements-check -->` marker and the
`<!-- requirements-check-sha: … -->` marker that follows it — the next
run reads the recorded commit from it to decide whether anything changed.
The PR target is already wired; do not pass `item_number`.
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
in `rendered_comment` with the resolved value.
2. Emit the resulting markdown using `add_comment` — set `body` to the
merged `rendered_comment` verbatim (the leading
`<!-- requirements-check -->` marker must be preserved). The PR
target is already set by the workflow; do not pass `item_number`.
If the artifact's top-level `needs_agent` is `false` (no checks need
you), emit `rendered_comment` unchanged.
## Check instructions
### Check kind: `repo_public`
`web-fetch` GET `package.repo_url`.
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
not publicly accessible. Home Assistant requires dependencies to
have publicly available source code.`
- Otherwise → ⚠️ with a one-line description.
Verify that the package's source repository is publicly reachable.
If ❌, also mark this package's `release_pipeline` and `async_blocking`
cells/details as `` and explain `Skipped because the source
repository is not publicly accessible.`.
1. Read `package.repo_url`.
2. Use the `web-fetch` tool to GET that URL.
3. Decide the verdict:
- HTTP 200, returns a public repository page → ✅
`<repo_url> is publicly accessible.`
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
❌ `Source repository at <repo_url> is not publicly accessible.
Home Assistant requires all dependencies to have publicly available
source code.`
- Any other inconclusive result → ⚠️ with a one-line description.
If `repo_public` resolves to ❌ for a package, **also** mark that
package's `release_pipeline` and `async_blocking` cells/details as ``
(em dash) and explain `Skipped because the source repository is not
publicly accessible.` — neither check can be performed without a public
repo.
### Check kind: `pr_link`
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
Verify the PR description contains the right link for the change.
- **New package** (`old_version == null`): body must contain a URL
pointing at `repo_url`'s `owner/repo` on the same host (any
sub-path OK). PyPI is not sufficient.
- ✅ if present; otherwise ❌ `PR description must link to the
source repository at <repo_url>. A PyPI page link is not
sufficient.`
- **Version bump**: body must contain a URL on the same host as
`repo_url` that mentions **both** `old_version` and `new_version`
(compare URL, changelog, release page).
- ✅ if present and versions match; otherwise ❌ `PR description
should link to a changelog or compare URL on <repo_url> that
mentions both <old_version> and <new_version>.`
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
field from the artifact.
2. Extract all URLs from the body.
3. For a **new package** (`package.old_version` is `null`):
- The PR body must contain a URL that points at `package.repo_url`
(any sub-path of the same `owner/repo` on the same host is
acceptable). A PyPI link is **not** sufficient.
- ✅ if such a URL is present.
- ❌ otherwise:
`PR description must link to the source repository at <repo_url>.
A PyPI page link is not sufficient.`
4. For a **version bump** (`package.old_version` is not `null`):
- The PR body must contain a URL on the same host as
`package.repo_url` that references **both** `package.old_version`
and `package.new_version` (e.g. a GitHub compare URL
`compare/vX...vY`, a release / changelog URL containing both
versions, etc.).
- ✅ if such a URL is present and the versions match the actual bump.
- ❌ otherwise:
`PR description should link to a changelog or compare URL on
<repo_url> that mentions both <old_version> and <new_version>.`
### Check kind: `release_pipeline`
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
rubric:
Inspect the upstream project's release / publish CI pipeline.
1. Locate the publish workflow / job (name or filename contains
`release`, `publish`, `pypi`, or `deploy`).
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
promising file by name, fetch its contents.
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
- Other hosts: `web-fetch` an obvious CI config
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
2. Apply this rubric:
- **Trigger**: tag push / `release: published` / protected branch —
not solely manual dispatch without an environment guard.
- **Credentials**: OIDC (`id-token: write` +
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
`PYPI_TOKEN` from a CI secret acceptable for a bump.
- **No bypass**: no ungated `twine upload` / `pip upload`.
3. Verdict:
- ✅ — OIDC + sane triggers + no bypass.
- ⚠️ — static token on a bump, details unclear, or
non-GitHub/GitLab host with limited CI visibility.
- ❌ — static token on a new package, or manual-only triggers
without environment protection.
For each package needing inspection, determine the source repository
host from `package.repo_url`, then apply the corresponding checklist.
#### GitHub repositories (`github.com`)
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
2. Identify any workflow whose name or filename suggests publishing to
PyPI (`release`, `publish`, `pypi`, or `deploy`).
3. Fetch the workflow file and check:
- **Trigger sanity**: triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job —
**not** solely `workflow_dispatch` with no environment-protection
guard.
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
- **No manual upload bypass**: no ungated `twine upload` or
`pip upload`.
4. Verdict:
- ✅ if OIDC + sane triggers + no bypass.
- ⚠️ if static token but version bump, or details unclear.
- ❌ if static token on a new package, or only-manual triggers with
no environment protection.
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
2. Fetch `.gitlab-ci.yml` via
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
3. Apply the same conceptual checks: tag-only / protected-branch
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
ungated `twine upload`. Same verdict rules as GitHub.
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
1. Use `web-fetch` to retrieve any visible CI configuration
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
`bitbucket-pipelines.yml`, `.builds/*.yml`).
2. Apply the conceptual checks: automated triggers, CI-injected
credentials, no manual `twine upload`.
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
inspected; hosting provider is not GitHub or GitLab.`
### Check kind: `async_blocking`
Verify the dependency does not call blocking APIs inside `async def`
bodies. Home Assistant runs on a single asyncio loop, so blocking
calls from the async surface stall the whole loop. A purely sync
library is fine — integrations wrap its calls in an executor.
Verify whether the dependency performs blocking I/O inside async code
paths. Home Assistant runs on a single asyncio event loop, so a library
that exposes an `async` surface must not call blocking APIs from inside
its `async def` functions — that stalls the whole loop. A purely sync
library is fine: Home Assistant integrations are expected to wrap such
calls in an executor.
**Mode** (decided by `old_version`):
- `null` → new package: review the entire current source tree.
- string → version bump: review only the diff between the two tags.
Blocking calls already present in `old_version` are not regressions.
**Two modes — pick by inspecting `package.old_version`:**
**Step 1 — async surface?**
- `old_version` is `null` → **new package**: review the *entire current
source tree*. Nothing about this dependency has been vetted before.
- `old_version` is a string → **version bump**: review only the *diff
between `old_version` and `new_version`*. The previous version was
already accepted, so blocking calls that were present in
`old_version` are not regressions; report only what `new_version`
introduces.
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
tag matching `new_version` (try `v{version}`, `{version}`,
`release-{version}` — at most three attempts). Use the `repos` MCP for
github.com, `web-fetch` otherwise.
#### Step 1 — Decide whether the library exposes an async surface
If sync-only (no `async def` in public modules; no
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
classifier) → ✅ `Sync-only library; Home Assistant integrations must
wrap calls in an executor.` (Same verdict for both modes.)
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
(other hosts) on `package.repo_url`. Always inspect the tag /
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
**Step 2 — review the surface**
- Locate the top-level package directory (usually named after the
import name, often equal or close to `package.name`).
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
example in the README).
- Grep the package source for `async def`. A handful of `async def`
entries in the public modules is enough to treat the library as
having an async surface.
- New package: grep public modules for `async def`, inspect each
async body and transitive helpers.
- Bump: fetch the compare diff
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
on GitLab/other hosts). Only flag patterns on **added** lines that
are inside or reachable from `async def`. If no tag format resolves,
fall back to a full review and note that the diff was unavailable.
If the library is **sync-only** (no `async def` in its public modules
and no async framework dependency) → ✅
`Sync-only library; Home Assistant integrations must wrap calls in an
executor.` *This verdict is the same in both modes.*
**Blocking patterns to flag inside `async def`:**
#### Step 2a — Mode: new package (`old_version` is `null`)
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
- `time.sleep(` (use `await asyncio.sleep(`).
- Sync sockets/SSL: bare `socket.socket` I/O, `ssl.wrap_socket`,
Inspect **every `async def` in the public modules** for blocking
patterns. Walk transitively into helpers the async functions call.
#### Step 2b — Mode: version bump (`old_version` is a string)
Fetch the diff between the two tags and review **only changed lines**:
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
the `github` MCP tool, or
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
via `web-fetch`. Try the common tag formats in order until one
resolves: `v{version}`, `{version}`, `release-{version}`.
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
- Other hosts: use the project's equivalent compare URL via
`web-fetch`.
If neither tag format resolves on the host, fall back to a full review
(Step 2a) and mention in the detail that the diff was unavailable.
When reviewing the diff, only flag blocking patterns that appear in
**added lines** *inside or reachable from* an `async def`. A blocking
call that existed in `old_version` and is unchanged is not a regression
for this bump.
#### Step 3 — Blocking patterns to look for
In both modes, the patterns to flag inside `async def` bodies are:
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
`AsyncClient`), `pycurl`.
- `time.sleep(` (must be `await asyncio.sleep(`).
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
blocking `select.select`.
- File I/O on the request path: `open(` /
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
one-shot reads during import are OK).
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
`redis.Redis`.
- `subprocess.run` / `subprocess.call` / `os.system`.
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
non-trivial sizes (small one-shot reads during import are
acceptable; reads/writes on the request path are not — prefer
`aiofiles` / executor).
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
`pymongo` (sync client), `redis.Redis` (sync client).
- `subprocess.run` / `subprocess.call` / `os.system` (must be
`asyncio.create_subprocess_*`).
Calls dispatched to an executor (`run_in_executor`,
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
blocking.
A call that is clearly dispatched to an executor
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
does NOT count as blocking.
**Verdict:**
#### Step 4 — Verdict
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
introduced in {old_version} → {new_version}.`.
- ⚠️ — blocking only in sync helpers the async API never calls, or
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
least one `<file>:<line>` and say why it's not hot.
- ❌ — blocking call reachable from a public `async def` on the
request/polling path (bump: introduced or moved onto the hot path
by this version). Cite the offending `<file>:<line>` as a clickable
link on the repo host.
### Check kind: `security`
**Baseline** scan of the upstream source for obvious supply-chain red
flags — a cheap first pass, **not** a security review or malware audit.
A clean result means "nothing obvious stood out", not "this package is
safe". The success icon is `☑️` — **never** `` — so a passing scan is
not read as an endorsement.
If `repo_public` resolves to ❌ for the same package, mark `security`'s
cell and detail as `` and explain `Skipped because the source
repository is not publicly accessible.` — the source cannot be fetched.
**Step 1 — Fetch a representative slice**
Locate the source from `package.repo_url`.
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
Fetch the **raw contents** of `setup.py` (install-time code runs on every
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
If the tree is too large for the API budget, inspect at least `setup.py`,
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
**Step 2 — Patterns to flag**
Reason from principles, not a fixed checklist: for each file ask *would a
well-behaved library doing what this package's PyPI description claims
need to do this?* If "no" or "unclear", record a finding. The categories
describe the **shape** of concerning behavior; the named APIs, filenames,
and keys are illustrative — treat any equivalent construct (including ones
that did not exist when this was written) the same way.
For every finding include the file path, line number, a snippet
(≤ 120 chars), a permalink
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
GitLab equivalent), and one sentence on why it is out of scope.
1. **Reaches into Home Assistant internals.** A library should touch HA
only through its documented Python API — never the `config_dir`
filesystem or internal auth / session state. Flag code that opens,
reads, writes, or resolves paths to artifacts it does not own
(top-level YAML it did not create, anything under `.storage/`, other
integrations' files) or reads tokens / refresh tokens / auth providers
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
*out-of-scope access*, not a static list of names.
2. **Network input flows into an execution sink (download-and-execute).**
Flag any data-flow from a network response body (any HTTP / WebSocket /
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
pipelines (`curl … | sh`), or a file later imported / executed — plus
package-manager calls (`pip install` / `download`) with args resolved
from network responses at runtime.
3. **Build / install-time code is non-deterministic or non-local.**
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
build hooks must only compile and copy files shipped in the sdist. Flag
build-stage code that opens a socket, shells out, writes outside the
build / install tree, or pulls a build backend not on PyPI (Git URL /
local path).
4. **Reads secrets and combines them with an egress path.** The shape is
*secret-source → outbound-channel*. Flag code that reads credential
material (token-like env vars, credential files under the user's home,
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
same path sends it to a destination the package needn't talk to.
Reading or sending alone is not enough — the *combination* is the signal.
5. **Hides what it does.** Flag opaque data flowing into an execution
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
`eval` / `compile` / `__import__`; identifiers assembled at runtime
then imported; or any construct whose evident purpose is to make the
behavior unreadable.
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
hosts absent from the package's PyPI `project_urls` with no obvious
connection to its function — short-link / paste services, ephemeral
tunnels, raw IPs, non-default ports against unknown hosts — and any
network call at module top-level / `__init__.py` (runs on import for
every consumer).
A clearly out-of-scope behavior that fits none of the above: flag under
the closest category and explain. The categories guide reasoning, not bound it.
**Verdict**
Aggregate the findings into one of:
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
This is not a security review — only the cheap checks were run.`
Use `☑️` (**not** ``) so a passing scan is not read as an endorsement.
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
include path / line / snippet / permalink per match for the reviewer.
- `❌ <one-line summary>` — patterns with no legitimate explanation
(install-time network execution, decode-and-exec of opaque blobs, reads
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
host); same detail.
Be precise. False positives are expected — when in doubt prefer `⚠️` with
context over ``. This check is informational and never blocks the
workflow on its own; a human reviewer decides whether to merge.
- ✅ — no offending blocking pattern in the surface being reviewed
(whole tree for a new package, added lines for a bump). For a bump,
phrase the detail as `No new blocking calls introduced in
{old_version} → {new_version}.`.
- ⚠️ — blocking calls exist only in sync helpers that the async API
does not call, or only on a clearly non-hot path (e.g. one-shot
setup before the event loop is running). Cite at least one
`<file>:<line>` and explain why it is not on the hot path.
- ❌ — a blocking call is reachable from an `async def` that is part
of the public API on the request / polling path (for a bump: the
call was introduced or moved onto the hot path by this version).
Cite the offending `<file>:<line>` as a clickable link on the repo
host so the contributor can jump to it.
## Notes
- Be constructive; reference the inspected file by URL when useful.
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
the `<!-- requirements-check -->` marker.
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
cancelled/failed), emit nothing — the post-step verification is
gated and won't complain.
- Be constructive and helpful. Reference the inspected workflow / CI
file by URL where useful so the contributor can fix the issue.
- The dedup of the requirements-check comment is handled by gh-aw's
`add_comment` safe-output via the `<!-- requirements-check -->`
marker on the first line of `rendered_comment`.
- If the deterministic workflow concluded with a non-success status,
this workflow's `if:` guard on `Download deterministic-results
artifact` skipped the download. If you find no file at
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
verification is also gated and will not complain.
+24 -24
View File
@@ -98,7 +98,7 @@ jobs:
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Generate partial Python venv restore key
@@ -264,7 +264,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register problem matchers
@@ -291,7 +291,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
@@ -318,7 +318,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register hadolint problem matcher
@@ -341,7 +341,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -404,7 +404,7 @@ jobs:
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
@@ -469,7 +469,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -512,7 +512,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -548,7 +548,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -576,7 +576,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Dependency review
@@ -603,7 +603,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -654,7 +654,7 @@ jobs:
|| github.event.inputs.pylint-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -707,7 +707,7 @@ jobs:
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -758,7 +758,7 @@ jobs:
|| github.event.inputs.mypy-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -825,7 +825,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -889,7 +889,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1030,7 +1030,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1179,7 +1179,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1317,7 +1317,7 @@ jobs:
if: needs.info.outputs.skip_coverage != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1326,7 +1326,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1355,7 +1355,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1476,7 +1476,7 @@ jobs:
- pytest-partial
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1485,7 +1485,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1513,7 +1513,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
report_type: test_results
fail_ci_if_error: true
+3 -3
View File
@@ -23,16 +23,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+5 -5
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -116,7 +116,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -167,7 +167,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+1
View File
@@ -3,6 +3,7 @@ config2/*
tests/testing_config/deps
tests/testing_config/home-assistant.log*
tests/testing_config/.storage/sandbox/
# hass-release
data/
+25 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.16
rev: v0.15.14
hooks:
- id: ruff-check
args:
@@ -64,6 +64,27 @@ repos:
files: ^(homeassistant|tests|script)/.+\.py$
- repo: local
hooks:
# Drift guard for the checked-in sandbox protobuf gencode. Manual
# stage only (grpcio-tools is not a project dep, so it bootstraps a
# throwaway venv and degrades gracefully when uv is absent): run with
# `prek run --hook-stage manual sandbox-proto-drift` or in a CI lane.
- id: sandbox-proto-drift
name: sandbox protobuf gencode drift guard
entry: sandbox/proto/check_drift.sh
language: script
pass_filenames: false
stages: [manual]
files: ^sandbox/proto/sandbox\.proto$
# Drift guard for the hand-mirrored sandbox wire modules (channel.py,
# codec_protobuf.py, messages.py). A plain byte-for-byte diff with no
# external tooling, so — unlike the proto gencode guard above — it runs as
# a regular hook whenever either copy of a mirrored file changes.
- id: sandbox-mirror-drift
name: sandbox wire-module mirror drift guard
entry: sandbox/proto/check_mirror_drift.sh
language: script
pass_filenames: false
files: ^(homeassistant/components/sandbox|sandbox/hass_client/hass_client)/(channel|codec_protobuf|messages)\.py$
# Run mypy through our wrapper script in order to get the possible
# pyenv and/or virtualenv activated; it may not have been e.g. if
# committing from a GUI tool that was not launched from an activated
@@ -75,6 +96,9 @@ repos:
require_serial: true
types_or: [python, pyi]
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
# Checked-in protobuf gencode (sandbox): the .py + .pyi pair trips
# mypy's duplicate-module check, and it is machine-generated anyway.
exclude: _pb2\.(py|pyi)$
- id: pylint
name: pylint
entry: script/run-in-env.sh pylint --ignore-missing-annotations=y
-1
View File
@@ -96,7 +96,6 @@ homeassistant.components.aprs.*
homeassistant.components.apsystems.*
homeassistant.components.aqualogic.*
homeassistant.components.aquostv.*
homeassistant.components.aqvify.*
homeassistant.components.aranet.*
homeassistant.components.arcam_fmj.*
homeassistant.components.arris_tg2492lg.*
+1 -2
View File
@@ -40,5 +40,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
Generated
+5 -13
View File
@@ -162,8 +162,6 @@ CLAUDE.md @home-assistant/core
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/homeassistant/components/aquacell/ @Jordi1990
/tests/components/aquacell/ @Jordi1990
/homeassistant/components/aqvify/ @astrandb
/tests/components/aqvify/ @astrandb
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
@@ -262,8 +260,8 @@ CLAUDE.md @home-assistant/core
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r
/tests/components/bring/ @miaucl @tr4nt0r
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/homeassistant/components/brother/ @bieniu
/tests/components/brother/ @bieniu
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
@@ -576,8 +574,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss @Marcello17
/tests/components/fluss/ @fluss @Marcello17
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -695,8 +693,6 @@ CLAUDE.md @home-assistant/core
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
/tests/components/greencell/ @BrzezowskiGC
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -949,8 +945,6 @@ CLAUDE.md @home-assistant/core
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
/tests/components/klik_aan_klik_uit/ @Phunkafizer
/homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
@@ -1088,8 +1082,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @erwindouna
/tests/components/melcloud/ @erwindouna
/homeassistant/components/melcloud_home/ @erwindouna
/tests/components/melcloud_home/ @erwindouna
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
@@ -1157,6 +1149,7 @@ CLAUDE.md @home-assistant/core
/tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys
@@ -1892,7 +1885,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/tests/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
+1
View File
@@ -11,6 +11,7 @@
"microsoft_face_identify",
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.18"]
"requirements": ["aioacaia==0.1.17"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.2"]
"requirements": ["serialx==1.8.0"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/acmeda",
"iot_class": "local_push",
"loggers": ["aiopulse"],
"requirements": ["aiopulse==0.4.7"]
"requirements": ["aiopulse==0.4.6"]
}
@@ -116,6 +116,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# This means the user has downgraded from a future version
if entry.version > 2:
return False
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
+1 -1
View File
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.8"],
"requirements": ["aioairq==0.4.7"],
"zeroconf": [
{
"properties": {
@@ -1,6 +1,6 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,12 +25,6 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
connections={
(
CONNECTION_NETWORK_MAC,
self.coordinator.data["status"]["mac_address"],
)
},
manufacturer="AirVisual",
model=self.coordinator.data["status"]["model"],
name=self.coordinator.data["settings"]["node_name"],
@@ -65,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
@@ -49,5 +49,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
await self.coordinator.api.call_routine(self._routine)
@@ -1,7 +1,5 @@
"""Support for Alexa Devices."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import timedelta
from aioamazondevices.api import AmazonEchoApi
@@ -21,11 +19,7 @@ from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,65 +29,6 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
@asynccontextmanager
async def alexa_api_call(
coordinator: DataUpdateCoordinator | None = None,
) -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as HomeAssistantError."""
try:
yield
except CannotAuthenticate as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except CannotConnect as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
@asynccontextmanager
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as ConfigEntry errors."""
try:
yield
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -178,12 +113,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except ValueError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
else:
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
@@ -240,8 +169,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_history_state(self) -> None:
"""Sync history state."""
async with alexa_config_entry_errors():
try:
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
@@ -257,8 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
async with alexa_config_entry_errors():
try:
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.4"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -22,8 +22,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -215,15 +216,16 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
@@ -231,8 +233,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self.device.serial_number,
volume,
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.set_device_volume(self.device, volume)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
@@ -262,12 +263,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.send_media_command(self.device, command)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
@@ -12,8 +12,9 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -79,11 +80,10 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
async with alexa_api_call(self.coordinator):
await self.entity_description.method(
self.coordinator.api, self.device, message
)
await self.entity_description.method(self.coordinator.api, self.device, message)
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
@@ -85,15 +85,13 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_sound_value",
translation_placeholders={"sound": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
async with alexa_api_call():
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
@@ -102,10 +100,9 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -14,9 +14,13 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
PARALLEL_UPDATES = 1
@@ -86,6 +90,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -93,8 +98,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
if TYPE_CHECKING:
assert method is not None
async with alexa_api_call(self.coordinator):
await method(self.device, state)
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
@@ -1,19 +1,54 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
async def async_update_unique_id(
@@ -75,6 +75,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version == 1:
new_data = {**entry.data}
if CONF_DEVICES in new_data:
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.1"]
"requirements": ["anova-wifi==0.17.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.2"]
"requirements": ["anthemav==1.4.1"]
}
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -87,12 +87,9 @@ class AnthemAVR(MediaPlayerEntity):
via_device=(DOMAIN, mac_address),
)
else:
# Zone 1 is the physical receiver that owns the network MAC; higher
# zones are via_device children and carry no connection.
self._attr_unique_id = mac_address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
connections={(CONNECTION_NETWORK_MAC, mac_address)},
name=name,
manufacturer=MANUFACTURER,
model=model,
@@ -178,6 +178,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
@@ -1,6 +1,7 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
model_id = model_id[:-9]
if model_id.endswith("-4"):
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.108.0"]
"requirements": ["anthropic==0.96.0"]
}
@@ -52,7 +52,10 @@ rules:
status: exempt
comment: |
Service integration, no discovery.
docs-data-update: done
docs-data-update:
status: exempt
comment: |
No data updates.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if f"claude-{family}" in m["value"]),
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
+1
View File
@@ -59,6 +59,7 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -193,7 +193,6 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
name=self.create_device_name(data),
manufacturer="Aprilaire",
)
@@ -1,6 +1,5 @@
"""Config flow for Aquacell integration."""
from collections.abc import Mapping
from datetime import datetime
import logging
from typing import Any
@@ -32,12 +31,6 @@ DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aquacell."""
@@ -84,48 +77,3 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
api = AquacellApi(
session, reauth_entry.data.get(CONF_BRAND, Brand.AQUACELL)
)
try:
refresh_token = await api.authenticate(
reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except ApiException, TimeoutError:
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
errors=errors,
)
@@ -14,7 +14,7 @@ from aioaquacell import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -79,7 +79,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
softeners = await self.aquacell_api.get_all_softeners()
except AuthenticationFailed as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryError from err
except (AquacellApiException, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,13 +9,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "The password for {email} is no longer valid. Enter your current softener mobile app password to reconnect.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"brand": "Brand",
@@ -1,28 +0,0 @@
"""The Aqvify integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry, AqvifyCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Set up Aqvify from a config entry."""
coordinator = AqvifyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Unload Aqvify config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,113 +0,0 @@
"""Config flow for the Aqvify integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aqvify."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
hub = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await hub.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(account_data.account_id)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"aqvify_url": "https://app.aqvify.com/User",
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors = {}
if user_input is not None:
api_client = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await api_client.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(account_data.account_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
-3
View File
@@ -1,3 +0,0 @@
"""Constants for the Aqvify integration."""
DOMAIN = "aqvify"
@@ -1,154 +0,0 @@
"""Coordinator for Aqvify integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator]
@dataclass
class AqvifyCoordinatorData:
"""Data class for storing coordinator data."""
devices: AqvifyDevices
device_data: dict[str, AqvifyDeviceData]
class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
"""Data update coordinator for Aqvify devices."""
config_entry: AqvifyConfigEntry
def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None:
"""Initialize the Aqvify data update coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
self.previous_devices: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.api_client.async_get_account_id()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
async def _async_update_data(self) -> AqvifyCoordinatorData:
"""Fetch device state."""
try:
devices = await self.api_client.async_get_devices()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
current_devices = set(devices.devices.keys())
if stale_devices := self.previous_devices - current_devices:
account_id = self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
for device_id in stale_devices:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
device_data = {}
for aqvify_device in devices.devices.values():
try:
device_key = str(aqvify_device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
return AqvifyCoordinatorData(
devices=devices,
device_data=device_data,
)
@@ -1,30 +0,0 @@
"""Diagnostics platform for Aqvify integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry
TO_REDACT = [CONF_API_KEY]
TO_REDACT_AQVIFY = ["name"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AqvifyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
device_list_raw_data = entry.runtime_data.data.devices.raw
device_data_raw_data = {
key: device.raw_data
for key, device in entry.runtime_data.data.device_data.items()
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY),
"device_data": device_data_raw_data,
}
-35
View File
@@ -1,35 +0,0 @@
"""Defines a base Aqvify entity."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AqvifyCoordinator
class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]):
"""Defines a base Aqvify entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AqvifyCoordinator,
description: EntityDescription,
device_key: str,
) -> None:
"""Initialize the Aqvify entity."""
super().__init__(coordinator)
account_id = self.coordinator.config_entry.unique_id
self.device_key = device_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{account_id}_{device_key}")},
name=coordinator.data.devices.devices[device_key].name,
manufacturer="Aqvify",
configuration_url="https://app.aqvify.com",
serial_number=device_key,
)
self._attr_unique_id = f"{account_id}_{device_key}_{description.key}"
self.entity_description = description
@@ -1,12 +0,0 @@
{
"entity": {
"sensor": {
"meter_value": {
"default": "mdi:waves-arrow-up"
},
"water_level": {
"default": "mdi:waves"
}
}
}
}
@@ -1,12 +0,0 @@
{
"domain": "aqvify",
"name": "Aqvify",
"codeowners": ["@astrandb"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aqvify",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.10"]
}
@@ -1,81 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions in this integration.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
There are no configuration options.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: |
Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
-79
View File
@@ -1,79 +0,0 @@
"""Sensor platform for Aqvify integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pyaqvify import AqvifyDeviceData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AqvifyConfigEntry
from .entity import AqvifyBaseEntity
# Coordinator is used to centralize the data updates.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AqvifySensorEntityDescription(SensorEntityDescription):
"""Description of an Aqvify sensor entity."""
value_fn: Callable[[AqvifyDeviceData], float | int | None]
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
AqvifySensorEntityDescription(
key="meter_value",
translation_key="meter_value",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.meter_value,
),
AqvifySensorEntityDescription(
key="water_level",
translation_key="water_level",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AqvifyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
"""Representation of an Aqvify sensor entity."""
entity_description: AqvifySensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.data.device_data[self.device_key]
)
@@ -1,56 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The entered API key corresponds to a different account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]"
},
"description": "Reauthentication required. Please enter your updated API key."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your Aqvify API key"
},
"description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below."
}
}
},
"entity": {
"sensor": {
"meter_value": {
"name": "Meter value"
},
"water_level": {
"name": "Water level"
}
}
},
"exceptions": {
"api_error": {
"message": "An error occurred while communicating with the Aqvify API for {entry}"
},
"api_timeout": {
"message": "Timeout occurred while communicating with the Aqvify API for {entry}"
},
"invalid_api_key": {
"message": "Invalid API key. Please verify your API key and try to reauthenticate."
}
}
}
+2 -4
View File
@@ -31,7 +31,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
adv: Aranet4Advertisement,
) -> DeviceInfo:
"""Convert a sensor device info to hass device info."""
hass_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
)
hass_device_info = DeviceInfo({})
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
@@ -1816,11 +1816,6 @@ class PipelineInput:
await self.run.text_to_speech(tts_input)
except PipelineError as err:
if self.run.tts_stream:
# Clean up TTS stream
self.run.tts_stream.delete()
self.run.tts_stream = None
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@@ -1890,17 +1885,15 @@ class PipelineInput:
):
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
# Do TTS prepare separately so we don't create a ResultStream if the
# pipeline is invalid.
if (
start_stage_index
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
<= end_stage_index
):
await self.run.prepare_text_to_speech()
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
class PipelinePreferred(CollectionError):
@@ -3,11 +3,8 @@
from dataclasses import asdict
import logging
from pathlib import Path
import re
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -167,7 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -205,8 +201,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -218,21 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.8.0"]
"requirements": ["hassil==3.6.0"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["atenpdu==0.3.6"]
"requirements": ["atenpdu==0.3.2"]
}
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==6.2.0.44.0"],
"requirements": ["mozart-api==5.3.1.108.2"],
"zeroconf": ["_bangolufsen._tcp.local."]
}
+8 -14
View File
@@ -1,7 +1,9 @@
"""The BleBox devices integration."""
import logging
from blebox_uniapi.box import Box
from blebox_uniapi.error import ConnectionError, Error, HttpError, UnauthorizedRequest
from blebox_uniapi.error import Error
from blebox_uniapi.session import ApiHost
from homeassistant.const import (
@@ -12,16 +14,14 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DEFAULT_SETUP_TIMEOUT
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
from .helpers import get_maybe_authenticated_session
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -50,15 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
try:
product = await Box.async_from_host(api_host)
except UnauthorizedRequest as ex:
raise ConfigEntryAuthFailed from ex
except (
ConnectionError,
HttpError,
) as ex:
raise ConfigEntryNotReady from ex
except Error as ex:
raise ConfigEntryError from ex
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
raise ConfigEntryNotReady from ex
coordinator = BleBoxCoordinator(hass, entry, product)
await coordinator.async_config_entry_first_refresh()
@@ -25,9 +25,6 @@ BINARY_SENSOR_TYPES = (
key="open",
device_class=BinarySensorDeviceClass.WINDOW,
),
BinarySensorEntityDescription(
key="input",
),
)
@@ -59,8 +56,6 @@ class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEn
"""Initialize a BleBox binary sensor feature."""
super().__init__(coordinator, feature)
self.entity_description = description
if feature.name:
self._attr_name = feature.name
@property
def is_on(self) -> bool:
@@ -41,8 +41,6 @@ async def async_setup_entry(
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
"""Representation of BleBox buttons."""
_attr_name = None
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
@@ -51,7 +51,6 @@ async def async_setup_entry(
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
"""Representation of a BleBox climate feature (saunaBox)."""
_attr_name = None
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
+33 -113
View File
@@ -1,6 +1,5 @@
"""Config flow for BleBox devices integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -17,7 +16,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
@@ -28,7 +26,6 @@ from .const import (
DEFAULT_PORT,
DEFAULT_SETUP_TIMEOUT,
DOMAIN,
INVALID_AUTH,
UNKNOWN,
UNSUPPORTED_VERSION,
)
@@ -49,7 +46,6 @@ STEP_SCHEMA = vol.Schema(
LOG_MSG = {
UNSUPPORTED_VERSION: "Outdated firmware",
CANNOT_CONNECT: "Failed to identify device",
INVALID_AUTH: "Authentication failed",
UNKNOWN: "Unknown error while identifying device",
}
@@ -76,21 +72,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_box_from_host_or_abort(
self, api_host: ApiHost
) -> Box | ConfigFlowResult:
"""Try to connect to the device; return product or an abort result."""
try:
return await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except Error:
return self.async_abort(reason="cannot_connect")
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
@@ -106,7 +87,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
)
except UnauthorizedRequest as ex:
return None, self.handle_step_exception(
ex, schema, host, port, INVALID_AUTH, _LOGGER.error, step_id
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
)
except Error as ex:
return None, self.handle_step_exception(
@@ -117,50 +98,43 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
"""Handle discovery by IP and port; probe device then confirm with the user."""
self.device_config["host"] = host
self.device_config["port"] = port
websession = async_get_clientsession(self.hass)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
result = await self._async_box_from_host_or_abort(api_host)
if not isinstance(result, Box):
return result
product = result
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": host,
},
"configuration_url": f"http://{host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_handle_discovery(
discovery_info.host, discovery_info.port or DEFAULT_PORT
hass = self.hass
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port
websession = async_get_clientsession(hass)
api_host = ApiHost(
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": self.device_config["host"],
},
"configuration_url": f"http://{discovery_info.host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -179,6 +153,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={
"name": self.device_config["name"],
"host": self.device_config["host"],
"port": self.device_config["port"],
},
)
@@ -271,58 +246,3 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry,
data_updates=data_updates,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication upon an API authentication error."""
self.context["title_placeholders"] = {
"name": self._get_reauth_entry().title,
"host": entry_data[CONF_HOST],
}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
host = reauth_entry.data[CONF_HOST]
port = reauth_entry.data[CONF_PORT]
if user_input is not None:
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
websession = get_maybe_authenticated_session(self.hass, password, username)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
try:
await Box.async_from_host(api_host)
except UnauthorizedRequest:
errors["base"] = INVALID_AUTH
except Error:
errors["base"] = CANNOT_CONNECT
except RuntimeError:
errors["base"] = UNKNOWN
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_USERNAME: username,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
),
errors=errors,
description_placeholders={"address": f"{host}:{port}"},
)
-11
View File
@@ -7,7 +7,6 @@ DEFAULT_SETUP_TIMEOUT = 10
# translation strings
ADDRESS_ALREADY_CONFIGURED = "address_already_configured"
CANNOT_CONNECT = "cannot_connect"
INVALID_AUTH = "invalid_auth"
UNSUPPORTED_VERSION = "unsupported_version"
UNKNOWN = "unknown"
@@ -25,13 +24,3 @@ OPEN_STATUS: dict[int, str] = {
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
CO2_LEVEL: dict[int, str] = {
0: "excellent",
1: "good",
2: "acceptable",
3: "medium",
4: "poor",
5: "unhealthy",
6: "hazardous",
}
@@ -4,11 +4,10 @@ from datetime import timedelta
import logging
from blebox_uniapi.box import Box
from blebox_uniapi.error import Error, UnauthorizedRequest
from blebox_uniapi.error import Error
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -41,8 +40,6 @@ class BleBoxCoordinator(DataUpdateCoordinator[None]):
"""Fetch data from the BleBox device."""
try:
await self.box.async_update_data()
except UnauthorizedRequest as err:
raise ConfigEntryAuthFailed from err
except Error as err:
raise UpdateFailed(
translation_domain=DOMAIN,
+3 -5
View File
@@ -74,8 +74,6 @@ async def async_setup_entry(
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
"""Representation of a BleBox cover feature."""
_attr_name = None
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
) -> None:
@@ -92,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
if feature.has_tilt:
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
)
if feature.is_calibrated:
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
if feature.tilt_only:
self._attr_supported_features &= ~(
+1 -2
View File
@@ -12,12 +12,11 @@ from .coordinator import BleBoxCoordinator
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
"""Implements a common class for entities representing a BleBox feature."""
_attr_has_entity_name = True
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
"""Initialize a BleBox entity."""
super().__init__(coordinator)
self._feature = feature
self._attr_name = feature.full_name
self._attr_unique_id = feature.unique_id
product = feature.product
self._attr_device_info = DeviceInfo(
@@ -18,12 +18,6 @@
}
},
"sensor": {
"co2_level": {
"default": "mdi:molecule-co2"
},
"open_status": {
"default": "mdi:window-open"
},
"power_consumption": {
"default": "mdi:lightning-bolt"
}
-5
View File
@@ -71,11 +71,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
super().__init__(coordinator, feature)
if feature.effect_list:
self._attr_supported_features = LightEntityFeature.EFFECT
if feature.index is not None:
self._attr_translation_key = "channel"
self._attr_translation_placeholders = {"index": str(feature.index + 1)}
else:
self._attr_name = None
@property
def is_on(self) -> bool:
+1 -40
View File
@@ -3,49 +3,10 @@
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"config_flow": true,
"dhcp": [
{ "hostname": "rollergate*" },
{ "hostname": "gatebox*" },
{ "hostname": "doorbox*" },
{ "hostname": "shutterbox*" },
{ "hostname": "switchbox*" },
{ "hostname": "dimmerbox*" },
{ "hostname": "dacbox*" },
{ "hostname": "wlightbox*" },
{ "hostname": "pixelbox*" },
{ "hostname": "saunabox*" },
{ "hostname": "thermobox*" },
{ "hostname": "tempsensor*" },
{ "hostname": "energymeter*" },
{ "hostname": "airsensor*" },
{ "hostname": "humiditysensor*" },
{ "hostname": "rainsensor*" },
{ "hostname": "floodsensor*" },
{ "hostname": "luxsensor*" },
{ "hostname": "inputsensor*" },
{ "hostname": "opensensor*" },
{ "hostname": "windsensor*" },
{ "hostname": "co2sensor*" },
{ "hostname": "simongo*" },
{ "hostname": "sabaj-k-smrt*" },
{ "hostname": "rico*" },
{ "hostname": "smartrollergate*" },
{ "hostname": "darco_ero_32ws_0*" },
{ "hostname": "pergoladc*" },
{ "hostname": "seltsmartscreen*" },
{ "hostname": "seltvenetianblind*" },
{ "hostname": "doorunitbox*" },
{ "hostname": "drutexsmart*" },
{ "hostname": "swingatecontroller*" },
{ "hostname": "windowopener*" },
{ "hostname": "smartawning*" },
{ "hostname": "smartshade*" },
{ "hostname": "smartshutter*" }
],
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.5"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+5 -74
View File
@@ -1,6 +1,5 @@
"""BleBox sensor entities."""
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
@@ -15,7 +14,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
UnitOfApparentPower,
@@ -24,7 +22,6 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfReactiveEnergy,
UnitOfReactivePower,
UnitOfSpeed,
UnitOfTemperature,
@@ -34,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BleBoxConfigEntry
from .const import CO2_LEVEL, OPEN_STATUS
from .const import OPEN_STATUS
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
@@ -53,26 +50,21 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="pm1",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm2_5",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="temperature",
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="powerConsumption",
@@ -84,110 +76,65 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="wind",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="forwardReactiveEnergy",
translation_key="forward_reactive_energy",
device_class=SensorDeviceClass.REACTIVE_ENERGY,
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reverseReactiveEnergy",
translation_key="reverse_reactive_energy",
device_class=SensorDeviceClass.REACTIVE_ENERGY,
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="forwardActiveEnergy",
translation_key="forward_active_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reverseActiveEnergy",
translation_key="reverse_active_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reactivePower",
translation_key="reactive_power",
device_class=SensorDeviceClass.REACTIVE_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="activePower",
translation_key="active_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="apparentPower",
translation_key="apparent_power",
device_class=SensorDeviceClass.APPARENT_POWER,
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="voltage",
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="current",
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="frequency",
translation_key="frequency",
device_class=SensorDeviceClass.FREQUENCY,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="openStatus",
translation_key="open_status",
device_class=SensorDeviceClass.ENUM,
icon="mdi:window-open",
options=list(OPEN_STATUS.values()),
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
),
BleBoxSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="co2Definition",
translation_key="co2_level",
device_class=SensorDeviceClass.ENUM,
options=list(CO2_LEVEL.values()),
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
),
)
@@ -197,20 +144,10 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
features = coordinator.box.features.get("sensors", [])
counts = Counter(f.device_class for f in features)
entities = [
BleBoxSensorEntity(
coordinator,
feature,
description,
feature.index
if counts[feature.device_class] > 1 and feature.index
else None,
)
for feature in features
BleBoxSensorEntity(coordinator, feature, description)
for feature in coordinator.box.features.get("sensors", [])
for description in SENSOR_TYPES
if description.key == feature.device_class
]
@@ -227,16 +164,10 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
coordinator: BleBoxCoordinator,
feature: blebox_uniapi.sensor.BaseSensor,
description: BleBoxSensorEntityDescription,
index: int | None = None,
) -> None:
"""Initialize a BleBox sensor feature."""
super().__init__(coordinator, feature)
self.entity_description = description
if feature.name:
self._attr_name = feature.name
elif index is not None and description.translation_key:
self._attr_translation_key = f"{description.translation_key}_n"
self._attr_translation_placeholders = {"index": str(index)}
@property
def native_value(self) -> StateType:
+2 -71
View File
@@ -3,38 +3,16 @@
"abort": {
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"authorization_required": "The BleBox device requires authentication.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
"unsupported_device_response": "The BleBox device returned an unrecognized response.",
"unsupported_device_version": "[%key:component::blebox::config::error::unsupported_version%]"
"unique_id_mismatch": "The device identifier does not match the previously configured device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_version": "BleBox device has outdated firmware. Please upgrade it first."
},
"flow_title": "{name} ({host})",
"step": {
"confirm_discovery": {
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
"title": "BleBox device discovered"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your BleBox device.",
"username": "The username for your BleBox device."
},
"description": "Enter credentials for the BleBox device at {address}.",
"title": "Reauthenticate your BleBox device"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
@@ -52,48 +30,14 @@
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The IP address of your BleBox device.",
"password": "The password for your BleBox device.",
"port": "The port of your BleBox device.",
"username": "The username for your BleBox device."
},
"description": "Set up your BleBox to integrate with Home Assistant.",
"title": "Set up your BleBox device"
}
}
},
"entity": {
"light": { "channel": { "name": "Channel {index}" } },
"sensor": {
"active_power": { "name": "Active power" },
"active_power_n": { "name": "Active power {index}" },
"apparent_power": { "name": "Apparent power" },
"apparent_power_n": { "name": "Apparent power {index}" },
"co2_level": {
"name": "Carbon dioxide level",
"state": {
"acceptable": "Acceptable",
"excellent": "Excellent",
"good": "Good",
"hazardous": "Hazardous",
"medium": "Medium",
"poor": "Poor",
"unhealthy": "Unhealthy"
}
},
"current": { "name": "Current" },
"current_n": { "name": "Current {index}" },
"forward_active_energy": { "name": "Forward active energy" },
"forward_active_energy_n": { "name": "Forward active energy {index}" },
"forward_reactive_energy": { "name": "Forward reactive energy" },
"forward_reactive_energy_n": {
"name": "Forward reactive energy {index}"
},
"frequency": { "name": "Frequency" },
"frequency_n": { "name": "Frequency {index}" },
"open_status": {
"name": "Open status",
"state": {
"ajar": "Ajar",
"closed": "[%key:common::state::closed%]",
@@ -101,20 +45,7 @@
"open": "[%key:common::state::open%]",
"unclosed_or_unlocked": "Unclosed or unlocked"
}
},
"power_consumption": { "name": "Energy last hour" },
"reactive_power": { "name": "Reactive power" },
"reactive_power_n": { "name": "Reactive power {index}" },
"reverse_active_energy": { "name": "Reverse active energy" },
"reverse_active_energy_n": { "name": "Reverse active energy {index}" },
"reverse_reactive_energy": { "name": "Reverse reactive energy" },
"reverse_reactive_energy_n": {
"name": "Reverse reactive energy {index}"
},
"temperature": { "name": "Temperature" },
"temperature_n": { "name": "Temperature {index}" },
"voltage": { "name": "Voltage" },
"voltage_n": { "name": "Voltage {index}" }
}
}
},
"exceptions": {
-11
View File
@@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
@@ -35,16 +34,6 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_name = None
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.switch.Switch
) -> None:
"""Initialize a BleBox switch feature."""
super().__init__(coordinator, feature)
if feature.name:
self._attr_name = feature.name
@property
def is_on(self) -> bool | None:
"""Return whether switch is on."""
+4
View File
@@ -169,7 +169,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.save_recent_clips(output_dir=file_path)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
@@ -189,7 +191,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.video_to_file(filename)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
+1 -1
View File
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.6"]
"requirements": ["blinkpy==0.25.2"]
}
+1 -7
View File
@@ -26,12 +26,6 @@
"description": "The credentials for {username} need to be updated",
"title": "Re-authenticate Blink"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -60,7 +54,7 @@
},
"exceptions": {
"cant_write": {
"message": "Can't write to file, check logs for details."
"message": "Can't write to file."
},
"failed_arm": {
"message": "Blink failed to arm camera."
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.3"]
"requirements": ["bluecurrent-api==1.3.2"]
}
+1 -1
View File
@@ -105,7 +105,7 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.8"],
"requirements": ["pyblu==2.0.6"],
"zeroconf": [
{
"type": "_musc._tcp.local."
@@ -118,7 +118,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
+6 -12
View File
@@ -6,7 +6,6 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -179,20 +178,15 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.3"
"habluetooth==6.8.1"
]
}
@@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, shc_info.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
identifiers={(DOMAIN, shc_info.unique_id)},
manufacturer="Bosch",
name=entry.title,
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.111"],
"requirements": ["boschshcpy==0.2.107"],
"zeroconf": [
{
"name": "bosch shc*",
+1 -1
View File
@@ -125,7 +125,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
await self.coordinator.async_refresh()
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item in the To-do list.
"""Update an item to the To-do list.
Bring has an internal 'recent' list which we want to use instead of a todo list
status, therefore completed todo list items are matched to the recent list and
+1 -8
View File
@@ -118,14 +118,7 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
return False
except (NetworkTimeoutError, OSError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connect_failed",
translation_placeholders={
"host": api.host[0],
"error": str(err),
},
) from err
raise ConfigEntryNotReady from err
except BroadlinkException as err:
_LOGGER.error(
@@ -1,7 +1,7 @@
{
"domain": "broadlink",
"name": "Broadlink",
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
"config_flow": true,
"dhcp": [
{
@@ -89,9 +89,6 @@
}
},
"exceptions": {
"connect_failed": {
"message": "Failed to connect to the device at {host}: {error}"
},
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
+10 -2
View File
@@ -31,7 +31,11 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -71,7 +75,7 @@ def get_bsblan_device_info(
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
@@ -226,6 +230,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
@@ -183,6 +183,7 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
try:
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_data_error",
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.23.4"]
"requirements": ["bthome-ble==3.23.2"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["buienradar", "vincenty"],
"requirements": ["buienradar==1.0.9"]
"requirements": ["buienradar==1.0.6"]
}
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
ActiveConnection,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
@@ -45,6 +45,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import (
CONF_EVENT,
DATA_COMPONENT,
DOMAIN,
EVENT_DESCRIPTION,

Some files were not shown because too many files have changed in this diff Show More