From 6000b7825f6d8f36d48db56177e633f500f66f83 Mon Sep 17 00:00:00 2001 From: dkrattiger Date: Thu, 9 Jul 2026 21:28:37 +0000 Subject: [PATCH] fix(sessionservice): don't default runner --host to the local hostname The host daemon's --host default fell back to socket.gethostname() when neither --host nor PANOPTICON_RUNNER_HOST was set, so every runner -- including a purely local one -- registered a truthy host. The terminal supervisor treats any truthy runner_host as remote and wraps tmux attach in `ssh -t `, which breaks attach for local-only setups. Default to None instead, matching the None-is-local contract the rest of the chain (attach_command, the dashboard, the switch-file) already expects. Co-Authored-By: Claude Sonnet 5 --- src/panopticon/sessionservice/host.py | 16 +++++++++++----- tests/test_host.py | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/panopticon/sessionservice/host.py b/src/panopticon/sessionservice/host.py index b7bbc70e..b9e7f7cb 100644 --- a/src/panopticon/sessionservice/host.py +++ b/src/panopticon/sessionservice/host.py @@ -25,7 +25,6 @@ import argparse import logging import os -import socket import threading import time from collections.abc import Callable @@ -183,7 +182,9 @@ def run_host( HostDaemon(client, spawner, provisioner, interval=interval, sleep=sleep).run(until=until) -def main(argv: list[str] | None = None, *, client: TaskServiceClient | None = None) -> None: # pragma: no cover - thin wiring + endless loop +def build_arg_parser() -> argparse.ArgumentParser: + """Build the ``host`` daemon's CLI parser — split out from :func:`main` so tests can inspect + argument defaults (e.g. ``--host``) without running the endless daemon loop.""" parser = argparse.ArgumentParser( prog="python -m panopticon.sessionservice.host", description="Per-host session service: spawn tasks + provision them (ADR 0008/0011).", @@ -201,8 +202,9 @@ def main(argv: list[str] | None = None, *, client: TaskServiceClient | None = No parser.add_argument("--runner-id", default=os.environ.get("PANOPTICON_RUNNER_ID", "local")) parser.add_argument( "--host", - default=os.environ.get("PANOPTICON_RUNNER_HOST", socket.gethostname()), - help="hostname or alias reported to the task service", + default=os.environ.get("PANOPTICON_RUNNER_HOST"), + help="hostname or alias reported to the task service — leave unset for a local runner; " + "set only when this host is genuinely remote (ADR 0013)", ) parser.add_argument("--image", default=DEFAULT_IMAGE) parser.add_argument("--cache-root", default=os.environ.get("PANOPTICON_CACHE_ROOT", DEFAULT_CACHE_ROOT)) @@ -211,7 +213,11 @@ def main(argv: list[str] | None = None, *, client: TaskServiceClient | None = No "--interval", type=float, default=2.0, help="change-feed long-poll wait, seconds (the keepalive ceiling between blocking calls)", ) - args = parser.parse_args(argv) + return parser + + +def main(argv: list[str] | None = None, *, client: TaskServiceClient | None = None) -> None: # pragma: no cover - thin wiring + endless loop + args = build_arg_parser().parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") client = client or TaskServiceClient(httpx.Client(base_url=args.service_url)) runner = LocalRunner(args.container_service_url, image=args.image, runner_id=args.runner_id) diff --git a/tests/test_host.py b/tests/test_host.py index 8822df39..d8a80cad 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -9,13 +9,14 @@ from pathlib import Path import httpx +import pytest from fastapi.testclient import TestClient from panopticon.client import JsonObj, TaskServiceClient from panopticon.core.git import GitClones from panopticon.core.models import Repo from panopticon.sessionservice.clones import CloneCache -from panopticon.sessionservice.host import HostDaemon, hold_runner_liveness, run_host +from panopticon.sessionservice.host import HostDaemon, build_arg_parser, hold_runner_liveness, run_host from panopticon.taskservice.api import create_app from panopticon.taskservice.artifacts_fs import FilesystemArtifactStore from panopticon.taskservice.service import TaskService @@ -304,6 +305,26 @@ def gen() -> Generator[None, None, None]: assert recorded == ["box.example.com"] +def test_build_arg_parser_host_defaults_to_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + # A local runner must not report a host at all — any truthy value makes the terminal + # supervisor wrap tmux attach in `ssh -t `, which breaks a purely local session. + monkeypatch.delenv("PANOPTICON_RUNNER_HOST", raising=False) + args = build_arg_parser().parse_args([]) + assert args.host is None + + +def test_build_arg_parser_host_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PANOPTICON_RUNNER_HOST", "box.example.com") + args = build_arg_parser().parse_args([]) + assert args.host == "box.example.com" + + +def test_build_arg_parser_host_flag_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PANOPTICON_RUNNER_HOST", "box.example.com") + args = build_arg_parser().parse_args(["--host", "other.example.com"]) + assert args.host == "other.example.com" + + def test_run_host_spawns_then_provisions_end_to_end(tmp_path: Path) -> None: service = TaskService(SqlAlchemyStore(), {"spike": Spike()}, FilesystemArtifactStore(tmp_path)) asyncio.run(service.init())