diff --git a/scripts/build_runtime_package.py b/scripts/build_runtime_package.py index 52f57c180..f0030149a 100755 --- a/scripts/build_runtime_package.py +++ b/scripts/build_runtime_package.py @@ -58,6 +58,7 @@ TOP_LEVEL_MODULES = { "a2a_response", "a2a_tools", "a2a_tools_delegation", + "a2a_tools_identity", "a2a_tools_inbox", "a2a_tools_memory", "a2a_tools_messaging", diff --git a/workspace/a2a_mcp_server.py b/workspace/a2a_mcp_server.py index ce27e982a..eb0d299fb 100644 --- a/workspace/a2a_mcp_server.py +++ b/workspace/a2a_mcp_server.py @@ -35,12 +35,14 @@ from a2a_tools import ( tool_commit_memory, tool_delegate_task, tool_delegate_task_async, + tool_get_runtime_identity, tool_get_workspace_info, tool_inbox_peek, tool_inbox_pop, tool_list_peers, tool_recall_memory, tool_send_message_to_user, + tool_update_agent_card, tool_wait_for_message, ) from platform_tools.registry import TOOLS as _PLATFORM_TOOL_SPECS @@ -166,6 +168,12 @@ async def handle_tool_call(name: str, arguments: dict) -> str: arguments.get("message", ""), workspace_id=arguments.get("workspace_id") or None, ) + elif name == "get_runtime_identity": + return await tool_get_runtime_identity() + elif name == "update_agent_card": + return await tool_update_agent_card( + arguments.get("card"), + ) return f"Unknown tool: {name}" diff --git a/workspace/a2a_tools.py b/workspace/a2a_tools.py index eb26e622f..b6c87e606 100644 --- a/workspace/a2a_tools.py +++ b/workspace/a2a_tools.py @@ -167,3 +167,15 @@ from a2a_tools_inbox import ( # noqa: E402 (import after the top-of-module imp tool_inbox_pop, tool_wait_for_message, ) + + +# Identity tool handlers — extracted to a2a_tools_identity. Ports the +# two T4-tier MCP tools (``tool_get_runtime_identity`` + +# ``tool_update_agent_card``) from molecule-ai-workspace-runtime PR#17. +# That repo is mirror-only (reference_runtime_repo_is_mirror_only); +# this is the canonical edit point, and the wheel mirror is +# regenerated by publish-runtime.yml on merge. +from a2a_tools_identity import ( # noqa: E402 (import after the top-of-module imports) + tool_get_runtime_identity, + tool_update_agent_card, +) diff --git a/workspace/a2a_tools_identity.py b/workspace/a2a_tools_identity.py new file mode 100644 index 000000000..cec89ed00 --- /dev/null +++ b/workspace/a2a_tools_identity.py @@ -0,0 +1,187 @@ +"""Identity tool handlers — single-concern slice of the a2a_tools surface. + +Owns the two MCP tools that close the T4-tier workspace owner-permission +gaps reported via the canvas: + + * ``tool_get_runtime_identity`` — env-only; returns model, model_provider, + molecule_model, anthropic_base_url, tier, workspace_id, runtime + (ADAPTER_MODULE). No HTTP call. Always permitted by RBAC — even + read-only agents may know what model they are. + + * ``tool_update_agent_card`` — POSTs the card to ``/registry/update-card`` + with the workspace's own bearer (same auth path as ``tool_commit_memory`` + via ``a2a_tools_rbac.auth_headers_for_heartbeat``). The platform + replaces the stored card and broadcasts an ``agent_card_updated`` + event so the canvas reflects the new card live. Gated on + ``memory.write`` capability via the existing RBAC permission map so + read-only roles can't silently rewrite the platform card. + +Both originated as a port of molecule-ai-workspace-runtime PR#17 +(``feat(mcp): add update_agent_card + get_runtime_identity tools``). +The mirror-only PR#17 was closed without merge per +``reference_runtime_repo_is_mirror_only``; the canonical edit point is +this monorepo at ``workspace/`` and the wheel mirror is regenerated +automatically by the publish-runtime workflow. + +Imports the auth-header primitive from ``a2a_tools_rbac`` (iter 4a) — +NOT from ``a2a_tools`` — to avoid a circular import with the +kitchen-sink re-export module. +""" +from __future__ import annotations + +import json +import os +from typing import Any + +import httpx + +from a2a_client import PLATFORM_URL +from a2a_tools_rbac import ( + auth_headers_for_heartbeat as _auth_headers_for_heartbeat, + check_memory_write_permission as _check_memory_write_permission, +) + + +def _runtime_identity_payload() -> dict[str, Any]: + """Build the identity dict — env-only, no I/O. + + Factored out from ``tool_get_runtime_identity`` so tests can assert + against the exact key set without re-parsing JSON. The MCP tool + handler ``tool_get_runtime_identity`` is the only public caller in + production; tests call this helper directly. + """ + return { + "model": os.environ.get("MODEL", ""), + "model_provider": os.environ.get("MODEL_PROVIDER", ""), + "molecule_model": os.environ.get("MOLECULE_MODEL", ""), + "anthropic_base_url": os.environ.get("ANTHROPIC_BASE_URL", ""), + "tier": os.environ.get("TIER", ""), + "workspace_id": os.environ.get("WORKSPACE_ID", ""), + # Adapter module is the closest thing the runtime has to a + # "template slug" — e.g. "adapter" for claude-code-default, + # "hermes" for hermes-template, etc. Picked from + # $ADAPTER_MODULE env baked by each template's Dockerfile. + "runtime": os.environ.get("ADAPTER_MODULE", ""), + } + + +async def tool_get_runtime_identity() -> str: + """Return this runtime's identity — model, provider, tier, IDs. + + Env-only; no HTTP call. Useful so the agent can answer "what model + am I?" correctly instead of guessing from a stale system prompt + that the operator may have changed between boots. + + Returns the identity as a JSON-encoded string (the dispatch contract + every MCP tool in this module follows). Tests that want to assert + individual fields can call ``_runtime_identity_payload()`` directly, + or ``json.loads`` the return value. + + Always permitted by RBAC — there is no sensitive information here + that isn't already available to the process via ``os.environ``. + The point of the tool is to surface those env values to the agent + layer in a stable, documented shape rather than expecting every + agent runtime to know to ``echo $MODEL``. + """ + return json.dumps(_runtime_identity_payload(), indent=2) + + +async def tool_update_agent_card(card: Any) -> str: + """Update this workspace's agent_card on the platform. + + POSTs the provided card to ``/registry/update-card`` with the + workspace's own bearer token (same auth path as ``tool_commit_memory`` + and ``tool_get_workspace_info``). The platform validates required + fields server-side, replaces the stored card, and broadcasts an + ``agent_card_updated`` event so the canvas updates live. + + Args: + card: A JSON-serialisable object (typically a dict) holding the + new card. The platform validates required fields server-side. + + Returns: + JSON-encoded string. Body: + - ``{"success": true, "status": "updated"}`` on success; + - ``{"success": false, "error": "", "status_code": }`` + on platform error; + - ``{"success": false, "error": ""}`` on local validation + (non-dict card, missing WORKSPACE_ID, network error). + + Permission gate: this tool requires the ``memory.write`` RBAC + capability — same gate as ``tool_commit_memory``. The check runs + inline rather than at the dispatcher layer to keep ``a2a_mcp_server`` + permission-agnostic (the gate sits with the implementation, not the + transport). Read-only roles get a clear error string back instead + of a 403 from the platform. + + We re-check ``isinstance(card, dict)`` here defensively rather than + trust the MCP schema validator alone — the schema only constrains + the transport, not the in-process call surface used by tests and + sibling modules. + """ + payload = await _update_agent_card_impl(card) + return json.dumps(payload, indent=2) + + +async def _update_agent_card_impl(card: Any) -> dict[str, Any]: + """Dict-returning core of ``tool_update_agent_card``. + + Split out so tests can assert against the raw dict shape (status + codes, error messages) without re-parsing JSON on every assertion. + The string-returning ``tool_update_agent_card`` is a thin wrapper + invoked by the MCP dispatcher. + """ + # RBAC: require memory.write permission. Same gate as + # tool_commit_memory (the agent already needs this capability to + # persist anything outbound). Read-only roles can still call + # get_runtime_identity / get_workspace_info to introspect — those + # are env-only / read-only and have no inline gate. + if not _check_memory_write_permission(): + return { + "success": False, + "error": ( + "RBAC — this workspace does not have the 'memory.write' " + "permission required to update the agent_card." + ), + } + if not isinstance(card, dict): + return { + "success": False, + "error": "card must be a JSON object (dict)", + } + ws_id = os.environ.get("WORKSPACE_ID", "") + if not ws_id: + return { + "success": False, + "error": "WORKSPACE_ID env not set; cannot identify caller", + } + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{PLATFORM_URL}/registry/update-card", + json={"workspace_id": ws_id, "agent_card": card}, + headers=_auth_headers_for_heartbeat(), + ) + if resp.status_code == 200: + body: dict[str, Any] = {} + try: + body = resp.json() + except Exception: + pass + return { + "success": True, + "status": body.get("status", "updated"), + } + # Non-200 — surface what the platform returned. + error_msg = "" + try: + error_msg = resp.json().get("error", "") or resp.text + except Exception: + error_msg = resp.text + return { + "success": False, + "status_code": resp.status_code, + "error": error_msg, + } + except Exception as e: + return {"success": False, "error": f"network error: {e}"} diff --git a/workspace/executor_helpers.py b/workspace/executor_helpers.py index aba334f9c..52ae41b46 100644 --- a/workspace/executor_helpers.py +++ b/workspace/executor_helpers.py @@ -340,6 +340,16 @@ _CLI_A2A_COMMAND_KEYWORDS: dict[str, str | None] = { "delegate_task_async": "delegate --async", "check_task_status": "status", "get_workspace_info": "info", + # `get_runtime_identity` + `update_agent_card` are MCP-first + # capabilities — the CLI subprocess interface doesn't expose them + # today. `get_runtime_identity` is env-only and an agent on a + # CLI-only runtime can already `echo $MODEL` etc, so there's no + # functional gap. `update_agent_card` requires a JSON object + # argument that wouldn't survive a positional-arg shell invocation + # cleanly. Mapped to None — flip to a keyword if a2a_cli grows + # `identity` / `card` subcommands in the future. + "get_runtime_identity": None, + "update_agent_card": None, # `broadcast_message` is not exposed via the CLI subprocess interface # today — it's an MCP-first capability. If a2a_cli grows a `broadcast` # subcommand, map it here and the alignment test will gate the change. diff --git a/workspace/platform_tools/registry.py b/workspace/platform_tools/registry.py index 6550c9e7d..c5b1f08e6 100644 --- a/workspace/platform_tools/registry.py +++ b/workspace/platform_tools/registry.py @@ -57,12 +57,14 @@ from a2a_tools import ( tool_commit_memory, tool_delegate_task, tool_delegate_task_async, + tool_get_runtime_identity, tool_get_workspace_info, tool_inbox_peek, tool_inbox_pop, tool_list_peers, tool_recall_memory, tool_send_message_to_user, + tool_update_agent_card, tool_wait_for_message, ) @@ -289,6 +291,61 @@ _GET_WORKSPACE_INFO = ToolSpec( section=A2A_SECTION, ) +_GET_RUNTIME_IDENTITY = ToolSpec( + name="get_runtime_identity", + short=( + "Return this runtime's identity — model, model_provider, tier, " + "workspace_id, runtime template. Reads from process env; no HTTP call." + ), + when_to_use=( + "Use this to answer 'what model am I?' truthfully instead of " + "guessing from a stale system prompt — the operator may have " + "routed you to a different model via persona env between boots. " + "Always permitted by RBAC: even read-only agents may know what " + "model they are. Distinct from get_workspace_info — that one " + "calls the platform for ID/role/tier/parent (workspace metadata); " + "this one returns the live process env (MODEL, MODEL_PROVIDER, " + "MOLECULE_MODEL, ANTHROPIC_BASE_URL, TIER, WORKSPACE_ID, " + "ADAPTER_MODULE)." + ), + input_schema={"type": "object", "properties": {}}, + impl=tool_get_runtime_identity, + section=A2A_SECTION, +) + +_UPDATE_AGENT_CARD = ToolSpec( + name="update_agent_card", + short=( + "Replace this workspace's agent_card on the platform. The " + "platform validates required fields and broadcasts an " + "agent_card_updated event so the canvas reflects the change live." + ), + when_to_use=( + "Use when the workspace's capabilities, skills, description, or " + "name change and the canvas display needs to follow. The " + "platform stores the new card and pushes an " + "``agent_card_updated`` event to subscribers. Gated behind the " + "``memory.write`` RBAC capability — read-only roles cannot " + "rewrite the card. Tier-1+ owners always have this capability." + ), + input_schema={ + "type": "object", + "properties": { + "card": { + "type": "object", + "description": ( + "The new agent_card object (name, version, " + "description, skills, etc). Server-side validation " + "rejects payloads missing required fields." + ), + }, + }, + "required": ["card"], + }, + impl=tool_update_agent_card, + section=A2A_SECTION, +) + _BROADCAST_MESSAGE = ToolSpec( name="broadcast_message", short=( @@ -642,6 +699,8 @@ TOOLS: list[ToolSpec] = [ _CHECK_TASK_STATUS, _LIST_PEERS, _GET_WORKSPACE_INFO, + _GET_RUNTIME_IDENTITY, + _UPDATE_AGENT_CARD, _BROADCAST_MESSAGE, _SEND_MESSAGE_TO_USER, # Inbox (standalone-only; in-container returns informational error) diff --git a/workspace/tests/snapshots/a2a_instructions_mcp.txt b/workspace/tests/snapshots/a2a_instructions_mcp.txt index 3f0213e1b..92de32fa6 100644 --- a/workspace/tests/snapshots/a2a_instructions_mcp.txt +++ b/workspace/tests/snapshots/a2a_instructions_mcp.txt @@ -5,6 +5,8 @@ - **check_task_status**: Poll the status of a task started with delegate_task_async; returns result when done. - **list_peers**: List the workspaces this agent can communicate with — name, ID, status, role for each. - **get_workspace_info**: Get this workspace's own info — ID, name, role, tier, parent, status. +- **get_runtime_identity**: Return this runtime's identity — model, model_provider, tier, workspace_id, runtime template. Reads from process env; no HTTP call. +- **update_agent_card**: Replace this workspace's agent_card on the platform. The platform validates required fields and broadcasts an agent_card_updated event so the canvas reflects the change live. - **broadcast_message**: Send a message to ALL agent workspaces in the org simultaneously. Requires broadcast_enabled=true on this workspace (set by user/admin). - **send_message_to_user**: Send a message directly to the user's canvas chat — pushed instantly via WebSocket. Use this to: (1) acknowledge a task immediately ('Got it, I'll start working on this'), (2) send interim progress updates while doing long work, (3) deliver follow-up results after delegation completes, (4) attach files (zip, pdf, csv, image) for the user to download via the `attachments` field (NEVER paste file URLs in `message`). The message appears in the user's chat as if you're proactively reaching out. - **wait_for_message**: Block until the next inbound message (canvas user OR peer agent) arrives, or until ``timeout_secs`` elapses. @@ -27,6 +29,12 @@ Call this first when you need to delegate but don't know the target's ID. Access ### get_workspace_info Use to introspect your own identity (e.g. before reporting back to the user, or to determine whether you're a tier-0 root that can write GLOBAL memory). +### get_runtime_identity +Use this to answer 'what model am I?' truthfully instead of guessing from a stale system prompt — the operator may have routed you to a different model via persona env between boots. Always permitted by RBAC: even read-only agents may know what model they are. Distinct from get_workspace_info — that one calls the platform for ID/role/tier/parent (workspace metadata); this one returns the live process env (MODEL, MODEL_PROVIDER, MOLECULE_MODEL, ANTHROPIC_BASE_URL, TIER, WORKSPACE_ID, ADAPTER_MODULE). + +### update_agent_card +Use when the workspace's capabilities, skills, description, or name change and the canvas display needs to follow. The platform stores the new card and pushes an ``agent_card_updated`` event to subscribers. Gated behind the ``memory.write`` RBAC capability — read-only roles cannot rewrite the card. Tier-1+ owners always have this capability. + ### broadcast_message Use for urgent, org-wide signals: critical status changes, emergency stop instructions, coordinated task announcements. Every non-removed workspace receives the message in its activity log (poll-mode agents see it on their next poll; push-mode canvases get a real-time banner). This tool returns an error if broadcast_enabled is false — a user or admin must enable it via the workspace abilities settings first. diff --git a/workspace/tests/test_a2a_tools_identity.py b/workspace/tests/test_a2a_tools_identity.py new file mode 100644 index 000000000..ca8b4dc11 --- /dev/null +++ b/workspace/tests/test_a2a_tools_identity.py @@ -0,0 +1,390 @@ +"""Tests for ``tool_get_runtime_identity`` and ``tool_update_agent_card``. + +These two MCP tools close the T4-tier workspace owner-permission gaps +reported via the canvas: + + - the agent could not update its own ``agent_card`` (no MCP tool + wrapped the existing ``POST /registry/update-card`` endpoint); + - the agent could not identify which model it was running (the + ``MODEL`` env var is injected by ``provisioner.workspace_provision`` + but nothing surfaced it back to the agent). + +Ported from molecule-ai-workspace-runtime PR#17 (mirror-only repo; +canonical edit point per ``reference_runtime_repo_is_mirror_only``). +Adapted to core's conventions: + + * tool functions return ``str`` (JSON-encoded), matching every other + tool in ``a2a_tools_*`` modules. Tests ``json.loads`` to inspect. + * permission check ``memory.write`` runs inline in + ``tool_update_agent_card`` (same pattern as + ``a2a_tools_memory.tool_commit_memory``). + * ``WORKSPACE_ID`` is read directly from ``os.environ`` — core does + not have the runtime's validated-cache layer (``molecule_runtime. + builtin_tools.validation``). +""" +from __future__ import annotations + +import json + +import pytest + + +# --- Drift gate: re-export aliases on a2a_tools ------------------------------ + +class TestBackCompatAliases: + """Pin that ``a2a_tools.tool_*`` resolves to the same callable as + ``a2a_tools_identity.tool_*``. Refactor wrapping (e.g. a doc-string + wrapper that loses the function identity) silently breaks call + sites that ``patch("a2a_tools.tool_update_agent_card", ...)`` — + this gate makes that drift fail fast.""" + + def test_tool_get_runtime_identity_alias(self): + import a2a_tools + import a2a_tools_identity + assert a2a_tools.tool_get_runtime_identity is a2a_tools_identity.tool_get_runtime_identity + + def test_tool_update_agent_card_alias(self): + import a2a_tools + import a2a_tools_identity + assert a2a_tools.tool_update_agent_card is a2a_tools_identity.tool_update_agent_card + + +# --- tool_get_runtime_identity ---------------------------------------------- + +class TestGetRuntimeIdentity: + """The tool returns env-derived runtime identity. No HTTP call.""" + + @pytest.mark.asyncio + async def test_returns_all_known_env_fields(self, monkeypatch): + from a2a_tools_identity import tool_get_runtime_identity + + monkeypatch.setenv("MODEL", "claude-opus-4-7") + monkeypatch.setenv("MODEL_PROVIDER", "anthropic") + monkeypatch.setenv("TIER", "T4") + monkeypatch.setenv("WORKSPACE_ID", "ws-abc") + monkeypatch.setenv("ADAPTER_MODULE", "adapter") + monkeypatch.setenv("MOLECULE_MODEL", "claude-opus-4-7") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + + out = await tool_get_runtime_identity() + # MCP tools return JSON-encoded strings (matches the contract + # every other tool_* in a2a_tools_* uses). + assert isinstance(out, str) + parsed = json.loads(out) + + assert parsed["model"] == "claude-opus-4-7" + assert parsed["model_provider"] == "anthropic" + assert parsed["tier"] == "T4" + assert parsed["workspace_id"] == "ws-abc" + assert parsed["runtime"] == "adapter" + assert parsed["molecule_model"] == "claude-opus-4-7" + assert parsed["anthropic_base_url"] == "https://api.anthropic.com" + + @pytest.mark.asyncio + async def test_missing_env_returns_empty_strings(self, monkeypatch): + """Tool MUST NOT raise when env vars are absent — every key is + present but the value is the empty string. The agent then knows + the slot exists but is unset.""" + from a2a_tools_identity import tool_get_runtime_identity + + for var in ( + "MODEL", "MODEL_PROVIDER", "TIER", "WORKSPACE_ID", + "ADAPTER_MODULE", "MOLECULE_MODEL", "ANTHROPIC_BASE_URL", + ): + monkeypatch.delenv(var, raising=False) + + parsed = json.loads(await tool_get_runtime_identity()) + assert parsed["model"] == "" + assert parsed["model_provider"] == "" + assert parsed["tier"] == "" + assert parsed["workspace_id"] == "" + assert parsed["runtime"] == "" + assert parsed["molecule_model"] == "" + assert parsed["anthropic_base_url"] == "" + + @pytest.mark.asyncio + async def test_no_http_call_made(self, monkeypatch): + """``get_runtime_identity`` is env-only — must not open + httpx.AsyncClient even if the call would otherwise succeed. + Tripwire any client construction.""" + import httpx + + from a2a_tools_identity import tool_get_runtime_identity + + class _Tripwire: + def __init__(self, *_a, **_kw): + raise AssertionError( + "tool_get_runtime_identity must not open httpx.AsyncClient" + ) + + monkeypatch.setattr(httpx, "AsyncClient", _Tripwire) + # Must not raise. + await tool_get_runtime_identity() + + @pytest.mark.asyncio + async def test_helper_dict_matches_string_payload(self, monkeypatch): + """``_runtime_identity_payload`` is the dict-returning helper + used by both the public tool and tests. Verify the public tool + json.dumps the same dict — no field is dropped or renamed by + the encoding step.""" + from a2a_tools_identity import ( + _runtime_identity_payload, + tool_get_runtime_identity, + ) + + monkeypatch.setenv("MODEL", "claude-opus-4-7") + monkeypatch.setenv("TIER", "T4") + monkeypatch.setenv("WORKSPACE_ID", "ws-helper-check") + + helper = _runtime_identity_payload() + tool_str = await tool_get_runtime_identity() + assert json.loads(tool_str) == helper + + +# --- tool_update_agent_card ------------------------------------------------- + + +class _MockResponse: + def __init__(self, status_code: int, payload: dict): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) + + def json(self): + return self._payload + + +class _MockClient: + """Drop-in for httpx.AsyncClient context manager. + + Records the URL + json body + headers the tool POSTed so the test + can assert against them. Returns the canned _MockResponse passed + in at construction time. + """ + + def __init__(self, *, response: _MockResponse, captured: dict): + self._response = response + self._captured = captured + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, url, *, json=None, headers=None, **_kw): # noqa: A002 + self._captured["url"] = url + self._captured["json"] = json + self._captured["headers"] = headers + return self._response + + +@pytest.fixture +def _grant_memory_write(monkeypatch): + """Force the inline RBAC gate inside ``tool_update_agent_card`` to + succeed. The gate calls + ``a2a_tools_rbac.check_memory_write_permission`` which inspects + ``$MOLECULE_ROLES`` / the role table; the patch sidesteps that + machinery so tests can focus on the platform-call shape. + """ + import a2a_tools_identity + monkeypatch.setattr( + a2a_tools_identity, "_check_memory_write_permission", lambda: True + ) + + +class TestUpdateAgentCard: + @pytest.mark.asyncio + async def test_posts_to_registry_update_card( + self, monkeypatch, _grant_memory_write, + ): + """Hits POST {PLATFORM_URL}/registry/update-card with the + workspace bearer and the {workspace_id, agent_card} body shape + the platform handler expects (workspace-server + ``internal/handlers/registry.go``).""" + import a2a_tools_identity + + monkeypatch.setenv("WORKSPACE_ID", "ws-42") + # Ensure PLATFORM_URL re-import sees a deterministic value — + # a2a_client imports it at module load so we patch the symbol + # on a2a_tools_identity directly (the module's own reference). + monkeypatch.setattr(a2a_tools_identity, "PLATFORM_URL", "http://test.invalid") + + captured: dict = {} + response = _MockResponse(200, {"status": "updated"}) + + def _client_factory(*_a, **_kw): + return _MockClient(response=response, captured=captured) + + monkeypatch.setattr(a2a_tools_identity.httpx, "AsyncClient", _client_factory) + monkeypatch.setattr( + a2a_tools_identity, "_auth_headers_for_heartbeat", + lambda: {"Authorization": "Bearer ws-token-xyz"}, + ) + + card = {"name": "agent-foo", "version": "0.1.0", "description": "demo"} + result_str = await a2a_tools_identity.tool_update_agent_card(card) + result = json.loads(result_str) + + # URL: PLATFORM_URL + /registry/update-card + assert captured["url"] == "http://test.invalid/registry/update-card" + + # The platform handler expects {workspace_id, agent_card}; the + # agent_card is the raw object the agent submitted. + body = captured["json"] + assert body["workspace_id"] == "ws-42" + assert body["agent_card"] == card + + # Auth header from auth_headers_for_heartbeat is forwarded + # verbatim — same path commit_memory uses. + assert captured["headers"]["Authorization"] == "Bearer ws-token-xyz" + + assert result["success"] is True + assert result["status"] == "updated" + + @pytest.mark.asyncio + async def test_propagates_server_error( + self, monkeypatch, _grant_memory_write, + ): + """Non-200 from platform surfaces as a structured error to the + agent. The agent sees {success:false, status_code, error} and + can decide whether to retry, fall back, or escalate.""" + import a2a_tools_identity + + monkeypatch.setenv("WORKSPACE_ID", "ws-42") + monkeypatch.setattr(a2a_tools_identity, "PLATFORM_URL", "http://test.invalid") + + captured: dict = {} + response = _MockResponse(400, {"error": "invalid card"}) + + monkeypatch.setattr( + a2a_tools_identity.httpx, "AsyncClient", + lambda *a, **kw: _MockClient(response=response, captured=captured), + ) + monkeypatch.setattr( + a2a_tools_identity, "_auth_headers_for_heartbeat", lambda: {}, + ) + + result = json.loads( + await a2a_tools_identity.tool_update_agent_card({"name": "x"}) + ) + assert result["success"] is False + assert result["status_code"] == 400 + assert "invalid card" in str(result["error"]).lower() + + @pytest.mark.asyncio + async def test_rejects_non_dict_card(self, _grant_memory_write): + """The MCP schema constrains transport callers to pass a dict; + in-process callers (tests, sibling modules) can still pass any + type. Reject non-dict defensively so the platform isn't asked + to validate JSON-encoded strings or lists.""" + from a2a_tools_identity import tool_update_agent_card + + result = json.loads(await tool_update_agent_card("not-a-dict")) + assert result["success"] is False + assert "dict" in str(result["error"]).lower() + + @pytest.mark.asyncio + async def test_workspace_id_missing_returns_error( + self, monkeypatch, _grant_memory_write, + ): + """If WORKSPACE_ID is not set the tool refuses to issue the + request — it would otherwise POST with an empty workspace_id + and let the platform return a confusing 400.""" + from a2a_tools_identity import tool_update_agent_card + + monkeypatch.delenv("WORKSPACE_ID", raising=False) + + result = json.loads(await tool_update_agent_card({"name": "x"})) + assert result["success"] is False + assert "workspace_id" in str(result["error"]).lower() + + @pytest.mark.asyncio + async def test_denies_when_memory_write_permission_missing(self, monkeypatch): + """The agent's RBAC role must grant ``memory.write`` to update + the card. Read-only roles get an RBAC error string back + immediately, never touching the platform.""" + import a2a_tools_identity + + monkeypatch.setenv("WORKSPACE_ID", "ws-42") + monkeypatch.setattr( + a2a_tools_identity, "_check_memory_write_permission", lambda: False, + ) + + # Tripwire httpx — must not be called when RBAC denies. + import httpx + + class _Tripwire: + def __init__(self, *_a, **_kw): + raise AssertionError("RBAC denial must short-circuit before httpx call") + + monkeypatch.setattr(httpx, "AsyncClient", _Tripwire) + + result = json.loads( + await a2a_tools_identity.tool_update_agent_card({"name": "x"}), + ) + assert result["success"] is False + assert "memory.write" in str(result["error"]).lower() + + @pytest.mark.asyncio + async def test_network_exception_returns_structured_error( + self, monkeypatch, _grant_memory_write, + ): + """A network exception (DNS failure, connect timeout, etc) is + wrapped into a structured error dict instead of bubbling up + to the MCP transport layer.""" + import a2a_tools_identity + + monkeypatch.setenv("WORKSPACE_ID", "ws-42") + monkeypatch.setattr(a2a_tools_identity, "PLATFORM_URL", "http://test.invalid") + + class _ExplodingClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_a): + return False + + async def post(self, *_a, **_kw): + raise RuntimeError("simulated DNS failure") + + monkeypatch.setattr( + a2a_tools_identity.httpx, "AsyncClient", + lambda *a, **kw: _ExplodingClient(), + ) + + result = json.loads( + await a2a_tools_identity.tool_update_agent_card({"name": "x"}) + ) + assert result["success"] is False + assert "network" in str(result["error"]).lower() + + +# --- Registry contract ------------------------------------------------------ + + +class TestRegistryContract: + """Pin the new tools' registration in platform_tools.registry. The + structural tests in ``test_platform_tools.py`` already check + registry↔MCP alignment; these are tighter assertions specific to + the two new tools so a future contributor deleting one entry sees + a focused failure.""" + + def test_get_runtime_identity_in_registry(self): + from platform_tools.registry import by_name + spec = by_name("get_runtime_identity") + assert spec.section == "a2a" + # No input parameters — env-only call. + assert spec.input_schema == {"type": "object", "properties": {}} + # impl points at the actual tool function, not a shim. + from a2a_tools_identity import tool_get_runtime_identity + assert spec.impl is tool_get_runtime_identity + + def test_update_agent_card_in_registry(self): + from platform_tools.registry import by_name + spec = by_name("update_agent_card") + assert spec.section == "a2a" + assert "card" in spec.input_schema["properties"] + assert spec.input_schema["required"] == ["card"] + from a2a_tools_identity import tool_update_agent_card + assert spec.impl is tool_update_agent_card