Rebase onto upstream (a4d95fd)
#12
@@ -374,12 +374,12 @@ def gateway(
|
||||
hooks_config = config.hooks if hasattr(config, 'hooks') else None
|
||||
hooks_server = None
|
||||
|
||||
if hooks_config and hooks_config.enabled and hooks_config.has_tokens:
|
||||
if hooks_config and hooks_config.enabled:
|
||||
# Register hook channel
|
||||
hook_channel = HookChannel(bus)
|
||||
channels.register_channel("hook", hook_channel)
|
||||
|
||||
# Create hooks server
|
||||
# Create hooks server (checks has_tokens internally)
|
||||
hooks_server = HooksServer(
|
||||
host=config.gateway.host,
|
||||
port=config.gateway.port,
|
||||
|
||||
@@ -313,26 +313,21 @@ class GatewayConfig(Base):
|
||||
class HooksConfig(Base):
|
||||
"""Webhook endpoint configuration."""
|
||||
enabled: bool = False
|
||||
token: str = "" # Single bearer token (backward compat)
|
||||
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
|
||||
path: str = "/hooks" # URL path for the endpoint
|
||||
timeout_seconds: int = 120 # Max time to wait for agent response
|
||||
|
||||
def resolve_token(self, provided: str) -> str | None:
|
||||
"""Return token name if provided secret matches, else None."""
|
||||
# Check named tokens first
|
||||
for name, secret in self.tokens.items():
|
||||
if secret == provided:
|
||||
return name
|
||||
# Fall back to single token
|
||||
if self.token and provided == self.token:
|
||||
return "hook"
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_tokens(self) -> bool:
|
||||
"""True if at least one token is configured."""
|
||||
return bool(self.token) or bool(self.tokens)
|
||||
return bool(self.tokens)
|
||||
|
||||
|
||||
class WebSearchConfig(Base):
|
||||
|
||||
+9
-12
@@ -60,19 +60,15 @@ class HooksServer:
|
||||
Validate auth and return token name if valid, None otherwise.
|
||||
Checks Authorization: Bearer <token> and X-Hook-Token headers.
|
||||
"""
|
||||
# Try Authorization: Bearer <token>
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
name = self.config.resolve_token(auth[7:])
|
||||
if name:
|
||||
return name
|
||||
token = auth[7:]
|
||||
else:
|
||||
# Try X-Hook-Token header
|
||||
token = request.headers.get("X-Hook-Token", "")
|
||||
|
||||
token = request.headers.get("X-Hook-Token", "")
|
||||
if token:
|
||||
name = self.config.resolve_token(token)
|
||||
if name:
|
||||
return name
|
||||
|
||||
return None
|
||||
return self.config.resolve_token(token) if token else None
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""Health check endpoint — no auth required."""
|
||||
@@ -131,12 +127,13 @@ class HooksServer:
|
||||
response = await asyncio.wait_for(future, timeout=timeout)
|
||||
return web.json_response({"ok": True, "response": response})
|
||||
except asyncio.TimeoutError:
|
||||
self.bus.cancel_correlation(correlation_id)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"agent did not respond within {timeout}s"},
|
||||
status=504,
|
||||
)
|
||||
except Exception as e:
|
||||
self.bus.cancel_correlation(correlation_id)
|
||||
logger.error(f"Hook processing error: {e}")
|
||||
return web.json_response({"error": "internal error"}, status=500)
|
||||
finally:
|
||||
# Clean up correlation on any failure
|
||||
self.bus.cancel_correlation(correlation_id)
|
||||
|
||||
@@ -3,36 +3,22 @@
|
||||
from nanobot.config.schema import HooksConfig
|
||||
|
||||
|
||||
def test_hooks_config_single_token_backward_compat():
|
||||
"""Single token string should still work."""
|
||||
config = HooksConfig(enabled=True, token="my-secret")
|
||||
assert config.token == "my-secret"
|
||||
assert config.tokens == {}
|
||||
|
||||
|
||||
def test_hooks_config_named_tokens():
|
||||
"""Named tokens dict should work."""
|
||||
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||
assert config.tokens == {"gitea": "secret1", "ha": "secret2"}
|
||||
|
||||
|
||||
def test_hooks_config_resolve_token_from_named():
|
||||
"""resolve_token should return token name for a matching named token."""
|
||||
def test_hooks_config_resolve_token():
|
||||
"""resolve_token should return token name for a matching token."""
|
||||
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||
assert config.resolve_token("secret1") == "gitea"
|
||||
assert config.resolve_token("secret2") == "ha"
|
||||
assert config.resolve_token("unknown") is None
|
||||
|
||||
|
||||
def test_hooks_config_resolve_token_from_single():
|
||||
"""resolve_token should return 'hook' for the single token."""
|
||||
config = HooksConfig(enabled=True, token="my-secret")
|
||||
assert config.resolve_token("my-secret") == "hook"
|
||||
assert config.resolve_token("wrong") is None
|
||||
|
||||
|
||||
def test_hooks_config_any_token_set():
|
||||
"""has_tokens should be True if either token or tokens is set."""
|
||||
assert HooksConfig(enabled=True, token="x").has_tokens
|
||||
assert HooksConfig(enabled=True, tokens={"a": "b"}).has_tokens
|
||||
def test_hooks_config_has_tokens():
|
||||
"""has_tokens should be True if tokens dict is non-empty."""
|
||||
assert HooksConfig(enabled=True, tokens={"webhook": "secret"}).has_tokens
|
||||
assert not HooksConfig(enabled=True).has_tokens
|
||||
assert not HooksConfig(enabled=True, tokens={}).has_tokens
|
||||
|
||||
Reference in New Issue
Block a user