diff --git a/docs/HUB.md b/docs/HUB.md index e6ffc2a..fdc84c5 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -143,7 +143,9 @@ It reports: - the co-located Agent Host identity/platform/capabilities/tools; - the exact local Relmote version/build; - credential-blind paired-target summaries; -- optionally, live Target reachability and authority state. +- optionally, live Target reachability and authority state; +- for live authorized Targets, the Target's minimal platform metadata and exact Relmote version/build; +- a conservative build relation: `same_build`, `different_build_same_snapshot`, `different_version`, or `unknown`. The live probe distinguishes a reachable-but-revoked Target from an unreachable Target. The snapshot does not expose paired-target bearer credentials or stored endpoint URLs. @@ -268,3 +270,10 @@ Hub implementation should reuse the same: - revocation semantics. The Hub should coordinate those primitives rather than invent a second authorization model. + + +## Version/build coordination boundary + +The Hub must not infer "older" or "newer" from two different commit hashes alone. + +The first version-coordination slice reports exact live Target build identity and build drift relative to the co-located Agent Host. A later update-availability provider may compare those builds against an authoritative configured source/channel and then state whether an update is actually available. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 27226fe..f2de505 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -121,6 +121,8 @@ Potential scope: - [x] credential-blind local Agent Host and paired-target inventory; - [x] optional live paired-target reachability/authority probes; - [x] read-only local/private Hub web dashboard; +- [x] exact live Target version/build metadata and conservative build-drift reporting; +- [ ] authoritative update-availability checks against the configured source/channel; - support a co-located Hub + Agent Host deployment as a first-class self-hosted topology; - discover and organize software and hardware Relmote nodes; - show node identity, availability, target, transport/path, and capability status; diff --git a/docs/STATUS.md b/docs/STATUS.md index e36d50f..933ea94 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -23,7 +23,7 @@ This document is the canonical high-level implementation-status snapshot. Detail ## Implemented, actively experimental -- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with live reachability/authority probes, cached-vs-current state reconciliation, non-destructive attention guidance, and a read-only localhost/Tailscale web dashboard. +- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with live reachability/authority probes, cached-vs-current state reconciliation, non-destructive attention guidance, exact live Target build metadata/build drift, and a read-only localhost/Tailscale web dashboard. - Cross-platform/private-transport portability beyond the exercised Linux direct-Tailscale path. - Wayland ScreenCast portal integration. diff --git a/src/relmote/agent_api.py b/src/relmote/agent_api.py index 96180b7..df50ee5 100644 --- a/src/relmote/agent_api.py +++ b/src/relmote/agent_api.py @@ -1,7 +1,10 @@ from __future__ import annotations +import platform + from .agent_executor import list_path, read_text, run_command from .runtime import RelmoteRuntime +from .version import build_info def bearer_token(headers) -> str: @@ -18,6 +21,19 @@ def handle_get(runtime: RelmoteRuntime, headers, path: str) -> dict: grant = runtime.agent_by_token(bearer_token(headers)) if path == "/api/v1/agent/session": return grant.public() + if path == "/api/v1/agent/info": + return { + "role": "target", + "target": { + "name": runtime.node.identity.display_name, + }, + "platform": { + "system": platform.system().lower(), + "architecture": platform.machine(), + }, + "relmote": build_info(), + "session": grant.public(), + } raise KeyError("unknown agent GET endpoint") diff --git a/src/relmote/agent_client.py b/src/relmote/agent_client.py index 7af315d..8abb72e 100644 --- a/src/relmote/agent_client.py +++ b/src/relmote/agent_client.py @@ -46,6 +46,9 @@ def _request(self, path: str, payload: dict | None = None) -> dict: def session(self) -> dict: return self._request("/api/v1/agent/session") + def info(self) -> dict: + return self._request("/api/v1/agent/info") + def list(self, path: str = ".") -> list[dict]: return self._request("/api/v1/agent/list", {"path": path})["entries"] diff --git a/src/relmote/hub_inventory.py b/src/relmote/hub_inventory.py index 1921807..d34b11f 100644 --- a/src/relmote/hub_inventory.py +++ b/src/relmote/hub_inventory.py @@ -97,6 +97,30 @@ def _reconcile_live(live: dict[str, Any]) -> dict[str, Any]: "recommended_action": "review live Target status", } + @staticmethod + def _build_relation( + local_build: dict[str, Any], + target_build: dict[str, Any] | None, + ) -> str: + if not isinstance(target_build, dict): + return "unknown" + local_commit = str(local_build.get("commit") or "unknown") + target_commit = str(target_build.get("commit") or "unknown") + local_version = str(local_build.get("display_version") or "") + target_version = str(target_build.get("display_version") or "") + + if ( + local_commit != "unknown" + and target_commit != "unknown" + and local_commit == target_commit + ): + return "same_build" + if local_version and target_version and local_version == target_version: + return "different_build_same_snapshot" + if local_version and target_version and local_version != target_version: + return "different_version" + return "unknown" + def snapshot(self, *, live: bool = False) -> dict[str, Any]: host = self._host_factory() build = self._build_factory() @@ -107,7 +131,7 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: item["role"] = "paired_target" if live: try: - status = self._paired_targets.status(str(item["name"])) + info = self._paired_targets.info(str(item["name"])) except ( PermissionError, ConnectionError, @@ -117,13 +141,19 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: ) as exc: item["live"] = self._live_error(exc) else: - state = status.get("state") + session = info.get("session") or {} + state = session.get("state") + target_build = info.get("relmote") or {} item["live"] = { "reachable": True, "authority": "active" if state == "active" else "inactive", "state": state, - "workspace": status.get("workspace"), - "capabilities": list(status.get("capabilities") or []), + "workspace": session.get("workspace"), + "capabilities": list(session.get("capabilities") or []), + "target": info.get("target") or {}, + "platform": info.get("platform") or {}, + "relmote": target_build, + "build_relation": self._build_relation(build, target_build), } item["current"] = self._reconcile_live(item["live"]) targets.append(item) @@ -135,6 +165,7 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: "unreachable": 0, "revoked": 0, "stale_credential": 0, + "build_drift": 0, } if live: for item in targets: @@ -144,6 +175,9 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: summary["attention"] += 1 if health in summary: summary[health] += 1 + relation = (item.get("live") or {}).get("build_relation") + if relation in {"different_build_same_snapshot", "different_version"}: + summary["build_drift"] += 1 return { "hub": { diff --git a/src/relmote/hub_web.py b/src/relmote/hub_web.py index c978539..cb22bbe 100644 --- a/src/relmote/hub_web.py +++ b/src/relmote/hub_web.py @@ -94,6 +94,17 @@ const reach=live.reachable===true?'reachable':(live.reachable===false?'unreachable':'unknown'); html+='

