diff --git a/CHANGELOG.md b/CHANGELOG.md index b21c6e2..57bb1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026.10.5.2 — 2026-10-05 + +- Expose action tools with readable ASCII names derived from their configured names, such as `otevrit_branu`, while retaining their original titles and descriptions. +- Keep legacy `action_` calls compatible with cached client tool lists. +- Prevent collisions with the status tool, legacy identifiers, literal names and generated suffixes; every configured action remains available. +- Bind retry results to the stable action ID, so switching between readable and legacy aliases cannot execute the same request twice. +- Keep fixed empty tool arguments, OAuth permissions and private diagnostic categories unchanged. + +Updating with the same configured origin and permissions preserves configuration and OAuth grants. Clients with cached tool lists may display old names until their tool list is refreshed. Renaming a configured action still revokes access and requires new pairing. This release passed 89 Python tests, 5 JavaScript tests and package checks; live client/display behavior needs separate acceptance testing. + ## 2026.10.5.1 — 2026-10-05 - Support public HTTPS addresses beyond Nabu Casa: your own domain or reverse proxy, including nonstandard ports. diff --git a/README.md b/README.md index 0e97d10..a797098 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ This is an independent integration, not the official Home Assistant MCP Server o Install the published release through HACS: 1. In HACS, open **Custom repositories** and add `https://github.com/Jenik5/GrokConnectorForHomeAssistant` as an **Integration**. -2. Download **2026.10.5.1** or a later stable release. Stable releases do not require beta versions to be enabled in HACS. +2. Download **2026.10.5.2** or a later stable release. Stable releases do not require beta versions to be enabled in HACS. 3. Restart Home Assistant to load the newly installed Python integration. 4. Open **Settings → Devices & services → Add integration → Grok Connector**. 5. Enter your public HTTPS origin, for example `https://your-instance.ui.nabu.casa` or `https://ha.example.org:8125`, and choose the MCP language. Enter only the origin, without credentials, a path, query or fragment. @@ -52,6 +52,8 @@ HACS manages this repository as a custom repository; inclusion in the HACS defau Each action has a **name**, an optional **icon**, a **description for Grok** and an HA **action sequence**. Choose the icon through HA's native icon picker; actions without an icon use the play symbol. The visual editor supports service actions, device actions, scripts, automations, scenes, conditions and sequences. The configuration is validated by HA before it becomes an MCP tool. +From **2026.10.5.2**, action tool names are readable ASCII forms of the configured name: `Otevřít bránu` becomes `otevrit_branu`. Diacritics are normalized and the base name is limited to 48 characters. Empty, duplicate or reserved names gain a short ID suffix and, when needed, a counter. Names are unique across the status tool, every action and legacy aliases. The original title and description are retained. Legacy `action_` calls remain accepted for cached clients; an alias change during a retry returns the same cached result within the existing session. Cached tool lists may display old names until refreshed. Updating alone preserves pairing; renaming a configured action continues to revoke access. + | Setting | What Grok gets | | --- | --- | | Selected entities | Current entity ID, friendly name, state and optional unit; other attributes are not exposed | diff --git a/custom_components/grok_connector/const.py b/custom_components/grok_connector/const.py index 1f0a519..8eca377 100644 --- a/custom_components/grok_connector/const.py +++ b/custom_components/grok_connector/const.py @@ -1,6 +1,6 @@ """Independent paths and bounds of the community connector.""" DOMAIN = 'grok_connector' -VERSION = '2026.10.5.1' +VERSION = '2026.10.5.2' MCP_PATH = '/api/grok_connector/mcp' OAUTH_PATH = '/api/grok_connector/oauth' RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource' + MCP_PATH diff --git a/custom_components/grok_connector/gateway.py b/custom_components/grok_connector/gateway.py index 93629d6..635d4c0 100644 --- a/custom_components/grok_connector/gateway.py +++ b/custom_components/grok_connector/gateway.py @@ -2,8 +2,10 @@ import asyncio import copy import json +import re import secrets import time +import unicodedata from .const import VERSION from .i18n import text @@ -12,6 +14,10 @@ SESSION_LIFETIME = 1800 MAX_SESSIONS = 128 +def tool_slug(name): + ascii_name = unicodedata.normalize('NFKD',name).encode('ascii','ignore').decode() + return re.sub(r'[^a-z0-9]+','_',ascii_name.lower()).strip('_')[:48] + def content(data,error=False): return {'content':[{'type':'text','text':json.dumps(data,ensure_ascii=False)}],'isError':error} @@ -71,12 +77,29 @@ def status(self): entities.append(item) return {'entities':entities} + def action_tools(self): + """Readable names never shadow a status tool, literal slug or legacy alias.""" + slugs = [tool_slug(action.name) for action in self.policy.actions] + literal_names = set(slugs) + names = {'entities_status', *('action_'+action.id for action in self.policy.actions)} + result = {} + for action,name in zip(self.policy.actions,slugs): + if not name or name in names or re.fullmatch(r'action_[a-f0-9]{32}',name): + stem = (name or 'action')+'_'+action.id[:6] + name,index = stem,2 + while (name in names or name in literal_names + or re.fullmatch(r'action_[a-f0-9]{32}',name)): + name,index = stem+'_'+str(index),index+1 + names.add(name) + result[name] = action + return result + def tools(self): tools = [{'name':'entities_status','description':text(self.catalogs,self.language,'tool_status'), 'inputSchema':copy.deepcopy(EMPTY_INPUT), 'annotations':{'readOnlyHint':True,'destructiveHint':False,'idempotentHint':True,'openWorldHint':False}}] - for action in self.policy.actions: - tools.append({'name':'action_'+action.id, + for name,action in self.action_tools().items(): + tools.append({'name':name, 'title':action.name, 'description':text(self.catalogs,self.language,'tool_action').format(name=action.name,description=action.description), 'inputSchema':copy.deepcopy(EMPTY_INPUT), @@ -121,8 +144,10 @@ async def rpc(self,message,principal,*,session=None): if session is not None and not self.session_active(session,principal): return self.error(request_id,-32000,'MCP session expired') actions = {'action_'+action.id:action for action in self.policy.actions} + actions.update(self.action_tools()) if name not in actions: return self.error(request_id,-32602,'Action not permitted') + action_id = actions[name].id now = self.clock() self.replies = {key:value for key,value in self.replies.items() if value[0] > now} # JSON-RPC IDs identify requests within an MCP session, not @@ -131,12 +156,12 @@ async def rpc(self,message,principal,*,session=None): key = (principal,session,type(request_id),request_id) if session is not None and key in self.replies: _,previous,cached = self.replies[key] - if previous != name: + if previous != action_id: return self.error(request_id,-32602,'Request ID already used for another action') result = copy.deepcopy(cached) else: try: - await self.action_runner(actions[name].id) + await self.action_runner(action_id) except Exception: result = content({'error':'action_execution_uncertain', 'instruction':'Do not retry automatically. Check the state and HA trace.'},True) @@ -145,7 +170,8 @@ async def rpc(self,message,principal,*,session=None): if session is not None and session in self.sessions: if len(self.replies) >= 256: self.replies.pop(next(iter(self.replies))) - self.replies[key] = (self.clock()+120,name,copy.deepcopy(result)) + # Both the readable name and legacy alias identify this action. + self.replies[key] = (self.clock()+120,action_id,copy.deepcopy(result)) else: return self.error(request_id,-32601,'Method not found') return {'jsonrpc':'2.0','id':request_id,'result':result} diff --git a/custom_components/grok_connector/http.py b/custom_components/grok_connector/http.py index 072de19..993a082 100644 --- a/custom_components/grok_connector/http.py +++ b/custom_components/grok_connector/http.py @@ -207,7 +207,8 @@ async def post(self, request): self.diagnostic(request, "mcp", "request", bearer_valid=True, rpc_method=category("rpc_method", message.get("method")), tool=category("tool", "configured_action" if isinstance(params, dict) - and isinstance(params.get("name"), str) and re.fullmatch(r"action_[a-f0-9]{32}", params["name"]) + and isinstance(params.get("name"), str) and (params["name"] in self.runtime.gateway.action_tools() + or re.fullmatch(r"action_[a-f0-9]{32}", params["name"])) else params.get("name") if isinstance(params, dict) else None)) task = self.hass.async_create_task(self.runtime.gateway.rpc(message, principal, session=session), "Grok connector request") response = await asyncio.shield(task) diff --git a/custom_components/grok_connector/manifest.json b/custom_components/grok_connector/manifest.json index 36ac09d..57389c3 100644 --- a/custom_components/grok_connector/manifest.json +++ b/custom_components/grok_connector/manifest.json @@ -18,5 +18,5 @@ "issue_tracker": "https://github.com/Jenik5/GrokConnectorForHomeAssistant/issues", "requirements": [], "single_config_entry": true, - "version": "2026.10.5.1" + "version": "2026.10.5.2" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6762b88..ec4e666 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ The integration domain is `grok_connector`. Its MCP endpoint is `/api/grok_conne HA's admin config/options flow owns the reading list and named action sequences. `policy.py` parses immutable JSON-backed configuration with size limits. `config_flow.py` uses HA's entity and action selectors. HA's `SCRIPT_SCHEMA` and `async_validate_actions_config` validate sequences; `Script` executes them with a new HA context and no user-supplied MCP variables. -`gateway.py` is independent of HA. It implements JSON-RPC discovery, a read-only `entities_status` tool and one `action_` tool per named action. Tools have empty argument schemas and reject incoming target/service/variable overrides. Friendly action titles and descriptions are separate from their stable IDs. +`gateway.py` is independent of HA. It implements JSON-RPC discovery, a read-only `entities_status` tool and one readable ASCII tool name per configured action. The base is derived from the configured name; collision handling reserves status, all legacy `action_` aliases and all literal slugs before allocating suffixes/counters. Calls through legacy aliases remain supported and cannot be shadowed by another action's display name. Tools have empty argument schemas and reject incoming target/service/variable overrides. Friendly action titles and descriptions remain unchanged; execution and retry identity use the stable action ID. The reading list is a data-disclosure boundary, not an action-target boundary. Actions may intentionally operate on different entities or call other services, using their fixed administrator-authored sequences. Device conditions and confirmation logic remain in HA configuration. The connector contains no assumptions about garages, contact sensors or particular device domains. @@ -26,7 +26,7 @@ During replacement/revocation, an authorization barrier rejects new HTTP authori HA actions that have already occurred cannot be undone by revocation. An independently started script/automation may continue under its own HA lifecycle. Sequence return, condition termination and service completion are not physical-device confirmation. Execution failures produce a generic uncertainty response without exception details; the client should check state or HA traces before any deliberate retry. -Inline action sequences are serialized. JSON-RPC notifications never execute commands. Duplicate action request IDs are cached for 120 seconds (up to 256 results), scoped to the OAuth grant and MCP session; both string and numeric IDs retain their JSON types. Different sessions may use the same ID for different actions or deliberately repeat the same action. Reusing an ID for a different action in one session remains an error. +Inline action sequences are serialized. JSON-RPC notifications never execute commands. Duplicate action request IDs are cached for 120 seconds (up to 256 results), scoped to the OAuth grant and MCP session; both string and numeric IDs retain their JSON types. The cached action identity is its stable ID, so retrying through the readable or legacy alias returns the same reply. Different sessions may use the same ID for different actions or deliberately repeat the same action. Reusing an ID for a different action in one session remains an error. Successful initialization returns a random, cryptographically secure `MCP-Session-Id` header. Sessions are bound to the authenticated OAuth grant, expire after 30 minutes of inactivity and have a hard limit of 128. Expiry, explicit DELETE or eviction drops that session's replies. An unknown, expired or foreign session header returns HTTP 404; possession of a session ID never replaces bearer authentication. Policy change, revocation and unload clear all sessions and replies. Queued actions recheck authorization and session validity before execution. diff --git a/docs/README.cs.md b/docs/README.cs.md index 86faf93..ec4fc8f 100644 --- a/docs/README.cs.md +++ b/docs/README.cs.md @@ -21,6 +21,8 @@ Označ entity ke čtení; jejich typ není omezen. V **Konfigurovat → Akce → Grok může vyvolat připravenou sekvenci, nemůže jí podstrčit jiný cíl ani parametry. Podmínky a rozsah sekvence určuješ ty. Konektor zveřejní stav, název, ID a případnou jednotku vybraných entit; ostatní atributy neposílá. +Od verze `2026.10.5.2` mají nástroje akcí čitelné názvy: například `Otevřít bránu` se nabídne jako `otevrit_branu`. Shodné a rezervované názvy se bezpečně odliší příponou. Původní identifikátory zůstávají funkční; aktualizace sama nevyžaduje nové párování. Klient může zobrazovat staré názvy do obnovení seznamu nástrojů. Přejmenování akce dál vyžaduje nové párování. + ## Připojení Groka V **Konfigurovat → Párovací kód** se zobrazí přesná adresa a jednorázový kód platný 10 minut. Adresa má tvar `https://tvoje-instance.ui.nabu.casa/api/grok_connector/mcp`. V Groku vytvoř vlastní MCP konektor a kód zadej v jeho přihlašovacím okně. diff --git a/docs/README.de.md b/docs/README.de.md index cd89588..782688b 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -21,6 +21,8 @@ Wähle die lesbaren Entitäten. Unter **Konfigurieren → Aktionen** kannst du e Grok darf nur die vorbereiteten Werkzeuge ohne zusätzliche Ziele oder Parameter aufrufen. Die lesbare Entitätenliste begrenzt die Datenweitergabe, nicht die Reichweite einer von dir konfigurierten Aktionsfolge. +Ab Version `2026.10.5.2` erhalten Aktionswerkzeuge lesbare Namen: `Garagentor öffnen` wird beispielsweise zu `garagentor_offnen`. Gleiche und reservierte Namen werden durch einen eindeutigen Zusatz unterschieden. Alte Kennungen bleiben gültig; das Update allein erfordert keine neue Kopplung. Ein Client kann alte Namen anzeigen, bis seine Werkzeugliste aktualisiert wird. Das Umbenennen einer Aktion erfordert weiterhin neue Kopplung. + ## Grok verbinden **Konfigurieren → Kopplungscode** zeigt die genaue MCP-Adresse und einen einmaligen Code, gültig für zehn Minuten. Die Adresse lautet `https://deine-instanz.ui.nabu.casa/api/grok_connector/mcp`. Erstelle in Grok einen benutzerdefinierten MCP-Connector und gib den Code in dessen Autorisierungsfenster ein. diff --git a/docs/README.pl.md b/docs/README.pl.md index cd5dfed..818a032 100644 --- a/docs/README.pl.md +++ b/docs/README.pl.md @@ -21,6 +21,8 @@ Wybierz encje do odczytu. W **Konfiguruj → Akcje** dodaj nazwę, opis dla Grok Grok może wywołać tylko przygotowane narzędzie, bez podawania innego celu lub dodatkowych parametrów. Lista encji do odczytu ogranicza ujawniane dane; zakres skonfigurowanej akcji określa jej sekwencja. +Od wersji `2026.10.5.2` narzędzia akcji mają czytelne nazwy: `Otwórz bramę` staje się na przykład `otworz_brame`. Identyczne i zastrzeżone nazwy otrzymują unikalny sufiks. Stare identyfikatory nadal działają; sama aktualizacja nie wymaga ponownego parowania. Klient może wyświetlać stare nazwy do odświeżenia listy narzędzi. Zmiana nazwy akcji nadal wymaga ponownego parowania. + ## Połączenie z Grokiem **Konfiguruj → Kod parowania** pokazuje dokładny adres MCP i jednorazowy kod ważny przez dziesięć minut. Adres ma postać `https://twoja-instancja.ui.nabu.casa/api/grok_connector/mcp`. Utwórz własny konektor MCP w Groku i wpisz kod w jego oknie autoryzacji. diff --git a/docs/README.sk.md b/docs/README.sk.md index 5d36e28..3d76732 100644 --- a/docs/README.sk.md +++ b/docs/README.sk.md @@ -21,6 +21,8 @@ Vyber entity na čítanie. V **Konfigurovať → Akcie** pridaj názov, opis pre Grok môže vyvolať pripravený nástroj bez dodatočných cieľov alebo parametrov. Zoznam na čítanie obmedzuje zdieľané údaje; rozsah nakonfigurovanej akcie určuje jej sekvencia. +Od verzie `2026.10.5.2` majú nástroje akcií čitateľné názvy: napríklad `Otvoriť bránu` sa ponúkne ako `otvorit_branu`. Zhodné a rezervované názvy sa bezpečne odlíšia príponou. Pôvodné identifikátory zostávajú funkčné; samotná aktualizácia nevyžaduje nové párovanie. Klient môže zobrazovať staré názvy do obnovenia zoznamu nástrojov. Premenovanie akcie naďalej vyžaduje nové párovanie. + ## Pripojenie Groka **Konfigurovať → Párovací kód** ukáže presnú adresu MCP a jednorazový kód platný desať minút. Adresa má tvar `https://tvoja-instancia.ui.nabu.casa/api/grok_connector/mcp`. V Groku vytvor vlastný konektor MCP a kód zadaj v jeho autorizačnom okne. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index be8447a..8f37900 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -180,3 +180,11 @@ See [the acceptance procedure](RELEASING.md) for deliberate follow-up tests. The custom-origin contribution and maintainer hardening passed 80 local Python tests, 5 JavaScript tests and package checks. New regressions cover ambiguous URLs, browser IPv4 representations, custom-port discovery metadata, exact Origin/resource boundaries, PKCE consent/exchange, refresh rotation and existing Nabu Casa grant compatibility. See the [security assessment](SECURITY-ASSESSMENT-2026-10-05.md). These checks use the shipped code with framework adapters. No live HA deployment, proxy/TLS audit, physical action or Tesla test was performed for this release preparation. The contributor reported successful custom-domain pairing; that is separate from maintainer-verified acceptance. + +## Readable action tool names — 2026-10-05 / 2026.10.5.2 + +PR [#4](https://github.com/Jenik5/GrokConnectorForHomeAssistant/pull/4) replaces opaque action names in tool discovery with ASCII names derived from configured titles. Review found that the original collision handling could drop actions or redirect a legacy alias to a different configured action. Four added regression tests failed on the original implementation, including shared six-character ID prefixes, literal/generated suffix collisions, legacy alias shadowing and alias changes during retries. + +The maintainer fix reserves status and legacy names, avoids all literal slugs during suffix allocation, and rechecks generated names until unique. Retry identity uses the immutable action ID. The fixed implementation passed **89 Python tests**, **5 JavaScript tests** and package checks. Coverage includes all 64 configured actions with empty/non-ASCII/truncated/reserved names, calling each action through readable and legacy names, mixed-alias concurrent retries, authorization and argument rejection, and private HTTP diagnostic categories. These names follow the [MCP tool naming guidance](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names). + +The tests use actual connector modules with framework adapters and simulated action runners. No household action, live HA update or Grok/Tesla display test was performed for this release preparation. The contributor's report of live use is separate from maintainer-verified evidence. Existing OAuth, origin/port checks, policy revocation and session-isolation regressions remain covered. diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 9c3a525..1e16b1f 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -103,7 +103,7 @@ async def call(self, identifier=1, name='action_' + 'a' * 32, arguments=None): async def test_only_selected_state_and_named_actions_are_exposed(self): tools = self.gateway.tools() self.assertEqual({tool['name'] for tool in tools}, - {'entities_status', 'action_' + 'a' * 32, 'action_' + 'b' * 32}) + {'entities_status', 'turn_on_the_light', 'run_automation'}) self.assertEqual(tools[1]['title'], 'Turn on the light') self.assertFalse(tools[1]['inputSchema']['additionalProperties']) result = json.loads((await self.call(name='entities_status'))['result']['content'][0]['text']) @@ -111,6 +111,90 @@ async def test_only_selected_state_and_named_actions_are_exposed(self): self.assertEqual(next(item for item in result['entities'] if item['entity_id'] == 'sensor.example')['unit'], '°C') self.assertFalse(self.executed) + async def test_readable_tool_name_runs_action_and_old_id_name_still_works(self): + await self.call(1, 'turn_on_the_light') + await self.call(2, 'action_' + 'a' * 32) + self.assertEqual(self.executed, ['a' * 32, 'a' * 32]) + + def test_tool_slug_strips_diacritics_and_clashes_get_id_suffix(self): + self.assertEqual(gateway.tool_slug('Otevřít bránu!'), 'otevrit_branu') + self.policy = policy.Policy.from_dict({'read_entities': [], 'actions': [ + action('a' * 32, 'Brána'), action('b' * 32, 'Brana'), action('c' * 32, '???')]}) + self.gateway.policy = self.policy + self.assertEqual(list(self.gateway.action_tools()), ['brana', 'brana_bbbbbb', 'action_cccccc']) + + async def test_readable_names_cannot_shadow_legacy_action_identifiers(self): + configured = [action('a' * 32, 'First action'), + action('b' * 32, 'action_' + 'a' * 32), + action('c' * 32, 'action_' + 'd' * 32)] + self.gateway.policy = policy.Policy.from_dict({'actions': configured}) + for index, item in enumerate(configured): + response = await self.call(index, 'action_' + item['id']) + self.assertNotIn('error', response) + self.assertEqual(self.executed, [item['id'] for item in configured]) + self.assertIn('error', await self.call(10, 'action_' + 'd' * 32)) + tools = self.gateway.action_tools() + self.assertEqual(len(tools), len(configured)) + for index, (name, item) in enumerate(tools.items(), 20): + await self.call(index, name) + self.assertEqual(self.executed[-1], item.id) + + async def test_suffix_collisions_preserve_every_action_and_literal_slug(self): + configured = [action('a' * 32, 'Brána'), action('b' * 32, 'Brana'), + action('bbbbbb' + 'c' * 26, 'Brana'), action('d' * 32, 'Brana_bbbbbb')] + self.gateway.policy = policy.Policy.from_dict({'actions': configured}) + tools = self.gateway.action_tools() + self.assertEqual(len(tools), len(configured)) + self.assertEqual({item.id for item in tools.values()}, {item['id'] for item in configured}) + self.assertEqual(tools['brana_bbbbbb'].id, 'd' * 32) + self.assertEqual(self.gateway.action_tools(), tools) + for index, (name, item) in enumerate(tools.items()): + self.assertNotIn('error', await self.call(index, name)) + self.assertEqual(self.executed[-1], item.id) + + def test_all_64_actions_keep_unique_bounded_names_after_normalization(self): + for names in (['???'] * 64, ['同じ'] * 64, ['x' * 48 + str(n) for n in range(64)], + ['entities_status'] * 64): + configured = [action('abcdef' + f'{index:026x}', name) for index, name in enumerate(names)] + self.gateway.policy = policy.Policy.from_dict({'actions': configured}) + tools = self.gateway.tools() + self.assertEqual(len(tools), 65) + self.assertEqual(len({tool['name'] for tool in tools}), 65) + self.assertEqual({item.id for item in self.gateway.action_tools().values()}, + {item['id'] for item in configured}) + for tool in tools: + self.assertRegex(tool['name'], r'^[a-z0-9_]{1,128}$') + self.assertEqual(tool['inputSchema'], gateway.EMPTY_INPUT) + + async def test_reserved_status_name_always_remains_read_only(self): + self.gateway.policy = policy.Policy.from_dict({'actions': [action('c' * 32, 'entities_status')]}) + result = await self.call(1, 'entities_status') + self.assertNotIn('error', result) + self.assertEqual(self.executed, []) + name = next(iter(self.gateway.action_tools())) + self.assertNotEqual(name, 'entities_status') + await self.call(2, name) + self.assertEqual(self.executed, ['c' * 32]) + + async def test_retry_with_readable_and_legacy_alias_executes_once(self): + for request_id, first, second in (('legacy-first', 'action_' + 'a' * 32, 'turn_on_the_light'), + ('readable-first', 'turn_on_the_light', 'action_' + 'a' * 32)): + response = await self.call(request_id, first) + self.assertEqual(await self.call(request_id, second), response) + self.assertIn('error', await self.call(request_id, 'run_automation')) + self.assertEqual(self.executed, ['a' * 32, 'a' * 32]) + first, second = await asyncio.gather(self.call('parallel-alias', 'turn_on_the_light'), + self.call('parallel-alias', 'action_' + 'a' * 32)) + self.assertEqual(first, second) + self.assertEqual(len(self.executed), 3) + + async def test_readable_names_keep_authorization_and_fixed_arguments(self): + self.assertIn('error', await self.call(1, 'turn_on_the_light', {'entity_id': 'light.other'})) + self.allowed = False + for name in ('turn_on_the_light', 'action_' + 'a' * 32): + self.assertIn('error', await self.call(2, name)) + self.assertEqual(self.executed, []) + async def test_light_already_on_does_not_block_direct_or_repeated_action(self): first, second = await self.call(1), await self.call(2) self.assertFalse(first['result']['isError']) diff --git a/tests/test_http.py b/tests/test_http.py index 9d4b8fa..b67c15d 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -225,6 +225,27 @@ async def test_stateless_client_reused_ids_do_not_suppress_commands(self): self.assertEqual(self.executed, ['a'*32, 'b'*32, 'a'*32]) self.assertFalse(self.gateway.replies) + async def test_readable_alias_keeps_retry_identity_and_private_diagnostics(self): + name = 'PRIVATE GARAGE ACTION' + self.gateway.policy = policy.Policy.from_dict({'actions': [ + {'id': 'a'*32, 'name': name, 'description': '', 'sequence': [{'variables': {'test': True}}]}]}) + session = await self.initialize() + with self.assertLogs(diagnostics._LOGGER, level='INFO') as captured: + first = await self.view.post(self.request({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call', + 'params': {'name': 'private_garage_action'}}, session=session)) + second = await self.call(session=session) + rejected = await self.view.post(self.request({'jsonrpc': '2.0', 'id': 3, 'method': 'tools/call', + 'params': {'name': 'PRIVATE_UNKNOWN_TOOL'}}, session=session)) + self.assertEqual(first.text, second.text) + self.assertNotIn('error', json.loads(first.text)) + self.assertIn('error', json.loads(rejected.text)) + self.assertEqual(self.executed, ['a'*32]) + log = '\n'.join(captured.output) + self.assertIn('"tool":"configured_action"', log) + self.assertIn('"tool":"other"', log) + for private in (name, 'private_garage_action', 'action_'+'a'*32, 'PRIVATE_UNKNOWN_TOOL', session): + self.assertNotIn(private, log) + async def test_unknown_and_foreign_sessions_fail_before_any_action(self): session = await self.initialize() with self.assertRaises(web.HTTPNotFound):