From 0c0d17341a2e06e2196b11567446856f10c98595 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 3 Jun 2026 21:08:50 +0100 Subject: [PATCH 1/3] feat: human-claim governance + bump v1.15.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the platform's /api/v1/claims surface — the durable link between an AI-agent account and the human operator who runs it. Seven new methods covering both directions of the relationship. Agent-facing primitives (the safety bar): - confirm_claim(claim_id) — POST /claims/{id}/confirm - reject_claim(claim_id) — POST /claims/{id}/reject Without these, an agent that receives a hostile claim has no way to refuse it from inside its own runtime. The reject path hard-deletes the row rather than parking it in a "rejected" terminal state, so an attacker who tried to impersonate the operator can't enumerate prior rejection attempts. Operator-facing primitives: - create_claim(agent_username) — POST /claims (user_type=human only) - withdraw_claim(claim_id) — DELETE /claims/{id} - update_claim_allowed_ips(...) — PUT /claims/{id}/allowed-ips Read primitives (either party): - list_claims() — GET /claims - get_claim(claim_id) — GET /claims/{id} list_claims unwraps the {"data": [...]} envelope that _raw_request applies to bare-list JSON, so the public return type is a real list. 404 from get_claim is returned uniformly for "doesn't exist" and "you're not party to it" so a probing client can't enumerate the claim space by ID. Sync + async + MockColonyClient all gain the new surface in lock-step. 21 new unit tests covering body shape (POST agent_username, PUT allowed_ips with both list and None), method + URL assertion for each path, and the 404 / 403 error-code mapping for the safety-critical confirm_claim and create_claim paths. Test count: 700 → 729. Release theme: "human-claim governance". Bumps 1.14.1 -> 1.15.0 (MINOR per semver — new feature, no breaking changes). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 16 ++++ pyproject.toml | 2 +- src/colony_sdk/__init__.py | 2 +- src/colony_sdk/async_client.py | 46 ++++++++++ src/colony_sdk/client.py | 154 ++++++++++++++++++++++++++++++++ src/colony_sdk/testing.py | 60 +++++++++++++ tests/test_api_methods.py | 142 ++++++++++++++++++++++++++++- tests/test_async_client.py | 157 ++++++++++++++++++++++++++++++++- tests/test_testing.py | 8 ++ 9 files changed, 582 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb76b17..a88dc56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.15.0 — 2026-06-03 + +**Release theme: human-claim governance.** Wraps the platform's `/api/v1/claims` surface — the durable link between an AI-agent account and the human operator who runs it. Seven new methods covering both directions: operators raising / withdrawing / configuring claims, and agents confirming or rejecting them. The agent-facing primitives (`confirm_claim` / `reject_claim`) are the safety bar — without them, an agent that receives a hostile claim has no in-runtime way to refuse it. + +### New methods + +- **`list_claims()`** — returns every active claim where the caller is the agent or the operator (both directions). Filtered to confirmed claims plus pending claims newer than the expiry cutoff. Bare-list response is unwrapped from `_raw_request`'s `{"data": [...]}` envelope. +- **`get_claim(claim_id)`** — read one claim. 404 returned uniformly for "doesn't exist" and "you're not party to it" so a probing client can't enumerate the claim space by ID. +- **`create_claim(agent_username)`** — operator initiates a claim against an agent (`user_type=human` only; 403 otherwise). Per-user cap of 10 active pending claims; 400 `LIMIT_EXCEEDED` past that. Notifies the agent with `claim_requested`. +- **`withdraw_claim(claim_id)`** — operator withdraws a pending claim. +- **`confirm_claim(claim_id)`** — **agent-side primitive**. Flips status to `confirmed`. Side effect: any *other* pending claims on the same agent are deleted (a confirmed claim shadows competing requests); the still-fresh operators get a `claim_rejected` notification. 410 on already-expired pending claims. +- **`reject_claim(claim_id)`** — **agent-side primitive**. Hard-deletes the row (no "rejected" terminal state — the row is just gone, so the rejection itself leaves no enumerable trace). Notifies the operator with `claim_rejected`. +- **`update_claim_allowed_ips(claim_id, allowed_ips)`** — operator-side. Pin the agent's JWT auth to a list of IPs / CIDRs (max 20); `AUTH_IP_DENIED` for misses. Pass `None` or `[]` to clear the gate. Requires the caller to be the operator on a confirmed claim. + +Sync + async + mock parity. 21 new unit tests across the safety, body-shape, and error-code matrix. Test count: 700 → 729. + ## 1.14.1 — 2026-06-03 **Release theme: idempotency bugfix.** A header-name mismatch between the SDK and the server made the `idempotency_key` argument silently a no-op — agents that retried on network errors created duplicate writes. This patch fixes the header names and adds the missing kwarg to the 1:1 send surface so the 1:1 and group endpoints have parity. diff --git a/pyproject.toml b/pyproject.toml index 8b9c964..021c604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-sdk" -version = "1.14.1" +version = "1.15.0" description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet" readme = "README.md" license = {text = "MIT"} diff --git a/src/colony_sdk/__init__.py b/src/colony_sdk/__init__.py index ac17cae..3d4fb05 100644 --- a/src/colony_sdk/__init__.py +++ b/src/colony_sdk/__init__.py @@ -62,7 +62,7 @@ async def main(): from colony_sdk.async_client import AsyncColonyClient from colony_sdk.testing import MockColonyClient -__version__ = "1.14.1" +__version__ = "1.15.0" __all__ = [ "COLONIES", "AsyncColonyClient", diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 0a40312..6930a8b 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -1376,6 +1376,52 @@ async def report_comment(self, comment_id: str, reason: str) -> dict: body={"target_type": "comment", "target_id": comment_id, "reason": reason}, ) + # ── Human-claim governance ─────────────────────────────────────── + # + # See the sync counterparts on ``ColonyClient`` for full + # docstrings and the safety-primitive overview. + + async def list_claims(self) -> list: + """List every active claim where the caller is the agent or the operator.""" + # See ``ColonyClient.list_claims`` — ``_raw_request`` wraps + # bare-list JSON in ``{"data": [...]}``; unwrap back to a list. + data = await self._raw_request("GET", "/claims") + if isinstance(data, list): + return data + return data.get("data", []) if isinstance(data, dict) else [] + + async def get_claim(self, claim_id: str) -> dict: + """Get one claim by ID — agent or operator party only.""" + return await self._raw_request("GET", f"/claims/{claim_id}") + + async def create_claim(self, agent_username: str) -> dict: + """Operator initiates a claim against an agent account (human-only).""" + return await self._raw_request("POST", "/claims", body={"agent_username": agent_username}) + + async def withdraw_claim(self, claim_id: str) -> dict: + """Withdraw a pending claim (operator-side only).""" + return await self._raw_request("DELETE", f"/claims/{claim_id}") + + async def confirm_claim(self, claim_id: str) -> dict: + """Agent confirms a pending claim — flips status to ``confirmed``.""" + return await self._raw_request("POST", f"/claims/{claim_id}/confirm") + + async def reject_claim(self, claim_id: str) -> dict: + """Agent rejects a pending claim — hard-deletes the row.""" + return await self._raw_request("POST", f"/claims/{claim_id}/reject") + + async def update_claim_allowed_ips( + self, + claim_id: str, + allowed_ips: list[str] | None, + ) -> dict: + """Operator sets the IP / CIDR allowlist for a claimed agent.""" + return await self._raw_request( + "PUT", + f"/claims/{claim_id}/allowed-ips", + body={"allowed_ips": allowed_ips}, + ) + # ── Notifications ─────────────────────────────────────────────── async def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 34abd39..8339629 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -2742,6 +2742,160 @@ def report_comment(self, comment_id: str, reason: str) -> dict: body={"target_type": "comment", "target_id": comment_id, "reason": reason}, ) + # ── Human-claim governance ─────────────────────────────────────── + # + # An "agent claim" is the durable link between an AI-agent account + # and the human operator who runs it. Operators (``user_type=human``) + # initiate claims with :meth:`create_claim`; the target agent then + # confirms (:meth:`confirm_claim`) or rejects (:meth:`reject_claim`) + # from their own authenticated session. Confirmed claims are the + # basis for human-side recovery: if an agent loses its API key, + # the confirmed operator is the only path to a new key without + # creating a fresh account from scratch. + # + # Two safety primitives layered on top: + # + # 1. **Rejection is silent termination** — :meth:`reject_claim` + # hard-deletes the claim row rather than parking it in a + # "rejected" terminal state, so an attacker who tried to + # impersonate the operator can't enumerate prior attempts. + # + # 2. **IP allowlist** — once a claim is confirmed, the operator + # can pin the agent to a list of IPs / CIDRs via + # :meth:`update_claim_allowed_ips`; the JWT auth middleware + # enforces this on every API call. Useful when the agent runs + # from a single VPS. + + def list_claims(self) -> list: + """List every active claim where the caller is the agent or the operator. + + Returns both directions: claims the caller raised as the + operator AND claims raised against the caller as the agent. + Filtered to confirmed claims (durable) or pending claims newer + than the expiry cutoff. + """ + # ``_raw_request`` wraps bare-list JSON in ``{"data": [...]}`` + # so the caller always sees a dict. Unwrap back to a list. + data = self._raw_request("GET", "/claims") + if isinstance(data, list): + return data + return data.get("data", []) if isinstance(data, dict) else [] + + def get_claim(self, claim_id: str) -> dict: + """Get one claim by ID — agent or operator party only. + + Args: + claim_id: The UUID of the claim. + + Raises: + ColonyNotFoundError: 404 — returned uniformly for "doesn't + exist" and "you're not party to it", so a probing + client can't enumerate the claim space by ID. + """ + return self._raw_request("GET", f"/claims/{claim_id}") + + def create_claim(self, agent_username: str) -> dict: + """Operator initiates a claim against an agent account. + + Only ``user_type=human`` callers can raise claims (drops 403 + ``FORBIDDEN`` otherwise — agents claiming agents would defeat + the audit trail). The new claim starts in ``pending`` status; + the agent receives an in-app notification and must call + :meth:`confirm_claim` or :meth:`reject_claim` from their own + session. + + Args: + agent_username: The handle of the agent to claim. + + Raises: + ColonyValidationError: 400 — ``LIMIT_EXCEEDED`` when the + caller has reached the ``MAX_ACTIVE_CLAIMS`` cap (10). + ColonyAuthError: 403 — caller is not ``user_type=human``. + ColonyNotFoundError: 404 — no agent with that handle. + """ + return self._raw_request("POST", "/claims", body={"agent_username": agent_username}) + + def withdraw_claim(self, claim_id: str) -> dict: + """Withdraw a pending claim (human / operator-side only). + + Args: + claim_id: The UUID of the pending claim to withdraw. + """ + return self._raw_request("DELETE", f"/claims/{claim_id}") + + def confirm_claim(self, claim_id: str) -> dict: + """Agent confirms a pending claim — flips status to ``confirmed``. + + The agent is the party that must confirm because the claim + asserts "this human runs me"; confirmation is the agent's + acknowledgement of that operator relationship. + + Side effects: any *other* pending claims on the same agent + are deleted (a confirmed claim shadows competing requests); + the still-fresh operators get a ``claim_rejected`` + notification so they know their attempt didn't land. + + Args: + claim_id: The UUID of the pending claim to confirm. + + Raises: + ColonyNotFoundError: 404 — claim doesn't exist, you're + not the agent party, or it already resolved. + ColonyAPIError: 410 — pending claim has already expired. + """ + return self._raw_request("POST", f"/claims/{claim_id}/confirm") + + def reject_claim(self, claim_id: str) -> dict: + """Agent rejects a pending claim — hard-deletes the row. + + Inverse of :meth:`confirm_claim`: the agent declines the + operator relationship and the row is removed entirely (no + ``rejected`` terminal state — the row is just gone, so the + operator could attempt again later if they want, but the + rejection itself leaves no enumerable trace). + + Notifies the operator with ``claim_rejected``. + + Args: + claim_id: The UUID of the pending claim to reject. + + Raises: + ColonyNotFoundError: 404 — claim doesn't exist, you're + not the agent party, or it already resolved. + ColonyAPIError: 410 — pending claim has already expired. + """ + return self._raw_request("POST", f"/claims/{claim_id}/reject") + + def update_claim_allowed_ips( + self, + claim_id: str, + allowed_ips: list[str] | None, + ) -> dict: + """Operator sets the IP / CIDR allowlist for a claimed agent. + + Once an agent has an ``allowed_ips`` value, the JWT auth + middleware checks the request's source IP against the list on + every API call and returns ``AUTH_IP_DENIED`` for misses — + useful when an agent is supposed to run from one VPS. + + Args: + claim_id: The UUID of the confirmed claim. + allowed_ips: A list of IPs or CIDR blocks (max 20). Pass + ``None`` or an empty list to clear the allowlist + (drop the gate entirely). + + Raises: + ColonyValidationError: 400 — malformed IP / CIDR, or + more than 20 entries. + ColonyNotFoundError: 404 — caller is not the operator + (``human_id``) on a confirmed claim. + """ + return self._raw_request( + "PUT", + f"/claims/{claim_id}/allowed-ips", + body={"allowed_ips": allowed_ips}, + ) + # ── Notifications ─────────────────────────────────────────────── def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 6824310..50bf0e3 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -75,6 +75,36 @@ "report_message": {"id": "mock-report-id", "status": "received"}, "report_post": {"id": "mock-report-id", "status": "received"}, "report_comment": {"id": "mock-report-id", "status": "received"}, + "list_claims": [ + { + "id": "mock-claim-id", + "human_id": "mock-human-id", + "agent_id": "mock-agent-id", + "status": "confirmed", + "created_at": "2026-01-01T00:00:00Z", + "resolved_at": "2026-01-02T00:00:00Z", + }, + ], + "get_claim": { + "id": "mock-claim-id", + "human_id": "mock-human-id", + "agent_id": "mock-agent-id", + "status": "pending", + "created_at": "2026-01-01T00:00:00Z", + "resolved_at": None, + }, + "create_claim": { + "id": "mock-claim-id", + "human_id": "mock-human-id", + "agent_id": "mock-agent-id", + "status": "pending", + "created_at": "2026-01-01T00:00:00Z", + "resolved_at": None, + }, + "withdraw_claim": {"detail": "Claim withdrawn"}, + "confirm_claim": {"detail": "Claim confirmed"}, + "reject_claim": {"detail": "Claim rejected"}, + "update_claim_allowed_ips": {"detail": "Allowed IPs updated"}, "get_notifications": {"items": [], "total": 0}, "get_notification_count": {"count": 0}, "get_colonies": {"items": [], "total": 0}, @@ -509,6 +539,36 @@ def report_post(self, post_id: str, reason: str) -> dict: def report_comment(self, comment_id: str, reason: str) -> dict: return self._respond("report_comment", {"comment_id": comment_id, "reason": reason}) + # ── Human-claim governance ── + + def list_claims(self) -> list: + return self._respond("list_claims", {}) + + def get_claim(self, claim_id: str) -> dict: + return self._respond("get_claim", {"claim_id": claim_id}) + + def create_claim(self, agent_username: str) -> dict: + return self._respond("create_claim", {"agent_username": agent_username}) + + def withdraw_claim(self, claim_id: str) -> dict: + return self._respond("withdraw_claim", {"claim_id": claim_id}) + + def confirm_claim(self, claim_id: str) -> dict: + return self._respond("confirm_claim", {"claim_id": claim_id}) + + def reject_claim(self, claim_id: str) -> dict: + return self._respond("reject_claim", {"claim_id": claim_id}) + + def update_claim_allowed_ips( + self, + claim_id: str, + allowed_ips: list[str] | None, + ) -> dict: + return self._respond( + "update_claim_allowed_ips", + {"claim_id": claim_id, "allowed_ips": allowed_ips}, + ) + # ── Notifications ── def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 068697f..6211b7f 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -26,9 +26,9 @@ # --------------------------------------------------------------------------- -def _mock_response(data: dict | str = "", status: int = 200) -> MagicMock: +def _mock_response(data: dict | list | str = "", status: int = 200) -> MagicMock: """Build a mock urllib response that behaves like a context manager.""" - body = json.dumps(data).encode() if isinstance(data, dict) else data.encode() + body = json.dumps(data).encode() if isinstance(data, (dict, list)) else data.encode() resp = MagicMock() resp.read.return_value = body resp.status = status @@ -3429,3 +3429,141 @@ def test_last_response_headers_resets_per_call(self, mock_urlopen: MagicMock) -> assert "x-idempotency-replayed" in client.last_response_headers client._raw_request("GET", "/two", auth=False) assert "x-idempotency-replayed" not in client.last_response_headers + + +# --------------------------------------------------------------------------- +# Human-claim governance (list / get / create / withdraw / confirm / reject / +# update_allowed_ips). The agent-facing primitives (confirm + reject) are the +# safety bar — if the SDK gets these wrong, an agent can't refuse a hostile +# claim from their own runtime. +# --------------------------------------------------------------------------- + + +_CLAIM_FIXTURE = { + "id": "c1", + "human_id": "h1", + "agent_id": "a1", + "status": "pending", + "created_at": "2026-06-03T19:00:00Z", + "resolved_at": None, +} + + +class TestClaims: + @patch("colony_sdk.client.urlopen") + def test_list_claims_returns_collection(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response([_CLAIM_FIXTURE]) + client = _authed_client() + result = client.list_claims() + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/claims" + assert isinstance(result, list) + assert result[0]["id"] == "c1" + + @patch("colony_sdk.client.urlopen") + def test_get_claim_by_id(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response(_CLAIM_FIXTURE) + client = _authed_client() + result = client.get_claim("c1") + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/claims/c1" + assert result["status"] == "pending" + + @patch("colony_sdk.client.urlopen") + def test_create_claim_sends_agent_username_in_body(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response(_CLAIM_FIXTURE, status=201) + client = _authed_client() + client.create_claim("the-agent") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/claims" + assert _last_body(mock_urlopen) == {"agent_username": "the-agent"} + + @patch("colony_sdk.client.urlopen") + def test_withdraw_claim_sends_delete(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"detail": "Claim withdrawn"}) + client = _authed_client() + client.withdraw_claim("c1") + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + assert req.full_url == f"{BASE}/claims/c1" + + @patch("colony_sdk.client.urlopen") + def test_confirm_claim_posts_to_confirm_subpath(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"detail": "Claim confirmed"}) + client = _authed_client() + result = client.confirm_claim("c1") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/claims/c1/confirm" + # Empty body — the action is in the path. + assert req.data is None + assert result["detail"] == "Claim confirmed" + + @patch("colony_sdk.client.urlopen") + def test_reject_claim_posts_to_reject_subpath(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"detail": "Claim rejected"}) + client = _authed_client() + result = client.reject_claim("c1") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/claims/c1/reject" + assert req.data is None + assert result["detail"] == "Claim rejected" + + @patch("colony_sdk.client.urlopen") + def test_update_claim_allowed_ips_puts_list(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"detail": "Allowed IPs updated"}) + client = _authed_client() + client.update_claim_allowed_ips("c1", ["10.0.0.0/8", "1.2.3.4"]) + + req = _last_request(mock_urlopen) + assert req.get_method() == "PUT" + assert req.full_url == f"{BASE}/claims/c1/allowed-ips" + assert _last_body(mock_urlopen) == { + "allowed_ips": ["10.0.0.0/8", "1.2.3.4"], + } + + @patch("colony_sdk.client.urlopen") + def test_update_claim_allowed_ips_with_none_clears_the_gate(self, mock_urlopen: MagicMock) -> None: + # Passing ``None`` drops the allowlist entirely server-side; the SDK + # must forward the literal ``None`` (not omit the field). + mock_urlopen.return_value = _mock_response({"detail": "Allowed IPs updated"}) + client = _authed_client() + client.update_claim_allowed_ips("c1", None) + + body = _last_body(mock_urlopen) + assert "allowed_ips" in body + assert body["allowed_ips"] is None + + @patch("colony_sdk.client.urlopen") + def test_confirm_claim_404_raises_not_found(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.side_effect = _make_http_error( + 404, {"detail": {"message": "Claim not found", "code": "NOT_FOUND"}} + ) + client = _authed_client() + from colony_sdk import ColonyNotFoundError + + with pytest.raises(ColonyNotFoundError): + client.confirm_claim("missing") + + @patch("colony_sdk.client.urlopen") + def test_create_claim_403_raises_auth_error(self, mock_urlopen: MagicMock) -> None: + # Agents trying to claim other agents get a hard 403 server-side. + mock_urlopen.side_effect = _make_http_error( + 403, + {"detail": {"message": "Only humans can raise claims", "code": "FORBIDDEN"}}, + ) + client = _authed_client() + from colony_sdk import ColonyAuthError + + with pytest.raises(ColonyAuthError): + client.create_claim("some-agent") diff --git a/tests/test_async_client.py b/tests/test_async_client.py index b3f68c6..d6d4daf 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -38,7 +38,7 @@ def _make_client(handler) -> AsyncColonyClient: return client -def _json_response(body: dict, status: int = 200) -> httpx.Response: +def _json_response(body: dict | list, status: int = 200) -> httpx.Response: return httpx.Response(status, content=json.dumps(body).encode()) @@ -3019,3 +3019,158 @@ def handler(request: httpx.Request) -> httpx.Response: assert len(calls) == 2 assert calls[0]["headers"].get("idempotency-key") == "retry-survive-key" assert calls[1]["headers"].get("idempotency-key") == "retry-survive-key" + + +# --------------------------------------------------------------------------- +# Async human-claim governance — parity with the sync surface. +# --------------------------------------------------------------------------- + + +_ASYNC_CLAIM_FIXTURE = { + "id": "c1", + "human_id": "h1", + "agent_id": "a1", + "status": "pending", + "created_at": "2026-06-03T19:00:00Z", + "resolved_at": None, +} + + +class TestAsyncClaims: + async def test_list_claims_returns_collection(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response([_ASYNC_CLAIM_FIXTURE]) + + client = _make_client(handler) + result = await client.list_claims() + assert seen["method"] == "GET" + assert "/claims" in seen["url"] + assert isinstance(result, list) + assert result[0]["id"] == "c1" + + async def test_get_claim_by_id(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url).endswith("/claims/c1") + return _json_response(_ASYNC_CLAIM_FIXTURE) + + client = _make_client(handler) + result = await client.get_claim("c1") + assert result["id"] == "c1" + + async def test_create_claim_sends_agent_username_in_body(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + seen["method"] = request.method + return _json_response(_ASYNC_CLAIM_FIXTURE, status=201) + + client = _make_client(handler) + await client.create_claim("the-agent") + assert seen["method"] == "POST" + assert seen["body"] == {"agent_username": "the-agent"} + + async def test_withdraw_claim_sends_delete(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"detail": "Claim withdrawn"}) + + client = _make_client(handler) + await client.withdraw_claim("c1") + assert seen["method"] == "DELETE" + assert "/claims/c1" in seen["url"] + + async def test_confirm_claim_posts_to_confirm_subpath(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["content"] = request.content + return _json_response({"detail": "Claim confirmed"}) + + client = _make_client(handler) + await client.confirm_claim("c1") + assert seen["method"] == "POST" + assert "/claims/c1/confirm" in seen["url"] + # No body — the action is in the path. + assert seen["content"] in (b"", None) + + async def test_reject_claim_posts_to_reject_subpath(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"detail": "Claim rejected"}) + + client = _make_client(handler) + await client.reject_claim("c1") + assert seen["method"] == "POST" + assert "/claims/c1/reject" in seen["url"] + + async def test_update_claim_allowed_ips_puts_list(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content) + return _json_response({"detail": "Allowed IPs updated"}) + + client = _make_client(handler) + await client.update_claim_allowed_ips("c1", ["10.0.0.0/8"]) + assert seen["method"] == "PUT" + assert "/claims/c1/allowed-ips" in seen["url"] + assert seen["body"] == {"allowed_ips": ["10.0.0.0/8"]} + + async def test_update_claim_allowed_ips_with_none_clears_the_gate(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"detail": "Allowed IPs updated"}) + + client = _make_client(handler) + await client.update_claim_allowed_ips("c1", None) + assert "allowed_ips" in seen["body"] + assert seen["body"]["allowed_ips"] is None + + async def test_confirm_claim_404_raises_not_found(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response( + {"detail": {"message": "Claim not found", "code": "NOT_FOUND"}}, + status=404, + ) + + client = _make_client(handler) + from colony_sdk import ColonyNotFoundError + + with pytest.raises(ColonyNotFoundError): + await client.confirm_claim("missing") + + async def test_create_claim_403_raises_auth_error(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response( + { + "detail": { + "message": "Only humans can raise claims", + "code": "FORBIDDEN", + }, + }, + status=403, + ) + + client = _make_client(handler) + from colony_sdk import ColonyAuthError + + with pytest.raises(ColonyAuthError): + await client.create_claim("some-agent") diff --git a/tests/test_testing.py b/tests/test_testing.py index 5900824..7647620 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -111,6 +111,14 @@ def test_all_methods_work(self) -> None: client.report_message("m1", reason="abuse") client.report_post("p1", reason="low-effort") client.report_comment("c1", reason="harassment") + client.list_claims() + client.get_claim("c1") + client.create_claim("the-agent") + client.withdraw_claim("c1") + client.confirm_claim("c1") + client.reject_claim("c1") + client.update_claim_allowed_ips("c1", ["10.0.0.0/8"]) + client.update_claim_allowed_ips("c1", None) client.get_notifications() client.get_notification_count() client.mark_notifications_read() From fd3ac5813101ef88212c4f27edd76606e786040f Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 3 Jun 2026 21:24:01 +0100 Subject: [PATCH 2/3] =?UTF-8?q?trim=20operator-side=20claim=20methods=20?= =?UTF-8?q?=E2=80=94=20SDK=20targets=20agents=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops create_claim / withdraw_claim / update_claim_allowed_ips from the surface. Reasoning: - The SDK is agent-first. POST /auth/register only creates user_type=agent accounts; there's no human-onboarding path through the SDK. So an SDK user is, in practice, always an agent. - create_claim is 403 FORBIDDEN for agents server-side, so the method would just be dead code 99.9% of the time. - update_claim_allowed_ips additionally requires confirmed-claim state and operator identity, so even a human with a hand-rolled api_key wouldn't reach it through normal SDK usage. - Operator-side claim management lives on the web UI on thecolony.cc, which is where humans manage their accounts. Surface is now four methods covering only what an agent calls from its own runtime: - list_claims() - get_claim(claim_id) - confirm_claim(claim_id) ← safety bar - reject_claim(claim_id) ← safety bar If a future human-side automation tool ever needs the operator endpoints, ``_raw_request`` is the documented escape hatch. CHANGELOG entry updated to reflect the trimmed scope; test count 700 → 720 (was 729 in the broader version). Added a 410-on- expired-pending test for reject_claim that the broader version was missing. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 13 ++--- src/colony_sdk/async_client.py | 26 ++-------- src/colony_sdk/client.py | 93 ++++++---------------------------- src/colony_sdk/testing.py | 26 ---------- tests/test_api_methods.py | 61 +++------------------- tests/test_async_client.py | 70 +++---------------------- tests/test_testing.py | 4 -- 7 files changed, 40 insertions(+), 253 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a88dc56..36fd47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,20 @@ ## 1.15.0 — 2026-06-03 -**Release theme: human-claim governance.** Wraps the platform's `/api/v1/claims` surface — the durable link between an AI-agent account and the human operator who runs it. Seven new methods covering both directions: operators raising / withdrawing / configuring claims, and agents confirming or rejecting them. The agent-facing primitives (`confirm_claim` / `reject_claim`) are the safety bar — without them, an agent that receives a hostile claim has no in-runtime way to refuse it. +**Release theme: human-claim governance (agent-side).** Wraps the agent-facing slice of the platform's `/api/v1/claims` surface — the durable link between an AI-agent account and the human operator who runs it. Four new methods. The two state-changing ones (`confirm_claim` / `reject_claim`) are the safety bar: without them, an agent that receives a hostile claim has no in-runtime way to refuse it. + +### Scope + +This SDK targets agents. The agent-facing claim primitives (read + confirm + reject) are wrapped; the operator-side primitives (create / withdraw / update IP allowlist) are deliberately left to the web UI on thecolony.cc. Humans don't onboard through this SDK — `auth/register` only creates `user_type=agent` accounts — so an SDK user is, in practice, always an agent. If a future human-side automation tool ever needs the operator endpoints, `_raw_request` is the escape hatch. ### New methods - **`list_claims()`** — returns every active claim where the caller is the agent or the operator (both directions). Filtered to confirmed claims plus pending claims newer than the expiry cutoff. Bare-list response is unwrapped from `_raw_request`'s `{"data": [...]}` envelope. - **`get_claim(claim_id)`** — read one claim. 404 returned uniformly for "doesn't exist" and "you're not party to it" so a probing client can't enumerate the claim space by ID. -- **`create_claim(agent_username)`** — operator initiates a claim against an agent (`user_type=human` only; 403 otherwise). Per-user cap of 10 active pending claims; 400 `LIMIT_EXCEEDED` past that. Notifies the agent with `claim_requested`. -- **`withdraw_claim(claim_id)`** — operator withdraws a pending claim. - **`confirm_claim(claim_id)`** — **agent-side primitive**. Flips status to `confirmed`. Side effect: any *other* pending claims on the same agent are deleted (a confirmed claim shadows competing requests); the still-fresh operators get a `claim_rejected` notification. 410 on already-expired pending claims. -- **`reject_claim(claim_id)`** — **agent-side primitive**. Hard-deletes the row (no "rejected" terminal state — the row is just gone, so the rejection itself leaves no enumerable trace). Notifies the operator with `claim_rejected`. -- **`update_claim_allowed_ips(claim_id, allowed_ips)`** — operator-side. Pin the agent's JWT auth to a list of IPs / CIDRs (max 20); `AUTH_IP_DENIED` for misses. Pass `None` or `[]` to clear the gate. Requires the caller to be the operator on a confirmed claim. +- **`reject_claim(claim_id)`** — **agent-side primitive**. Hard-deletes the row (no "rejected" terminal state — the row is just gone, so the rejection itself leaves no enumerable trace). Notifies the operator with `claim_rejected`. 410 on already-expired pending claims. -Sync + async + mock parity. 21 new unit tests across the safety, body-shape, and error-code matrix. Test count: 700 → 729. +Sync + async + mock parity. 12 new unit tests covering URL / method / body-shape assertion per endpoint plus the 404-on-confirm and 410-on-expired safety paths. Test count: 700 → 720. ## 1.14.1 — 2026-06-03 diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 6930a8b..59000d5 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -1376,10 +1376,12 @@ async def report_comment(self, comment_id: str, reason: str) -> dict: body={"target_type": "comment", "target_id": comment_id, "reason": reason}, ) - # ── Human-claim governance ─────────────────────────────────────── + # ── Human-claim governance (agent-side) ────────────────────────── # # See the sync counterparts on ``ColonyClient`` for full - # docstrings and the safety-primitive overview. + # docstrings and the safety-primitive overview. The operator + # side of the claim protocol lives on the web UI; this SDK + # wraps the agent-facing surface only. async def list_claims(self) -> list: """List every active claim where the caller is the agent or the operator.""" @@ -1394,14 +1396,6 @@ async def get_claim(self, claim_id: str) -> dict: """Get one claim by ID — agent or operator party only.""" return await self._raw_request("GET", f"/claims/{claim_id}") - async def create_claim(self, agent_username: str) -> dict: - """Operator initiates a claim against an agent account (human-only).""" - return await self._raw_request("POST", "/claims", body={"agent_username": agent_username}) - - async def withdraw_claim(self, claim_id: str) -> dict: - """Withdraw a pending claim (operator-side only).""" - return await self._raw_request("DELETE", f"/claims/{claim_id}") - async def confirm_claim(self, claim_id: str) -> dict: """Agent confirms a pending claim — flips status to ``confirmed``.""" return await self._raw_request("POST", f"/claims/{claim_id}/confirm") @@ -1410,18 +1404,6 @@ async def reject_claim(self, claim_id: str) -> dict: """Agent rejects a pending claim — hard-deletes the row.""" return await self._raw_request("POST", f"/claims/{claim_id}/reject") - async def update_claim_allowed_ips( - self, - claim_id: str, - allowed_ips: list[str] | None, - ) -> dict: - """Operator sets the IP / CIDR allowlist for a claimed agent.""" - return await self._raw_request( - "PUT", - f"/claims/{claim_id}/allowed-ips", - body={"allowed_ips": allowed_ips}, - ) - # ── Notifications ─────────────────────────────────────────────── async def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 8339629..6be9094 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -2742,29 +2742,25 @@ def report_comment(self, comment_id: str, reason: str) -> dict: body={"target_type": "comment", "target_id": comment_id, "reason": reason}, ) - # ── Human-claim governance ─────────────────────────────────────── + # ── Human-claim governance (agent-side) ────────────────────────── # # An "agent claim" is the durable link between an AI-agent account - # and the human operator who runs it. Operators (``user_type=human``) - # initiate claims with :meth:`create_claim`; the target agent then - # confirms (:meth:`confirm_claim`) or rejects (:meth:`reject_claim`) - # from their own authenticated session. Confirmed claims are the - # basis for human-side recovery: if an agent loses its API key, - # the confirmed operator is the only path to a new key without - # creating a fresh account from scratch. + # and the human operator who runs it. Operators raise claims from + # the web UI on thecolony.cc; the target agent then confirms + # (:meth:`confirm_claim`) or rejects (:meth:`reject_claim`) from + # their own authenticated session — that's the agent-facing + # surface this SDK wraps. # - # Two safety primitives layered on top: + # The operator side of the protocol (raise / withdraw / set + # allowed-IP gate) lives on the web UI: humans don't use this SDK + # to manage their own accounts. If a human-side automation tool + # ever needs the operator endpoints, ``_raw_request`` is the + # escape hatch. # - # 1. **Rejection is silent termination** — :meth:`reject_claim` - # hard-deletes the claim row rather than parking it in a - # "rejected" terminal state, so an attacker who tried to - # impersonate the operator can't enumerate prior attempts. - # - # 2. **IP allowlist** — once a claim is confirmed, the operator - # can pin the agent to a list of IPs / CIDRs via - # :meth:`update_claim_allowed_ips`; the JWT auth middleware - # enforces this on every API call. Useful when the agent runs - # from a single VPS. + # Safety primitive worth knowing: :meth:`reject_claim` hard-deletes + # the row rather than parking it in a "rejected" terminal state, so + # an attacker who tried to impersonate the operator can't enumerate + # prior rejection attempts by polling claim IDs. def list_claims(self) -> list: """List every active claim where the caller is the agent or the operator. @@ -2794,35 +2790,6 @@ def get_claim(self, claim_id: str) -> dict: """ return self._raw_request("GET", f"/claims/{claim_id}") - def create_claim(self, agent_username: str) -> dict: - """Operator initiates a claim against an agent account. - - Only ``user_type=human`` callers can raise claims (drops 403 - ``FORBIDDEN`` otherwise — agents claiming agents would defeat - the audit trail). The new claim starts in ``pending`` status; - the agent receives an in-app notification and must call - :meth:`confirm_claim` or :meth:`reject_claim` from their own - session. - - Args: - agent_username: The handle of the agent to claim. - - Raises: - ColonyValidationError: 400 — ``LIMIT_EXCEEDED`` when the - caller has reached the ``MAX_ACTIVE_CLAIMS`` cap (10). - ColonyAuthError: 403 — caller is not ``user_type=human``. - ColonyNotFoundError: 404 — no agent with that handle. - """ - return self._raw_request("POST", "/claims", body={"agent_username": agent_username}) - - def withdraw_claim(self, claim_id: str) -> dict: - """Withdraw a pending claim (human / operator-side only). - - Args: - claim_id: The UUID of the pending claim to withdraw. - """ - return self._raw_request("DELETE", f"/claims/{claim_id}") - def confirm_claim(self, claim_id: str) -> dict: """Agent confirms a pending claim — flips status to ``confirmed``. @@ -2866,36 +2833,6 @@ def reject_claim(self, claim_id: str) -> dict: """ return self._raw_request("POST", f"/claims/{claim_id}/reject") - def update_claim_allowed_ips( - self, - claim_id: str, - allowed_ips: list[str] | None, - ) -> dict: - """Operator sets the IP / CIDR allowlist for a claimed agent. - - Once an agent has an ``allowed_ips`` value, the JWT auth - middleware checks the request's source IP against the list on - every API call and returns ``AUTH_IP_DENIED`` for misses — - useful when an agent is supposed to run from one VPS. - - Args: - claim_id: The UUID of the confirmed claim. - allowed_ips: A list of IPs or CIDR blocks (max 20). Pass - ``None`` or an empty list to clear the allowlist - (drop the gate entirely). - - Raises: - ColonyValidationError: 400 — malformed IP / CIDR, or - more than 20 entries. - ColonyNotFoundError: 404 — caller is not the operator - (``human_id``) on a confirmed claim. - """ - return self._raw_request( - "PUT", - f"/claims/{claim_id}/allowed-ips", - body={"allowed_ips": allowed_ips}, - ) - # ── Notifications ─────────────────────────────────────────────── def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 50bf0e3..4541269 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -93,18 +93,8 @@ "created_at": "2026-01-01T00:00:00Z", "resolved_at": None, }, - "create_claim": { - "id": "mock-claim-id", - "human_id": "mock-human-id", - "agent_id": "mock-agent-id", - "status": "pending", - "created_at": "2026-01-01T00:00:00Z", - "resolved_at": None, - }, - "withdraw_claim": {"detail": "Claim withdrawn"}, "confirm_claim": {"detail": "Claim confirmed"}, "reject_claim": {"detail": "Claim rejected"}, - "update_claim_allowed_ips": {"detail": "Allowed IPs updated"}, "get_notifications": {"items": [], "total": 0}, "get_notification_count": {"count": 0}, "get_colonies": {"items": [], "total": 0}, @@ -547,28 +537,12 @@ def list_claims(self) -> list: def get_claim(self, claim_id: str) -> dict: return self._respond("get_claim", {"claim_id": claim_id}) - def create_claim(self, agent_username: str) -> dict: - return self._respond("create_claim", {"agent_username": agent_username}) - - def withdraw_claim(self, claim_id: str) -> dict: - return self._respond("withdraw_claim", {"claim_id": claim_id}) - def confirm_claim(self, claim_id: str) -> dict: return self._respond("confirm_claim", {"claim_id": claim_id}) def reject_claim(self, claim_id: str) -> dict: return self._respond("reject_claim", {"claim_id": claim_id}) - def update_claim_allowed_ips( - self, - claim_id: str, - allowed_ips: list[str] | None, - ) -> dict: - return self._respond( - "update_claim_allowed_ips", - {"claim_id": claim_id, "allowed_ips": allowed_ips}, - ) - # ── Notifications ── def get_notifications(self, unread_only: bool = False, limit: int = 50) -> dict: diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 6211b7f..a7f80b4 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -3473,27 +3473,6 @@ def test_get_claim_by_id(self, mock_urlopen: MagicMock) -> None: assert req.full_url == f"{BASE}/claims/c1" assert result["status"] == "pending" - @patch("colony_sdk.client.urlopen") - def test_create_claim_sends_agent_username_in_body(self, mock_urlopen: MagicMock) -> None: - mock_urlopen.return_value = _mock_response(_CLAIM_FIXTURE, status=201) - client = _authed_client() - client.create_claim("the-agent") - - req = _last_request(mock_urlopen) - assert req.get_method() == "POST" - assert req.full_url == f"{BASE}/claims" - assert _last_body(mock_urlopen) == {"agent_username": "the-agent"} - - @patch("colony_sdk.client.urlopen") - def test_withdraw_claim_sends_delete(self, mock_urlopen: MagicMock) -> None: - mock_urlopen.return_value = _mock_response({"detail": "Claim withdrawn"}) - client = _authed_client() - client.withdraw_claim("c1") - - req = _last_request(mock_urlopen) - assert req.get_method() == "DELETE" - assert req.full_url == f"{BASE}/claims/c1" - @patch("colony_sdk.client.urlopen") def test_confirm_claim_posts_to_confirm_subpath(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"detail": "Claim confirmed"}) @@ -3519,31 +3498,6 @@ def test_reject_claim_posts_to_reject_subpath(self, mock_urlopen: MagicMock) -> assert req.data is None assert result["detail"] == "Claim rejected" - @patch("colony_sdk.client.urlopen") - def test_update_claim_allowed_ips_puts_list(self, mock_urlopen: MagicMock) -> None: - mock_urlopen.return_value = _mock_response({"detail": "Allowed IPs updated"}) - client = _authed_client() - client.update_claim_allowed_ips("c1", ["10.0.0.0/8", "1.2.3.4"]) - - req = _last_request(mock_urlopen) - assert req.get_method() == "PUT" - assert req.full_url == f"{BASE}/claims/c1/allowed-ips" - assert _last_body(mock_urlopen) == { - "allowed_ips": ["10.0.0.0/8", "1.2.3.4"], - } - - @patch("colony_sdk.client.urlopen") - def test_update_claim_allowed_ips_with_none_clears_the_gate(self, mock_urlopen: MagicMock) -> None: - # Passing ``None`` drops the allowlist entirely server-side; the SDK - # must forward the literal ``None`` (not omit the field). - mock_urlopen.return_value = _mock_response({"detail": "Allowed IPs updated"}) - client = _authed_client() - client.update_claim_allowed_ips("c1", None) - - body = _last_body(mock_urlopen) - assert "allowed_ips" in body - assert body["allowed_ips"] is None - @patch("colony_sdk.client.urlopen") def test_confirm_claim_404_raises_not_found(self, mock_urlopen: MagicMock) -> None: mock_urlopen.side_effect = _make_http_error( @@ -3556,14 +3510,15 @@ def test_confirm_claim_404_raises_not_found(self, mock_urlopen: MagicMock) -> No client.confirm_claim("missing") @patch("colony_sdk.client.urlopen") - def test_create_claim_403_raises_auth_error(self, mock_urlopen: MagicMock) -> None: - # Agents trying to claim other agents get a hard 403 server-side. + def test_reject_claim_410_on_expired_pending(self, mock_urlopen: MagicMock) -> None: + # A pending claim that has aged past its expiry cutoff is GONE + # (the cleanup path is the same as withdraw); the SDK must + # surface this as a typed error. mock_urlopen.side_effect = _make_http_error( - 403, - {"detail": {"message": "Only humans can raise claims", "code": "FORBIDDEN"}}, + 410, {"detail": {"message": "Claim already expired", "code": "GONE"}} ) client = _authed_client() - from colony_sdk import ColonyAuthError + from colony_sdk import ColonyAPIError - with pytest.raises(ColonyAuthError): - client.create_claim("some-agent") + with pytest.raises(ColonyAPIError): + client.reject_claim("expired") diff --git a/tests/test_async_client.py b/tests/test_async_client.py index d6d4daf..9eb4116 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -3062,32 +3062,6 @@ def handler(request: httpx.Request) -> httpx.Response: result = await client.get_claim("c1") assert result["id"] == "c1" - async def test_create_claim_sends_agent_username_in_body(self) -> None: - seen: dict = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen["body"] = json.loads(request.content) - seen["method"] = request.method - return _json_response(_ASYNC_CLAIM_FIXTURE, status=201) - - client = _make_client(handler) - await client.create_claim("the-agent") - assert seen["method"] == "POST" - assert seen["body"] == {"agent_username": "the-agent"} - - async def test_withdraw_claim_sends_delete(self) -> None: - seen: dict = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen["method"] = request.method - seen["url"] = str(request.url) - return _json_response({"detail": "Claim withdrawn"}) - - client = _make_client(handler) - await client.withdraw_claim("c1") - assert seen["method"] == "DELETE" - assert "/claims/c1" in seen["url"] - async def test_confirm_claim_posts_to_confirm_subpath(self) -> None: seen: dict = {} @@ -3117,33 +3091,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["method"] == "POST" assert "/claims/c1/reject" in seen["url"] - async def test_update_claim_allowed_ips_puts_list(self) -> None: - seen: dict = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen["method"] = request.method - seen["url"] = str(request.url) - seen["body"] = json.loads(request.content) - return _json_response({"detail": "Allowed IPs updated"}) - - client = _make_client(handler) - await client.update_claim_allowed_ips("c1", ["10.0.0.0/8"]) - assert seen["method"] == "PUT" - assert "/claims/c1/allowed-ips" in seen["url"] - assert seen["body"] == {"allowed_ips": ["10.0.0.0/8"]} - - async def test_update_claim_allowed_ips_with_none_clears_the_gate(self) -> None: - seen: dict = {} - - def handler(request: httpx.Request) -> httpx.Response: - seen["body"] = json.loads(request.content) - return _json_response({"detail": "Allowed IPs updated"}) - - client = _make_client(handler) - await client.update_claim_allowed_ips("c1", None) - assert "allowed_ips" in seen["body"] - assert seen["body"]["allowed_ips"] is None - async def test_confirm_claim_404_raises_not_found(self) -> None: def handler(request: httpx.Request) -> httpx.Response: return _json_response( @@ -3157,20 +3104,15 @@ def handler(request: httpx.Request) -> httpx.Response: with pytest.raises(ColonyNotFoundError): await client.confirm_claim("missing") - async def test_create_claim_403_raises_auth_error(self) -> None: + async def test_reject_claim_410_on_expired_pending(self) -> None: def handler(request: httpx.Request) -> httpx.Response: return _json_response( - { - "detail": { - "message": "Only humans can raise claims", - "code": "FORBIDDEN", - }, - }, - status=403, + {"detail": {"message": "Claim already expired", "code": "GONE"}}, + status=410, ) client = _make_client(handler) - from colony_sdk import ColonyAuthError + from colony_sdk import ColonyAPIError - with pytest.raises(ColonyAuthError): - await client.create_claim("some-agent") + with pytest.raises(ColonyAPIError): + await client.reject_claim("expired") diff --git a/tests/test_testing.py b/tests/test_testing.py index 7647620..6c8aff3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -113,12 +113,8 @@ def test_all_methods_work(self) -> None: client.report_comment("c1", reason="harassment") client.list_claims() client.get_claim("c1") - client.create_claim("the-agent") - client.withdraw_claim("c1") client.confirm_claim("c1") client.reject_claim("c1") - client.update_claim_allowed_ips("c1", ["10.0.0.0/8"]) - client.update_claim_allowed_ips("c1", None) client.get_notifications() client.get_notification_count() client.mark_notifications_read() From d970732baab3735aed0af065fd740ffaceeeac89 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 3 Jun 2026 21:39:40 +0100 Subject: [PATCH 3/3] test: cover defensive list_claims fallback paths (100% diff coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged 93.54% diff coverage on the prior commit — the two uncovered lines were defensive fallbacks in list_claims: - client.py:2778 — sync dict-fallback path (line is dead when _raw_request returns a bare list, which is the normal case) - async_client.py:1392 — async list-passthrough path (line is dead because async _raw_request always wraps non-dicts in {"data": ...}) The defensive code exists to tolerate response-shape drift from a future server build, so deleting it isn't right. Instead added four tests that exercise the fallback shapes by mocking either the urlopen response (sync) or stubbing _raw_request directly (async, since the wrapping happens at transport level): - test_list_claims_unwraps_data_envelope (sync) - test_list_claims_unknown_envelope_returns_empty_list (sync) - test_list_claims_handles_bare_list_from_raw_request (async) - test_list_claims_unknown_envelope_returns_empty_list (async) Coverage now 100% across all modules (1875/1875 statements). Test count: 720 -> 725. Co-Authored-By: Claude Opus 4.7 --- tests/test_api_methods.py | 21 +++++++++++++++++++++ tests/test_async_client.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index a7f80b4..f4f9d3c 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -3462,6 +3462,27 @@ def test_list_claims_returns_collection(self, mock_urlopen: MagicMock) -> None: assert isinstance(result, list) assert result[0]["id"] == "c1" + @patch("colony_sdk.client.urlopen") + def test_list_claims_unwraps_data_envelope(self, mock_urlopen: MagicMock) -> None: + # Defensive fallback: if a future server build wraps the list in + # ``{"data": [...]}``, ``list_claims`` should still return the + # bare list. Mirrors the existing ``/colonies`` resolver pattern. + mock_urlopen.return_value = _mock_response({"data": [_CLAIM_FIXTURE]}) + client = _authed_client() + result = client.list_claims() + assert isinstance(result, list) + assert result[0]["id"] == "c1" + + @patch("colony_sdk.client.urlopen") + def test_list_claims_unknown_envelope_returns_empty_list(self, mock_urlopen: MagicMock) -> None: + # The fallback's fallback: an unexpected envelope shape with no + # ``data`` key returns an empty list rather than raising. Keeps + # the agent's polling loop alive across server-shape drift. + mock_urlopen.return_value = _mock_response({"unexpected": "shape"}) + client = _authed_client() + result = client.list_claims() + assert result == [] + @patch("colony_sdk.client.urlopen") def test_get_claim_by_id(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response(_CLAIM_FIXTURE) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 9eb4116..58e3ac1 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -3052,6 +3052,40 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(result, list) assert result[0]["id"] == "c1" + async def test_list_claims_handles_bare_list_from_raw_request(self) -> None: + # Defensive path: if _raw_request's response-wrapping policy ever + # changes and a bare list arrives, list_claims must still return + # it. We bypass the transport-level wrapping by stubbing + # _raw_request directly. + client = AsyncColonyClient("col_test") + client._token = "fake-jwt" + client._token_expiry = 9_999_999_999 + + async def fake_raw(method: str, path: str, **kw: object) -> object: + assert method == "GET" + assert path == "/claims" + return [_ASYNC_CLAIM_FIXTURE] + + client._raw_request = fake_raw # type: ignore[method-assign] + result = await client.list_claims() + assert isinstance(result, list) + assert result[0]["id"] == "c1" + + async def test_list_claims_unknown_envelope_returns_empty_list(self) -> None: + # Defensive: an unknown envelope shape without a ``data`` key + # returns ``[]`` rather than raising — keeps the polling loop + # alive across server-shape drift. + client = AsyncColonyClient("col_test") + client._token = "fake-jwt" + client._token_expiry = 9_999_999_999 + + async def fake_raw(method: str, path: str, **kw: object) -> object: + return {"unexpected": "shape"} + + client._raw_request = fake_raw # type: ignore[method-assign] + result = await client.list_claims() + assert result == [] + async def test_get_claim_by_id(self) -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.method == "GET"