diff --git a/sdk/src/sdk/openclaw_hooks.py b/sdk/src/sdk/openclaw_hooks.py index 22e4826..b6b48b7 100644 --- a/sdk/src/sdk/openclaw_hooks.py +++ b/sdk/src/sdk/openclaw_hooks.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import logging import os from dataclasses import dataclass @@ -110,16 +111,28 @@ async def post_agent_hook( try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(cfg.agent_url, json=payload, headers=headers) as response: - body = await response.json(content_type=None) + status = response.status + reason = response.reason + text = await response.text(errors="replace") except TimeoutError as err: raise OpenClawHookError("OpenClaw hook request timed out") from err except aiohttp.ClientError as err: raise OpenClawHookError(f"OpenClaw hook request failed: {err}") from err + if not 200 <= status < 300: + snippet = text.strip()[:200] + detail = snippet or reason or "no body" + raise OpenClawHookError(f"OpenClaw hook returned status {status}: {detail}") + if not text.strip(): + raise OpenClawHookError("OpenClaw hook returned empty response body") + try: + body = json.loads(text) + except ValueError as err: + raise OpenClawHookError(f"OpenClaw hook returned invalid JSON: {err}") from err if not isinstance(body, dict): raise OpenClawHookError("OpenClaw hook returned a non-object response") - if response.status < 200 or response.status >= 300 or body.get("ok") is not True: - error = body.get("error") if isinstance(body.get("error"), str) else response.reason + if body.get("ok") is not True: + error = body.get("error") if isinstance(body.get("error"), str) else reason raise OpenClawHookError(f"OpenClaw hook rejected the request: {error}") run_id = body.get("runId") diff --git a/sdk/tests/test_openclaw_hooks.py b/sdk/tests/test_openclaw_hooks.py index 0edaff7..4f2d7a1 100644 --- a/sdk/tests/test_openclaw_hooks.py +++ b/sdk/tests/test_openclaw_hooks.py @@ -2,8 +2,6 @@ from __future__ import annotations -import socket - import pytest from aiohttp import web from sdk.openclaw_hooks import ( @@ -14,10 +12,16 @@ ) -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) +async def _serve(handler): + """Start an aiohttp test server on an OS-assigned port and return (runner, port).""" + app = web.Application() + app.router.add_post("/hooks/agent", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port def test_hook_config_requires_dedicated_token(monkeypatch): @@ -48,14 +52,7 @@ async def handler(request: web.Request) -> web.Response: seen["body"] = await request.json() return web.json_response({"ok": True, "runId": "run-123"}) - app = web.Application() - app.router.add_post("/hooks/agent", handler) - runner = web.AppRunner(app) - await runner.setup() - port = _free_port() - site = web.TCPSite(runner, "127.0.0.1", port) - await site.start() - + runner, port = await _serve(handler) monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") @@ -86,17 +83,10 @@ async def handler(request: web.Request) -> web.Response: @pytest.mark.asyncio async def test_post_agent_hook_surfaces_rejection(monkeypatch): - async def handler(request: web.Request) -> web.Response: + async def handler(_request: web.Request) -> web.Response: return web.json_response({"ok": False, "error": "denied"}, status=400) - app = web.Application() - app.router.add_post("/hooks/agent", handler) - runner = web.AppRunner(app) - await runner.setup() - port = _free_port() - site = web.TCPSite(runner, "127.0.0.1", port) - await site.start() - + runner, port = await _serve(handler) monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") @@ -105,3 +95,69 @@ async def handler(request: web.Request) -> web.Response: await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") finally: await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_rejects_empty_success_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=200, body=b"") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="empty response body"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_rejects_invalid_json_success_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=200, body=b"not json", content_type="application/json") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="invalid JSON"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_surfaces_5xx_with_empty_body(monkeypatch): + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=503, body=b"") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="status 503"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_post_agent_hook_handles_undecodable_body(monkeypatch): + """A 5xx with bytes that aren't valid UTF-8 must surface as OpenClawHookError, not UnicodeDecodeError.""" + + async def handler(_request: web.Request) -> web.Response: + return web.Response(status=500, body=b"\xff\xfe\xfd garbage \x80\x81") + + runner, port = await _serve(handler) + monkeypatch.setenv("OPENCLAW_HOOK_TOKEN", "secret") + monkeypatch.setenv("OPENCLAW_GATEWAY_HTTP_URL", f"http://127.0.0.1:{port}") + + try: + with pytest.raises(OpenClawHookError, match="status 500"): + await post_agent_hook(agent_id="yumi", message="Do it", name="Voice") + finally: + await runner.cleanup()