Live: '+esc(reach)+' ยท '+esc(live.authority||'unknown')+'

'; if(live.state) html+='

Live state: '+esc(live.state)+'

'; + const targetMeta=live.target||{}; + const platformMeta=live.platform||{}; + const relmoteMeta=live.relmote||{}; + if(targetMeta.name) html+='

Target node: '+esc(targetMeta.name)+'

'; + if(platformMeta.system||platformMeta.architecture){ + html+='

Target platform: '+esc(platformMeta.system||'?')+' / '+esc(platformMeta.architecture||'?')+'

'; + } + if(relmoteMeta.display_version){ + html+='

Target Relmote: '+esc(relmoteMeta.display_version)+' (build '+esc(relmoteMeta.short_commit||'?')+')

'; + html+='

Build relation: '+esc(live.build_relation||'unknown')+'

'; + } if(live.detail) html+='

Detail: '+esc(live.detail)+'

'; } if(current.recommended_action){ @@ -117,7 +128,8 @@ metric('need attention',s.attention||0)+ metric('revoked',s.revoked||0)+ metric('stale credentials',s.stale_credential||0)+ - metric('unreachable',s.unreachable||0); + metric('unreachable',s.unreachable||0)+ + metric('build drift',s.build_drift||0); const h=value.agent_host||{}; const b=h.relmote||{}; document.getElementById('host').innerHTML= diff --git a/src/relmote/paired_targets.py b/src/relmote/paired_targets.py index 7d0a8e5..47e424d 100644 --- a/src/relmote/paired_targets.py +++ b/src/relmote/paired_targets.py @@ -65,6 +65,9 @@ def targets(self) -> list[dict]: def status(self, target: str) -> dict: return self._client(target).session() + def info(self, target: str) -> dict: + return self._client(target).info() + def list(self, target: str, path: str = ".") -> list[dict]: return self._client(target).list(path) diff --git a/tests/test_agent_api.py b/tests/test_agent_api.py new file mode 100644 index 0000000..ee06763 --- /dev/null +++ b/tests/test_agent_api.py @@ -0,0 +1,53 @@ +from unittest.mock import patch + +from relmote.agent_api import handle_get +from relmote.runtime import RelmoteRuntime + + +class Headers(dict): + pass + + +def active_runtime(): + runtime = RelmoteRuntime() + runtime.support_access.enable_until_disabled() + grant = runtime.request_agent( + "/workspace", + ["workspace.list"], + controller="test-controller", + ) + runtime.approve_agent(grant.session.session_id) + return runtime, grant + + +def test_agent_info_returns_minimal_authenticated_target_metadata(): + runtime, grant = active_runtime() + headers = Headers(Authorization=f"Bearer {grant.token}") + + with patch( + "relmote.agent_api.platform.system", + return_value="Linux", + ), patch( + "relmote.agent_api.platform.machine", + return_value="x86_64", + ), patch( + "relmote.agent_api.build_info", + return_value={ + "version": "0.1.0.dev12", + "display_version": "0.1.0-dev.12", + "commit": "abcdef1234567890", + "short_commit": "abcdef12", + "channel": "repository", + }, + ): + value = handle_get(runtime, headers, "/api/v1/agent/info") + + assert value["role"] == "target" + assert value["target"]["name"] == runtime.node.identity.display_name + assert value["platform"] == { + "system": "linux", + "architecture": "x86_64", + } + assert value["relmote"]["short_commit"] == "abcdef12" + assert value["session"]["state"] == "active" + assert "token" not in repr(value) diff --git a/tests/test_agent_client.py b/tests/test_agent_client.py index 009fe4b..1991979 100644 --- a/tests/test_agent_client.py +++ b/tests/test_agent_client.py @@ -33,3 +33,32 @@ def test_pair_formats_unreachable_endpoint(): match="Relmote pairing endpoint unavailable: connection refused", ): pair("http://100.64.1.2:8788", "12345678") + + +def test_agent_info_uses_authenticated_read_only_endpoint(): + client = RelmoteAgentClient( + "http://100.64.1.2:8788", + "test-token", + ) + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return b'{"role":"target","session":{"state":"active"}}' + + with patch( + "relmote.agent_client.urllib.request.urlopen", + return_value=Response(), + ) as open_url: + value = client.info() + + request = open_url.call_args.args[0] + assert request.full_url.endswith("/api/v1/agent/info") + assert request.method == "GET" + assert request.get_header("Authorization") == "Bearer test-token" + assert value["role"] == "target" diff --git a/tests/test_hub_inventory.py b/tests/test_hub_inventory.py index 897f9b2..70acbe2 100644 --- a/tests/test_hub_inventory.py +++ b/tests/test_hub_inventory.py @@ -3,10 +3,12 @@ class FakePairedTargets: - def __init__(self, *, status_result=None, status_error=None): + def __init__(self, *, status_result=None, status_error=None, info_result=None): self.status_result = status_result self.status_error = status_error + self.info_result = info_result self.status_calls = [] + self.info_calls = [] def targets(self): return [ @@ -28,6 +30,27 @@ def status(self, target): "capabilities": ["workspace.list", "workspace.read"], } + def info(self, target): + self.info_calls.append(target) + if self.status_error is not None: + raise self.status_error + return self.info_result or { + "role": "target", + "target": {"name": "target-node-a"}, + "platform": {"system": "linux", "architecture": "x86_64"}, + "relmote": { + "display_version": "0.1.0-dev.12", + "commit": "abcdef1234567890", + "short_commit": "abcdef12", + "channel": "repository", + }, + "session": self.status_result or { + "state": "active", + "workspace": "/workspace", + "capabilities": ["workspace.list", "workspace.read"], + }, + } + def fake_host(): return AgentHost( @@ -79,7 +102,8 @@ def test_hub_live_inventory_reports_active_target(): ).snapshot(live=True) live = snapshot["paired_targets"][0]["live"] - assert service.status_calls == ["target-a"] + assert service.info_calls == ["target-a"] + assert service.status_calls == [] assert live["reachable"] is True assert live["authority"] == "active" assert live["state"] == "active" @@ -168,3 +192,47 @@ def test_hub_summary_counts_active_and_unreachable_targets(): assert unreachable["summary"]["unreachable"] == 1 assert unreachable["summary"]["attention"] == 1 assert unreachable["paired_targets"][0]["current"]["health"] == "unreachable" + + +def test_hub_live_inventory_reports_target_build_and_relation(): + same = HubInventory( + paired_targets=FakePairedTargets(), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + live = same["paired_targets"][0]["live"] + assert live["target"]["name"] == "target-node-a" + assert live["platform"] == {"system": "linux", "architecture": "x86_64"} + assert live["relmote"]["short_commit"] == "abcdef12" + assert live["build_relation"] == "same_build" + assert same["summary"]["build_drift"] == 0 + + drift = HubInventory( + paired_targets=FakePairedTargets( + info_result={ + "role": "target", + "target": {"name": "target-node-a"}, + "platform": {"system": "linux", "architecture": "x86_64"}, + "relmote": { + "display_version": "0.1.0-dev.12", + "commit": "9999999999999999", + "short_commit": "99999999", + "channel": "repository", + }, + "session": { + "state": "active", + "workspace": "/workspace", + "capabilities": ["workspace.list"], + }, + } + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + assert ( + drift["paired_targets"][0]["live"]["build_relation"] + == "different_build_same_snapshot" + ) + assert drift["summary"]["build_drift"] == 1 diff --git a/tests/test_paired_targets.py b/tests/test_paired_targets.py index e4a95c6..dc21baa 100644 --- a/tests/test_paired_targets.py +++ b/tests/test_paired_targets.py @@ -12,6 +12,21 @@ def session(self): self.calls.append(("session",)) return {"state": "active", "session_id": "session-1"} + def info(self): + self.calls.append(("info",)) + return { + "role": "target", + "target": {"name": "target-a-node"}, + "platform": {"system": "linux", "architecture": "x86_64"}, + "relmote": { + "display_version": "0.1.0-dev.12", + "commit": "abcdef1234567890", + "short_commit": "abcdef12", + "channel": "repository", + }, + "session": {"state": "active", "workspace": "/workspace"}, + } + def list(self, path="."): self.calls.append(("list", path)) return [{"name": "README.md", "type": "file"}] @@ -74,6 +89,7 @@ def client_factory(base_url, token): service = PairedTargetService(client_factory=client_factory) assert service.status("target-a")["state"] == "active" + assert service.info("target-a")["target"]["name"] == "target-a-node" assert service.list("target-a", "src")[0]["name"] == "README.md" assert service.read("target-a", "README.md") == "hello\n" assert service.exec( @@ -86,9 +102,10 @@ def client_factory(base_url, token): assert all(client.base_url == "https://target-a.example.invalid" for client in clients) assert all(client.token == "secret-bearer" for client in clients) assert clients[0].calls == [("session",)] - assert clients[1].calls == [("list", "src")] - assert clients[2].calls == [("read", "README.md")] - assert clients[3].calls == [("exec", ["git", "status"], "repo", 12)] + assert clients[1].calls == [("info",)] + assert clients[2].calls == [("list", "src")] + assert clients[3].calls == [("read", "README.md")] + assert clients[4].calls == [("exec", ["git", "status"], "repo", 12)] def test_targets_marks_unreadable_profiles_without_exposing_file_contents(