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
11 changes: 10 additions & 1 deletion docs/HUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/relmote/agent_api.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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")


Expand Down
3 changes: 3 additions & 0 deletions src/relmote/agent_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
42 changes: 38 additions & 4 deletions src/relmote/hub_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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": {
Expand Down
14 changes: 13 additions & 1 deletion src/relmote/hub_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@
const reach=live.reachable===true?'reachable':(live.reachable===false?'unreachable':'unknown');
html+='<p><span class="muted">Live:</span> '+esc(reach)+' · '+esc(live.authority||'unknown')+'</p>';
if(live.state) html+='<p><span class="muted">Live state:</span> '+esc(live.state)+'</p>';
const targetMeta=live.target||{};
const platformMeta=live.platform||{};
const relmoteMeta=live.relmote||{};
if(targetMeta.name) html+='<p><span class="muted">Target node:</span> '+esc(targetMeta.name)+'</p>';
if(platformMeta.system||platformMeta.architecture){
html+='<p><span class="muted">Target platform:</span> '+esc(platformMeta.system||'?')+' / '+esc(platformMeta.architecture||'?')+'</p>';
}
if(relmoteMeta.display_version){
html+='<p><span class="muted">Target Relmote:</span> '+esc(relmoteMeta.display_version)+' <span class="muted">(build '+esc(relmoteMeta.short_commit||'?')+')</span></p>';
html+='<p><span class="muted">Build relation:</span> '+esc(live.build_relation||'unknown')+'</p>';
}
if(live.detail) html+='<p><span class="muted">Detail:</span> '+esc(live.detail)+'</p>';
}
if(current.recommended_action){
Expand All @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions src/relmote/paired_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 53 additions & 0 deletions tests/test_agent_api.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions tests/test_agent_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
72 changes: 70 additions & 2 deletions tests/test_hub_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Loading
Loading