Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions sdk/src/sdk/openclaw_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass
Expand Down Expand Up @@ -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")
Expand Down
102 changes: 79 additions & 23 deletions sdk/tests/test_openclaw_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from __future__ import annotations

import socket

import pytest
from aiohttp import web
from sdk.openclaw_hooks import (
Expand All @@ -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):
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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}")

Expand All @@ -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()