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
21 changes: 21 additions & 0 deletions docs/HUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@ When an operation requires target authority, the Agent Host or another explicitl

Longer term, credentials should move into OS keyrings or another local secret store with process-scoped access.

## Current first slice

The first implemented Hub slice is intentionally read-only and runs locally on the Agent Host:

```bash
relmote hub inventory
relmote hub inventory --live
relmote hub inventory --json
```

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.

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.

This is inventory only. It cannot create grants, execute Target operations, update nodes, or act as a relay.

## Initial Hub MVP

The first useful Hub does not need to be a full RMM platform.
Expand Down
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ Build an optional self-hosted control plane for people who administer or support

Potential scope:

- [x] credential-blind local Agent Host and paired-target inventory;
- [x] optional live paired-target reachability/authority probes;
- 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: 2 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ 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 optional live reachability/authority probes.

- Cross-platform/private-transport portability beyond the exercised Linux direct-Tailscale path.
- Wayland ScreenCast portal integration.
- Screen-provider discovery/selection architecture.
Expand Down
83 changes: 83 additions & 0 deletions src/relmote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .agent_profiles import save_profile
from .agent_host import detect_agent_host
from .paired_targets import PairedTargetService
from .hub_inventory import HubInventory
from .app import run_app
from .version import build_info
from .updater import update_repo_preview
Expand Down Expand Up @@ -680,6 +681,88 @@ def run_workspace(args):
workspace_sub.choices["git-status"].set_defaults(func=run_workspace)
workspace_sub.choices["git-diff"].set_defaults(func=run_workspace)

hub_parser = sub.add_parser(
"hub",
help="preview the optional self-hosted Hub role",
)
hub_sub = hub_parser.add_subparsers(dest="hub_command", required=True)

hub_inventory = hub_sub.add_parser(
"inventory",
help="show credential-blind local Agent Host and paired-target inventory",
)
hub_inventory.add_argument(
"--live",
action="store_true",
help="probe paired Targets for current reachability/authority state",
)
hub_inventory.add_argument(
"--json",
action="store_true",
help="emit the complete structured inventory snapshot",
)

def run_hub_inventory(args):
snapshot = HubInventory().snapshot(live=args.live)
if args.json:
print(json.dumps(snapshot, indent=2))
return 0

hub = snapshot["hub"]
host = snapshot["agent_host"]
build = host["relmote"]

print("RELMOTE HUB INVENTORY")
print(f"Mode: {hub['mode']} · authority: {hub['authority']}")
print()
print("AGENT HOST")
print(f"Name: {host['name']}")
print(f"Platform: {host['platform']} / {host['architecture']}")
print(
f"Relmote: {build['display_version']} "
f"(build {build['short_commit']})"
)
print("Capabilities:")
for capability in host.get("capabilities", []):
print(f" - {capability}")
print("Tools:")
for tool in host.get("tools", []):
print(f" - {tool}")

print()
print("PAIRED TARGETS")
targets = snapshot["paired_targets"]
if not targets:
print("No paired Relmote targets.")
return 0

for target in targets:
print(f"- {target['name']}")
print(f" workspace: {target.get('workspace') or '?'}")
print(f" stored state: {target.get('state') or '?'}")
capabilities = target.get("capabilities") or []
print(
" stored capabilities: "
+ (", ".join(capabilities) if capabilities else "none")
)
if args.live:
live = target.get("live") or {}
reachable = live.get("reachable")
if reachable is True:
reachability = "reachable"
elif reachable is False:
reachability = "unreachable"
else:
reachability = "unknown"
print(f" live: {reachability} · {live.get('authority') or 'unknown'}")
if live.get("state"):
print(f" live state: {live['state']}")
if live.get("detail"):
print(f" detail: {live['detail']}")
return 0

hub_inventory.set_defaults(func=run_hub_inventory)

status_parser = sub.add_parser(
"status",
help="show read-only software-node status (useful locally or over SSH)",
Expand Down
101 changes: 101 additions & 0 deletions src/relmote/hub_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Any

from .agent_host import AgentHost, detect_agent_host
from .paired_targets import PairedTargetService
from .version import build_info


class HubInventory:
"""Read-only local inventory for the first Relmote Hub MVP.

The Hub consumes credential-blind paired-target summaries. Live probes use
the Agent Host's existing paired-target service but never return bearer
credentials or stored endpoint URLs.
"""

def __init__(
self,
*,
paired_targets: PairedTargetService | None = None,
host_factory: Callable[[], AgentHost] = detect_agent_host,
build_factory: Callable[[], dict[str, str]] = build_info,
):
self._paired_targets = paired_targets or PairedTargetService()
self._host_factory = host_factory
self._build_factory = build_factory

@staticmethod
def _live_error(exc: Exception) -> dict[str, Any]:
if isinstance(exc, PermissionError):
return {
"reachable": True,
"authority": "denied",
"state": None,
"detail": str(exc),
}
if isinstance(exc, ConnectionError):
return {
"reachable": False,
"authority": "unknown",
"state": None,
"detail": str(exc),
}
return {
"reachable": None,
"authority": "unknown",
"state": None,
"detail": str(exc),
}

def snapshot(self, *, live: bool = False) -> dict[str, Any]:
host = self._host_factory()
build = self._build_factory()

targets = []
for target in self._paired_targets.targets():
item = dict(target)
item["role"] = "paired_target"
if live:
try:
status = self._paired_targets.status(str(item["name"]))
except (
PermissionError,
ConnectionError,
KeyError,
ValueError,
OSError,
) as exc:
item["live"] = self._live_error(exc)
else:
state = status.get("state")
item["live"] = {
"reachable": True,
"authority": "active" if state == "active" else "inactive",
"state": state,
"workspace": status.get("workspace"),
"capabilities": list(status.get("capabilities") or []),
}
targets.append(item)

return {
"hub": {
"role": "hub",
"mode": "local-read-only",
"co_located_agent_host": True,
"authority": "inventory-only",
},
"agent_host": {
"role": "agent_host",
**host.as_dict(),
"relmote": {
"display_version": build["display_version"],
"commit": build["commit"],
"short_commit": build["short_commit"],
"channel": build["channel"],
},
},
"paired_targets": targets,
}
10 changes: 10 additions & 0 deletions tests/test_hub_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from relmote.cli import build_parser


def test_parser_accepts_hub_inventory():
args = build_parser().parse_args(["hub", "inventory", "--live", "--json"])

assert args.command == "hub"
assert args.hub_command == "inventory"
assert args.live is True
assert args.json is True
111 changes: 111 additions & 0 deletions tests/test_hub_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from relmote.agent_host import AgentHost
from relmote.hub_inventory import HubInventory


class FakePairedTargets:
def __init__(self, *, status_result=None, status_error=None):
self.status_result = status_result
self.status_error = status_error
self.status_calls = []

def targets(self):
return [
{
"name": "target-a",
"workspace": "/workspace",
"state": "active",
"capabilities": ["workspace.list", "workspace.read"],
}
]

def status(self, target):
self.status_calls.append(target)
if self.status_error is not None:
raise self.status_error
return self.status_result or {
"state": "active",
"workspace": "/workspace",
"capabilities": ["workspace.list", "workspace.read"],
}


def fake_host():
return AgentHost(
name="agent-host-a",
platform="linux",
architecture="x86_64",
capabilities=("relmote.agent-client", "development.codex"),
tools=("git", "codex"),
)


def fake_build():
return {
"display_version": "0.1.0-dev.12",
"commit": "abcdef1234567890",
"short_commit": "abcdef12",
"channel": "repository",
}


def test_hub_inventory_is_credential_blind_and_co_located():
snapshot = HubInventory(
paired_targets=FakePairedTargets(),
host_factory=fake_host,
build_factory=fake_build,
).snapshot()

assert snapshot["hub"] == {
"role": "hub",
"mode": "local-read-only",
"co_located_agent_host": True,
"authority": "inventory-only",
}
assert snapshot["agent_host"]["name"] == "agent-host-a"
assert snapshot["agent_host"]["relmote"]["short_commit"] == "abcdef12"
assert snapshot["paired_targets"][0]["role"] == "paired_target"

text = repr(snapshot)
assert "token" not in text
assert "base_url" not in text


def test_hub_live_inventory_reports_active_target():
service = FakePairedTargets()
snapshot = HubInventory(
paired_targets=service,
host_factory=fake_host,
build_factory=fake_build,
).snapshot(live=True)

live = snapshot["paired_targets"][0]["live"]
assert service.status_calls == ["target-a"]
assert live["reachable"] is True
assert live["authority"] == "active"
assert live["state"] == "active"


def test_hub_live_inventory_distinguishes_revoked_from_unreachable():
denied = HubInventory(
paired_targets=FakePairedTargets(
status_error=PermissionError("agent session is not active")
),
host_factory=fake_host,
build_factory=fake_build,
).snapshot(live=True)["paired_targets"][0]["live"]

unavailable = HubInventory(
paired_targets=FakePairedTargets(
status_error=ConnectionError("connection refused")
),
host_factory=fake_host,
build_factory=fake_build,
).snapshot(live=True)["paired_targets"][0]["live"]

assert denied["reachable"] is True
assert denied["authority"] == "denied"
assert "not active" in denied["detail"]

assert unavailable["reachable"] is False
assert unavailable["authority"] == "unknown"
assert "connection refused" in unavailable["detail"]
Loading