diff --git a/README.md b/README.md index f593e26e..4dd7a93b 100644 --- a/README.md +++ b/README.md @@ -1727,6 +1727,7 @@ Example config: } }, "gateway": { + "host": "127.0.0.1", "port": 18790 } } @@ -1739,6 +1740,14 @@ nanobot gateway --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-discord/config.json ``` +Each gateway instance also exposes a lightweight HTTP health endpoint on +`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`, +so the endpoint stays local unless you explicitly set `gateway.host` to a +public or LAN-facing address. + +- `GET /health` returns `{"status":"ok"}` +- Other paths return `404` + Override workspace for one-off runs when needed: ```bash diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 48400bbd..953e8b1f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -821,6 +821,48 @@ def gateway( console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") + async def _health_server(host: str, health_port: int): + """Lightweight HTTP health endpoint on the gateway port.""" + import json as _json + + async def handle(reader, writer): + try: + data = await asyncio.wait_for(reader.read(4096), timeout=5) + except (asyncio.TimeoutError, ConnectionError): + writer.close() + return + + request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace") + method, path = "", "" + parts = request_line.split(" ") + if len(parts) >= 2: + method, path = parts[0], parts[1] + + if method == "GET" and path == "/health": + body = _json.dumps({"status": "ok"}) + resp = ( + f"HTTP/1.0 200 OK\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n" + f"\r\n{body}" + ) + else: + body = "Not Found" + resp = ( + f"HTTP/1.0 404 Not Found\r\n" + f"Content-Type: text/plain\r\n" + f"Content-Length: {len(body)}\r\n" + f"\r\n{body}" + ) + + writer.write(resp.encode()) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, host, health_port) + console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health") + async with server: + await server.serve_forever() # Register Dream system job (always-on, idempotent on restart) dream_cfg = config.agents.defaults.dream if dream_cfg.model_override: @@ -843,6 +885,7 @@ def gateway( await asyncio.gather( agent.run(), channels.start_all(), + _health_server(config.gateway.host, port), ) except KeyboardInterrupt: console.print("\nShutting down...") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index aa5ab993..fd73e080 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -152,7 +152,7 @@ class ApiConfig(Base): class GatewayConfig(Base): """Gateway/server configuration.""" - host: str = "0.0.0.0" + host: str = "127.0.0.1" # Safer default: local-only bind. port: int = 18790 heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 3a1e7145..e4edfaf8 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1126,6 +1126,153 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) assert "port 18792" in result.stdout +def test_gateway_health_endpoint_binds_and_serves_expected_responses( + monkeypatch, tmp_path: Path +) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.gateway.port = 18791 + captured: dict[str, object] = {} + + class _FakeDream: + model = None + max_batch_size = 0 + max_iterations = 0 + + async def run(self) -> None: + return None + + class _FakeAgentLoop: + def __init__(self, **_kwargs) -> None: + self.model = "test-model" + self.dream = _FakeDream() + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close_mcp(self) -> None: + return None + + def stop(self) -> None: + return None + + class _FakeChannelManager: + def __init__(self, _config, _bus) -> None: + self.enabled_channels = ["telegram", "discord"] + + async def start_all(self) -> None: + await asyncio.Event().wait() + + async def stop_all(self) -> None: + return None + + class _FakeCronService: + def __init__(self, _store_path: Path) -> None: + self.on_job = None + + async def start(self) -> None: + return None + + def stop(self) -> None: + return None + + def status(self) -> dict[str, int]: + return {"jobs": 0} + + def register_system_job(self, _job) -> None: + return None + + class _FakeHeartbeatService: + def __init__(self, **_kwargs) -> None: + return None + + async def start(self) -> None: + return None + + def stop(self) -> None: + return None + + class _FakeServer: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + async def serve_forever(self) -> None: + raise _StopGatewayError("stop") + + async def _fake_start_server(handler, host: str, port: int): + captured["handler"] = handler + captured["host"] = host + captured["port"] = port + return _FakeServer() + + class _FakeReader: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + async def read(self, _size: int) -> bytes: + return self.payload + + class _FakeWriter: + def __init__(self) -> None: + self.output = b"" + self.closed = False + + def write(self, data: bytes) -> None: + self.output += data + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + ) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService) + monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService) + monkeypatch.setattr("asyncio.start_server", _fake_start_server) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert result.exit_code == 0 + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 18791 + assert "Health endpoint: http://127.0.0.1:18791/health" in result.stdout + + def _call_handler(path: str) -> tuple[str, _FakeWriter]: + request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode() + writer = _FakeWriter() + handler = captured["handler"] + assert callable(handler) + asyncio.run(handler(_FakeReader(request), writer)) + return writer.output.decode(), writer + + root_response, root_writer = _call_handler("/") + assert root_writer.closed is True + assert "HTTP/1.0 404 Not Found" in root_response + assert root_response.endswith("\r\n\r\nNot Found") + + health_response, health_writer = _call_handler("/health") + assert health_writer.closed is True + assert "HTTP/1.0 200 OK" in health_response + health_body = json.loads(health_response.split("\r\n\r\n", 1)[1]) + assert health_body == {"status": "ok"} + + missing_response, missing_writer = _call_handler("/missing") + assert missing_writer.closed is True + assert "HTTP/1.0 404 Not Found" in missing_response + assert missing_response.endswith("\r\n\r\nNot Found") + + def test_serve_uses_api_config_defaults_and_workspace_override( monkeypatch, tmp_path: Path ) -> None: