From 9493d397900007d470f77e15f85396b7f4116a66 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Wed, 24 Jun 2026 16:43:54 +0700 Subject: [PATCH 1/4] Update README.md --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e8ecdb9..7967982 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# hermes-nodes-plugin +# Hermes Node Plugin A Hermes Agent plugin that turns any Hermes profile into a “brain” to command remote nodes over an authenticated WebSocket. ## Table of Contents @@ -17,14 +17,14 @@ Install Python 3.11+ and have a Hermes Agent set up. Install the plugin: ```bash - git clone https://github.com/blaspat/hermes-nodes-plugin \ - ~/.hermes/plugins/hermes-nodes-plugin + git clone https://github.com/blaspat/hermes-node-plugin \ + ~/.hermes/plugins/hermes-node-plugin ``` -Then, update your `config.yaml`. Add `hermes-nodes-plugin` +Then, update your `config.yaml`. Add `hermes-node-plugin` ```yaml plugins: enabled: - - hermes-nodes-plugin + - hermes-node-plugin ``` ## Core Features @@ -36,7 +36,7 @@ plugins: ## Usage ### 1. Configure -Edit `~/.hermes/hermes-nodes.yaml` with host/port/TLS settings. +Edit `~/.hermes/hermes-node.yaml` with host/port/TLS settings. Optional retry settings (defaults shown): @@ -127,7 +127,7 @@ hermes node revoke --name my-devbox A: All interactions are logged to `~/.hermes/logs/nodes-audit.log` and retained per `audit_retention_days`. ## Related -- **[hermes‑nodes](`github.com/blaspat/hermes-nodes`):** Remote node binary. +- **[Hermes Node (client)](`github.com/blaspat/hermes-node`):** Remote node binary. - **[Hermes Agent](`github.com/NousResearch/hermes-agent`):** Core framework. --- From f6a08e0bd1a9337b0c96bdfe8aa6c32aba0ca299 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Wed, 24 Jun 2026 16:44:30 +0700 Subject: [PATCH 2/4] Update plugin.yaml --- plugin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.yaml b/plugin.yaml index a1384bf..537aa48 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,4 +1,4 @@ -name: hermes-nodes-plugin +name: hermes-node-plugin version: 0.1.0 description: "Hermes Agent plugin: WSS node server for distributed edge node execution. Provides node_exec, node_read, node_write, and node_list tools for running commands on paired remote nodes." author: Blasius Patrick From c6c7096bd249ddadca48d3d835aac4708dd143a5 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Wed, 24 Jun 2026 16:44:58 +0700 Subject: [PATCH 3/4] Update __init__.py --- __init__.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/__init__.py b/__init__.py index feb81b4..462a1ae 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ -"""hermes-nodes-plugin: Hermes Agent plugin for remote node control. +"""hermes-node-plugin: Hermes Agent plugin for remote node control. -Pairs with the ``hermes-nodes`` Go binary. The plugin turns Agent (or any +Pairs with the ``hermes-node`` Go binary. The plugin turns Agent (or any Hermes agent) into a "brain" that can exec / read / write on paired remote nodes over an authenticated WSS connection. @@ -78,7 +78,7 @@ async def _on_session_end_lazy(session_id: str = "", **kwargs) -> None: ctx.register_hook("on_session_start", _on_session_start_lazy) ctx.register_hook("on_session_end", _on_session_end_lazy) except Exception as exc: - log.warning("hermes-nodes-plugin: hook registration failed: %s", exc) + log.warning("hermes-node-plugin: hook registration failed: %s", exc) # ------------------------------------------------------------------ # # 2. CLI subcommand — guarded separately # @@ -98,14 +98,14 @@ def _node_handler_lazy(args) -> None: ctx.register_cli_command( "node", help=( - "Manage paired hermes-nodes (WSS node server). " + "Manage paired hermes-node (WSS node server). " "Subcommands: pair, list, revoke, status." ), setup_fn=_setup_node_subcommand_lazy, handler_fn=_node_handler_lazy, ) except Exception as exc: - log.warning("hermes-nodes-plugin: CLI registration failed: %s", exc) + log.warning("hermes-node-plugin: CLI registration failed: %s", exc) # ------------------------------------------------------------------ # # 3. Agent tools # @@ -136,7 +136,7 @@ def _node_handler_lazy(args) -> None: emoji=emoji, ) except Exception as exc: - log.warning("hermes-nodes-plugin: tool registration failed: %s", exc) + log.warning("hermes-node-plugin: tool registration failed: %s", exc) # ------------------------------------------------------------------ # # 4. Auto-start the WSS server # @@ -187,7 +187,7 @@ def _start_server() -> None: loop.run_until_complete(_on_session_start()) log.info( - "hermes-nodes-plugin: WSS server started on port %d" + "hermes-node-plugin: WSS server started on port %d" " (background thread)", _check_port, ) @@ -195,7 +195,7 @@ def _start_server() -> None: loop.run_forever() except Exception as exc: log.warning( - "hermes-nodes-plugin: server background thread failed: %s", + "hermes-node-plugin: server background thread failed: %s", exc, ) finally: @@ -205,20 +205,20 @@ def _start_server() -> None: import threading t = threading.Thread( - target=_start_server, daemon=True, name="hermes-nodes-wss" + target=_start_server, daemon=True, name="hermes-node-wss" ) t.start() except Exception as exc: log.warning( - "hermes-nodes-plugin: could not start server thread: %s", exc + "hermes-node-plugin: could not start server thread: %s", exc ) else: log.debug( - "hermes-nodes-plugin: port %d already bound — " + "hermes-node-plugin: port %d already bound — " "server likely already running. Skipping auto-start.", _check_port, ) else: log.debug( - "hermes-nodes-plugin: auto-start disabled (HERMES_NODES_AUTO_START=0)" - ) \ No newline at end of file + "hermes-node-plugin: auto-start disabled (HERMES_NODES_AUTO_START=0)" + ) From 0caccb0ea6344c8decc57ed7de5a743788aff218 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Wed, 24 Jun 2026 16:56:10 +0700 Subject: [PATCH 4/4] =?UTF-8?q?Rename=20remaining=20hermes-nodes=20?= =?UTF-8?q?=E2=86=92=20hermes-node=20refs=20across=2016=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the rename from hermes-nodes-plugin to hermes-node-plugin and hermes-nodes to hermes-node across all Python source files, documentation, CI config, and security docs. - Update all log message prefixes, docstrings, task names - Fix DEFAULT_CONFIG_PATH to ~/.hermes/hermes-node.yaml - Update Go binary config dir from ~/.hermes-nodes/ to ~/.hermes-node/ - Update GitHub URLs and cross-references - Update archive name in release workflow Signed-off-by: Blasius Patrick --- .github/workflows/release.yml | 6 +-- CONTRIBUTING.md | 8 ++-- SECURITY.md | 6 +-- audit.py | 12 ++--- cli.py | 8 ++-- config.py | 8 ++-- docs/PROTOCOL.md | 4 +- docs/REQUIREMENTS.md | 8 ++-- docs/architecture.md | 82 +++++++++++++++++------------------ environment.py | 4 +- errors.py | 2 +- lifecycle.py | 48 ++++++++++---------- schemas.py | 2 +- scripts/run_server.py | 18 ++++---- tokens.py | 2 +- wsserver/server.py | 8 ++-- 16 files changed, 113 insertions(+), 113 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4274bfc..742e27e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,9 +49,9 @@ jobs: - name: Build source archive run: | TAG="${{ github.ref_name }}" - ARCHIVE="hermes-nodes-plugin-${TAG#v}.tar.gz" - mkdir -p dist - git archive --format=tar.gz --prefix="hermes-nodes-plugin-${TAG#v}/" \ + ARCHIVE="hermes-node-plugin-${TAG#v}.tar.gz" + # Use git archive to create the tarball + git archive --format=tar.gz --prefix="hermes-node-plugin-${TAG#v}/" \ -o "dist/${ARCHIVE}" HEAD echo "artifact=${ARCHIVE}" >> "$GITHUB_ENV" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98f273f..d7c5bb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,15 +13,15 @@ for getting changes reviewed and merged. file, ship the cleanup as a separate PR. - **Tests required for behavior changes.** A bug fix without a regression test is incomplete. The test suite is `pytest tests/ -v`; the project also - has E2E tests gated on `-m e2e` that need the `hermes-nodes` Go binary. + has E2E tests gated on `-m e2e` that need the `hermes-node` Go binary. - **Don't reformat unrelated code.** Keep diffs focused. A drive-by `black` pass makes the reviewer read more than they need to. ## Local setup ```bash -git clone https://github.com/blaspat/hermes-nodes-plugin.git -cd hermes-nodes-plugin +git clone https://github.com/blaspat/hermes-node-plugin.git +cd hermes-node-plugin # Run the test suite pytest tests/ -v @@ -89,7 +89,7 @@ Don't file public issues for security bugs. See - Unit tests live in `tests/` and mirror the source layout. - E2E tests in `tests/e2e/` are gated behind `@pytest.mark.e2e` and need - the Go binary from [`hermes-nodes`](https://github.com/blaspat/hermes-nodes) + the Go binary from [`hermes-node`](https://github.com/blaspat/hermes-node) built and on `PATH`. Run them with `pytest tests/e2e/ -v -m e2e`. - New features need at least one happy-path test and one failure-path test in the same PR. diff --git a/SECURITY.md b/SECURITY.md index c04e9b6..aebd21c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ - **Token format:** 32 random bytes, base64url-encoded, generated with `secrets.token_urlsafe(32)`. ~256 bits of entropy. - **Token comparison:** constant-time on the server (`hmac.compare_digest`). - **Token storage (server):** Fernet-encrypted (AES-128-CBC + HMAC-SHA256) at `~/.hermes/nodes/tokens.json`. The Fernet key is loaded from `HERMES_NODES_TOKEN_KEY`. -- **Token storage (node):** plaintext in `~/.hermes-nodes/config.toml`, file mode `0600`. **v1 limitation** — see "Future hardening" below. +- **Token storage (node):** plaintext in `~/.hermes-node/config.toml`, file mode `0600`. **v1 limitation** — see "Future hardening" below. - **Token lifetime:** unlimited. Revoked only by `hermes node revoke`. v1 has no auto-rotation. ## Transport @@ -44,7 +44,7 @@ **Both sides log every call.** Format is identical: one JSON object per line. -**Node-side log:** `~/.hermes-nodes/audit.log` (configurable). Default retention: 90 days (configurable). +**Node-side log:** `~/.hermes-node/audit.log` (configurable). Default retention: 90 days (configurable). **Server-side log:** `~/.hermes/logs/nodes-audit.log`. Default retention: 1 year (configurable). **Fields:** @@ -88,7 +88,7 @@ This list is important for a security review. The node binary: - ❌ Does not capture keystrokes or clipboard. - ❌ Does not modify system settings, install software, or change firewall rules. - ❌ Does not open any inbound network ports. -- ❌ Does not write to disk outside `~/.hermes-nodes/` (the config dir). +- ❌ Does not write to disk outside `~/.hermes-node/` (the config dir). - ❌ Does not read files outside the configured `allowed_paths`. - ❌ Does not load dynamic code or evaluate external input. - ❌ Does not phone home (no telemetry, no update checks, no analytics). diff --git a/audit.py b/audit.py index e5c6ea1..be43c31 100644 --- a/audit.py +++ b/audit.py @@ -1,4 +1,4 @@ -"""Append-only JSONL audit log for hermes-nodes-plugin calls (Task 2.9). +"""Append-only JSONL audit log for hermes-node-plugin calls (Task 2.9). Every ``node_exec`` / ``node_read`` / ``node_write`` call (successful, errored, timed out, or refused because the node is offline) is recorded @@ -369,7 +369,7 @@ def record( # the write rather than losing the row # silently or breaking the call site. logger.warning( - "hermes-nodes audit: record() called on a closed writer " + "hermes-node audit: record() called on a closed writer " "(node=%r, action=%r, status=%r) — row dropped", node, action, @@ -406,7 +406,7 @@ def record( # rotation errors that would otherwise violate the # ``record()`` never-raises contract (Issue #11). logger.warning( - "hermes-nodes audit: failed to write entry " + "hermes-node audit: failed to write entry " "(node=%r, action=%r, status=%r): %s", node, action, @@ -471,7 +471,7 @@ def purge_expired_rotations(self, *, now: float | None = None) -> int: entries = sorted(parent.iterdir()) except OSError as exc: logger.warning( - "hermes-nodes audit: cannot list %s to purge rotations: %s", + "hermes-node audit: cannot list %s to purge rotations: %s", parent, exc, ) @@ -493,7 +493,7 @@ def purge_expired_rotations(self, *, now: float | None = None) -> int: except OSError as exc: # Don't fail the whole sweep on one unreadable file. logger.warning( - "hermes-nodes audit: cannot delete expired rotation %s: %s", + "hermes-node audit: cannot delete expired rotation %s: %s", entry, exc, ) @@ -665,7 +665,7 @@ def _resolve_audit_config(env: Mapping[str, str] | None = None) -> AuditConfig: retention = int(retention_raw) except (TypeError, ValueError): logger.warning( - "hermes-nodes audit: invalid %s=%r; using default %d", + "hermes-node audit: invalid %s=%r; using default %d", RETENTION_ENV_VAR, retention_raw, DEFAULT_RETENTION_DAYS, diff --git a/cli.py b/cli.py index 19fcb5f..eaeb201 100644 --- a/cli.py +++ b/cli.py @@ -101,7 +101,7 @@ def setup_node_cli(subparser: argparse.ArgumentParser) -> None: that's been built since the plugin first installed. """ subparser.description = ( - "Manage paired hermes-nodes (WSS node server). " + "Manage paired hermes-node (WSS node server). " "Subcommands: pair, list, revoke, status." ) subs = subparser.add_subparsers(dest="node_action") @@ -422,10 +422,10 @@ def _cmd_status(args: argparse.Namespace | None = None) -> int: try: s.connect((config.connect_host, config.port)) s.close() - print(f"hermes-nodes server: listening on {config.connect_host}:{config.port}") + print(f"hermes-node server: listening on {config.connect_host}:{config.port}") return 0 except (OSError, socket.timeout): - print("hermes-nodes server: not running") + print("hermes-node server: not running") return 1 @@ -494,7 +494,7 @@ def main() -> None: """ parser = argparse.ArgumentParser( prog="hermes-node", - description="Manage paired hermes-nodes (WSS node server).", + description="Manage paired hermes-node (WSS node server).", ) setup_node_cli(parser) args = parser.parse_args() diff --git a/config.py b/config.py index 30d8d06..7a41d9c 100644 --- a/config.py +++ b/config.py @@ -1,9 +1,9 @@ -"""Configuration loader for the hermes-nodes plugin. +"""Configuration loader for the hermes-node plugin. Precedence (highest to lowest): 1. Environment variables (``HERMES_NODES_*``). - 2. YAML config file at ``~/.hermes/hermes-nodes.yaml`` (path overridable + 2. YAML config file at ``~/.hermes/hermes-node.yaml`` (path overridable via ``load_config(config_path=...)``). 3. Built-in defaults baked into :class:`NodeServerConfig`. @@ -90,7 +90,7 @@ # Constants # --------------------------------------------------------------------------- -DEFAULT_CONFIG_PATH = Path("~/.hermes/hermes-nodes.yaml").expanduser() +DEFAULT_CONFIG_PATH = Path("~/.hermes/hermes-node.yaml").expanduser() DEFAULT_TOKEN_STORE_PATH = Path("~/.hermes/nodes/tokens.json") DEFAULT_TOKEN_STORE_STR = "~/.hermes/nodes/tokens.json" @@ -628,7 +628,7 @@ def load_config( Tests use this to inject fixtures without touching the real process environment. config_path: Override the YAML file location (defaults to - ``~/.hermes/hermes-nodes.yaml``). A missing file is *not* an + ``~/.hermes/hermes-node.yaml``). A missing file is *not* an error — the loader falls through to dataclass defaults. Returns: diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 71b5d79..98b2e7e 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -3,7 +3,7 @@ The wire protocol spoken between a Hermes Agent brain (Python, on the server/VPS) and a Hermes Node (Go, on the laptop/remote machine) is **defined canonically in -[`hermes-nodes/PROTOCOL.md`](https://github.com/blaspat/hermes-nodes/blob/main/PROTOCOL.md)**. +[`hermes-node/PROTOCOL.md`](https://github.com/blaspat/hermes-node/blob/main/PROTOCOL.md)**. Both implementations must conform to that contract. This document exists for two reasons: @@ -24,7 +24,7 @@ doc wins. File a PR against this file to bring it back in sync. ## 1. Canonical protocol See -[`hermes-nodes/PROTOCOL.md`](https://github.com/blaspat/hermes-nodes/blob/main/PROTOCOL.md). +[`hermes-node/PROTOCOL.md`](https://github.com/blaspat/hermes-node/blob/main/PROTOCOL.md). That doc covers: - Connection lifecycle (hello / hello_ack / auth / exec / bye) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index f38e8ce..ae9a2c3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,6 +1,6 @@ -# hermes-nodes-plugin: Requirements +# hermes-node-plugin: Requirements -This document is the source of truth for what the `hermes-nodes-plugin` package must do. The implementation plan in `/home/User/.hermes/plans/2026-06-04_001727-hermes-nodes.md` derives from this. +This document is the source of truth for what the `hermes-node-plugin` package must do. The implementation plan derives from this. > **Audience:** Agent (implements), Quinn (validates against), User (approves). @@ -58,7 +58,7 @@ This document is the source of truth for what the `hermes-nodes-plugin` package ### FR-4: Configuration -**FR-4.1** Plugin reads configuration from `~/.hermes/hermes-nodes.yaml` and env vars. Env vars override file values. Defaults if neither set: +**FR-4.1** Plugin reads configuration from `~/.hermes/hermes-node.yaml` and env vars. Env vars override file values. Defaults if neither set: - `host`: `127.0.0.1` (the safe default — assumes nginx is fronting TLS) - `port`: `7000` - `tls_cert_path`: unset (TLS termination is expected at the reverse proxy) @@ -173,7 +173,7 @@ All of the following must be true: 5. ✅ The plugin's unit test suite passes in CI with >= 80% coverage. 6. ✅ The e2e test in `tests/e2e/test_full_flow.py` passes on Linux amd64 (CI), and the install scripts work on a clean Mac and a clean Windows machine (manual verification by User). 7. ✅ `SECURITY-REVIEW.md` exists and is suitable for showing to a corporate security team. -8. ✅ A `pip install git+https://github.com/blaspat/hermes-nodes-plugin.git` in any Hermes profile's venv results in the plugin auto-loading and the `hermes node ...` commands appearing in the CLI. +8. ✅ A `pip install git+https://github.com/blaspat/hermes-node-plugin.git` in any Hermes profile's venv results in the plugin auto-loading and the `hermes node ...` commands appearing in the CLI. --- diff --git a/docs/architecture.md b/docs/architecture.md index 95cad10..614bfc6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,4 @@ -# hermes-nodes Architecture +# hermes-node Architecture ## Overview @@ -6,18 +6,18 @@ Two independently-versioned repos: | Repo | Language | Role | |------|----------|------| -| `hermes-nodes` | Go | Node client binary (runs on each remote machine) | -| `hermes-nodes-plugin` | Python | Hermes Agent plugin + WS server (runs on VPS) | +| `hermes-node` | Go | Node client binary (runs on each remote machine) | +| `hermes-node-plugin` | Python | Hermes Agent plugin + WS server (runs on VPS) | ### Two CLIs — two binaries -It is easy to confuse `hermes node` and `hermes-nodes`. They are completely separate programs: +It is easy to confuse `hermes node` and `hermes-node`. They are completely separate programs: -- **`hermes-nodes`** — Standalone Go binary installed on each node machine. - - `hermes-nodes node start --server wss://:` — connect to WS server - - `hermes-nodes pair --server wss://: --token --name ` — pair with WS server using a token +- **`hermes-node`** — Standalone Go binary installed on each node machine. + - `hermes-node node start --server wss://:` — connect to WS server + - `hermes-node pair --server wss://: --token --name ` — pair with WS server using a token -- **`hermes node`** — Plugin commands registered with the Hermes Agent CLI (Python). Only available when `hermes-nodes-plugin` is loaded. +- **`hermes node`** — Plugin commands registered with the Hermes Agent CLI (Python). Only available when `hermes-node-plugin` is loaded. - `hermes node server start|stop|status` — manage the WS server on the VPS - `hermes node list` — show paired and connected nodes - `hermes node revoke ` — revoke a node's pairing token @@ -31,7 +31,7 @@ It is easy to confuse `hermes node` and `hermes-nodes`. They are completely sepa │ VPS (WS server only — Python plugin code runs in Kate) │ │ │ │ Port: 7000 (configurable) │ -│ Log: ~/.hermes-nodes/server.log │ +│ Log: ~/.hermes-node/server.log │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ WS Server │ │ @@ -48,18 +48,18 @@ It is easy to confuse `hermes node` and `hermes-nodes`. They are completely sepa │ ┌──────┴─────────┐ │ Node A │ - │ (hermes-nodes │ + │ (hermes-node │ │ client) │ ├────────────────┤ │ Node B │ - │ (hermes-nodes │ + │ (hermes-node │ │ client) │ └────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Hermes Agent (Kate) │ │ │ -│ ~/.hermes/profiles/kate/plugins/hermes-nodes-plugin/ │ +│ ~/.hermes/profiles/kate/plugins/hermes-node-plugin/ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Tools (node_exec, node_read, node_write, node_list) │ │ @@ -72,22 +72,22 @@ It is easy to confuse `hermes node` and `hermes-nodes`. They are completely sepa --- -## hermes-nodes (Go) +## hermes-node (Go) -Installed on each node machine. Download from the [hermes-nodes releases page](https://github.com/blaspat/hermes-nodes/releases) or build from source with `go install`. +Installed on each node machine. Download from the [hermes-node releases page](https://github.com/blaspat/hermes-node/releases) or build from source with `go install`. ### Commands ```bash -hermes-nodes node start --server wss://: # Start node client (connects to WS server, default port 7000) -hermes-nodes pair --server wss://: --token --name # Pair node client to WS server +hermes-node node start --server wss://: # Start node client (connects to WS server, default port 7000) +hermes-node pair --server wss://: --token --name # Pair node client to WS server ``` **Node → WS Server protocol:** Each node client maintains a persistent WS connection. When Hermes Agent sends an exec/read/write HTTP request, the WS server relays it over the node's live WS connection and waits for the response via a waiter/future pattern (request ID matching). -**Log file:** The Python WS server on the VPS writes to `~/.hermes-nodes/server.log`. Each node client (Go binary) also writes to `~/.hermes-nodes/server.log` on its own machine. Both rotate daily, 10MB max. +**Log file:** The Python WS server on the VPS writes to `~/.hermes-node/server.log`. Each node client (Go binary) also writes to `~/.hermes-node/server.log` on its own machine. Both rotate daily, 10MB max. ### Pairing @@ -101,15 +101,15 @@ hermes node pair --name **On the node machine (client side):** ```bash -hermes-nodes pair --server wss://: --token --name +hermes-node pair --server wss://: --token --name # → presents token to server, token is bound to this node name ``` -After pairing, the node is configured with the token in `~/.hermes-nodes/config.yaml`. Subsequent connections use `node start` instead of `pair`: +After pairing, the node is configured with the token in `~/.hermes-node/config.yaml`. Subsequent connections use `node start` instead of `pair`: ```bash -hermes-nodes node start --server wss://: -# → reads token from ~/.hermes-nodes/config.yaml, connects +hermes-node node start --server wss://: +# → reads token from ~/.hermes-node/config.yaml, connects ``` **Revocation:** `hermes node revoke ` on the VPS deletes the token. The node cannot reconnect — it must `pair` again with a new token. @@ -122,26 +122,26 @@ hermes-nodes node start --server wss://: Runs on each remote machine. Connects to the WS server and registers itself by name. -**Auth:** Uses the same pre-shared token as the WS server (configured in `~/.hermes-nodes/config.yaml` on the node machine). +**Auth:** Uses the same pre-shared token as the WS server (configured in `~/.hermes-node/config.yaml` on the node machine). -**Configuration:** All configuration (Auth, Name, WS Server URL, etc) configured in `~/.hermes-nodes/config.yaml` on the node machine +**Configuration:** All configuration (Auth, Name, WS Server URL, etc) configured in `~/.hermes-node/config.yaml` on the node machine **Startup:** ```bash -hermes-nodes node start --server wss://: # default port 7000 +hermes-node node start --server wss://: # default port 7000 ``` --- -## hermes-nodes-plugin (Python) +## hermes-node-plugin (Python) -Located at: `~/.hermes/profiles/kate/plugins/hermes-nodes-plugin/` +Located at: `~/.hermes/profiles/kate/plugins/hermes-node-plugin/` Installed by copying the plugin directory to the plugins folder (no pip install). ### Installation -1. Copy `hermes-nodes-plugin/` to `~/.hermes/profiles/kate/plugins/` +1. Copy `hermes-node-plugin/` to `~/.hermes/profiles/kate/plugins/` 2. Ensure `hermes_nodes` config block is present in `~/.hermes/profiles/kate/config.yaml` 3. (Re)start Hermes Agent — the plugin is auto-loaded at startup, and `hermes node ...` commands become available @@ -159,13 +159,13 @@ The `hermes node` commands only appear when the plugin is loaded. ### WS Server (VPS side) -Runs on the VPS alongside hermes-nodes-plugin. Started automatically via `on_session_start` plugin hook (auto-start). +Runs on the VPS alongside hermes-node-plugin. Started automatically via `on_session_start` plugin hook (auto-start). **TCP port:** 7000 (default, configurable via `HERMES_NODES_PORT`) **Token auth:** Nodes authenticate using pairing tokens generated by `hermes node pair --name `. Tokens are stored encrypted in `~/.hermes/nodes/tokens.json`. The WS server validates tokens via the `TokenStore`. The Fernet encryption key is read from the env var named by `token_encryption_key_env` (default `HERMES_NODES_TOKEN_KEY`). -**WS Server config:** Reads token store path and Fernet key env var name from `~/.hermes/hermes-nodes.yaml` (or env vars). The WS server itself has no separate auth token — tool calls from the Hermes Agent to the WS server use plain HTTP on localhost with no additional auth (the server is not exposed externally). +**WS Server config:** Reads token store path and Fernet key env var name from `~/.hermes/hermes-node.yaml` (or env vars). The WS server itself has no separate auth token — tool calls from the Hermes Agent to the WS server use plain HTTP on localhost with no additional auth (the server is not exposed externally). **HTTP Endpoints** (internal, same host, no additional auth): @@ -205,10 +205,10 @@ Header: Authorization: Bearer (token presented on WS connect) ### Hermes Agent Config -The plugin reads from `~/.hermes/hermes-nodes.yaml` (and env vars, which override file values): +The plugin reads from `~/.hermes/hermes-node.yaml` (and env vars, which override file values): ```yaml -# ~/.hermes/hermes-nodes.yaml +# ~/.hermes/hermes-node.yaml host: "127.0.0.1" # WS server bind address (default) # connect_host is resolved automatically at load time: the loader probes # ``host`` first, then ``localhost``, and stores the reachable address here. @@ -344,13 +344,13 @@ Error: ## File Structure -### hermes-nodes (Go) — Client side only +### hermes-node (Go) — Client side only ``` -hermes-nodes/ +hermes-node/ ├── cmd/ │ └── node/ -│ └── main.go # hermes-nodes node start +│ └── main.go # hermes-node node start ├── internal/ │ ├── wsclient/ # Node WS client implementation │ └── protocol/ # Node ↔ WS server protocol @@ -360,14 +360,14 @@ hermes-nodes/ └── README.md ``` -### hermes-nodes-plugin (Python) — Server side (VPS) +### hermes-node-plugin (Python) — Server side (VPS) ``` -hermes-nodes-plugin/ +hermes-node-plugin/ ├── __init__.py # register(ctx) — exposes tools ├── tools.py # node_exec, node_read, node_write, node_list ├── schemas.py # Tool schemas -├── cli.py # hermes-nodes server|node CLI commands +├── cli.py # hermes-node server|node CLI commands ├── config.py # WS server URL / token config ├── wsserver/ # WS server implementation │ ├── __init__.py @@ -381,8 +381,8 @@ hermes-nodes-plugin/ ## Launch Sequence -1. **Hermes Agent startup:** `hermes-nodes-plugin` is loaded via `register(ctx)` → `on_session_start` hook auto-starts the WS server on port 7000 -2. **VPS:** WS server listens on `127.0.0.1:7000`; logs to `~/.hermes-nodes/server.log` -3. **Node machines:** `hermes-nodes node start --server wss://:` → each node connects to WS server via WSS -4. **Hermes Agent:** Already has `hermes-nodes-plugin` in its plugins folder → tools `node_exec`, `node_read`, `node_write`, `node_list` are registered at startup +1. **Hermes Agent startup:** `hermes-node-plugin` is loaded via `register(ctx)` → `on_session_start` hook auto-starts the WS server on port 7000 +2. **VPS:** WS server listens on `127.0.0.1:7000`; logs to `~/.hermes-node/server.log` +3. **Node machines:** `hermes-node node start --server wss://:` → each node connects to WS server via WSS +4. **Hermes Agent:** Already has `hermes-node-plugin` in its plugins folder → tools `node_exec`, `node_read`, `node_write`, `node_list` are registered at startup 5. **Usage:** Hermes Agent calls tools → HTTP to WS server → relayed over WS to node → response back diff --git a/environment.py b/environment.py index 38f757b..4309d3c 100644 --- a/environment.py +++ b/environment.py @@ -1,7 +1,7 @@ """Hermes ``BaseEnvironment`` implementation for paired remote nodes. This is the interface Agent uses to run shell commands on a paired -``hermes-nodes`` Go binary over the WSS connection. It satisfies the +``hermes-node`` Go binary over the WSS connection. It satisfies the same contract as ``hermes_agent.tools.environments.base.BaseEnvironment`` — ``execute()`` returns ``{"output": str, "returncode": int}`` — so the agent's tool layer can swap a local shell for a node shell without @@ -222,7 +222,7 @@ def _record_audit( ) except Exception as exc: # pragma: no cover — defensive logger.warning( - "hermes-nodes: audit record raised unexpectedly " + "hermes-node: audit record raised unexpectedly " "(action=%r, node=%r, status=%r): %s", action, self._target, diff --git a/errors.py b/errors.py index 0a10cb4..7f06e7a 100644 --- a/errors.py +++ b/errors.py @@ -9,7 +9,7 @@ class PluginError(Exception): - """Base class for all hermes-nodes-plugin errors.""" + """Base class for all hermes-node-plugin errors.""" class ConfigError(PluginError): diff --git a/lifecycle.py b/lifecycle.py index c875e6f..66e4363 100644 --- a/lifecycle.py +++ b/lifecycle.py @@ -170,7 +170,7 @@ async def start(self) -> None: ``startup`` returns. """ if self.is_running: - logger.debug("hermes-nodes server already running; start() is a no-op") + logger.debug("hermes-node server already running; start() is a no-op") return # Build the FastAPI app lazily on first start. ``create_app`` @@ -226,7 +226,7 @@ async def _serve() -> None: await self._server.startup() except (OSError, SystemExit) as exc: logger.warning( - "hermes-nodes: server startup failed " + "hermes-node: server startup failed " "(port %d): %s", self._config.port, exc, @@ -238,7 +238,7 @@ async def _serve() -> None: if self._server.started: await self._server.shutdown() - self._task = asyncio.create_task(_serve(), name="hermes-nodes-wss-server") + self._task = asyncio.create_task(_serve(), name="hermes-node-wss-server") # Background sweep (issue #19). Created after the uvicorn # task so the sweep is never running while the server isn't. @@ -247,7 +247,7 @@ async def _serve() -> None: self._stop_sweep = asyncio.Event() self._sweep_task = asyncio.create_task( self._sweep_stale_connections(), - name="hermes-nodes-stale-sweep", + name="hermes-node-stale-sweep", ) # Wait for the server to actually be listening before @@ -266,7 +266,7 @@ async def _serve() -> None: # doesn't leak. await self.drain(timeout=1.0) raise RuntimeError( - f"hermes-nodes server failed to bind {self._config.host}:" + f"hermes-node server failed to bind {self._config.host}:" f"{self._config.port} within 5s" ) @@ -275,7 +275,7 @@ async def _serve() -> None: # error so the caller can log it; do not raise past # here in the hook path (callers handle errors). logger.error( - "hermes-nodes server failed to start on %s:%d", + "hermes-node server failed to start on %s:%d", self._config.host, self._config.port, ) @@ -289,7 +289,7 @@ async def _serve() -> None: return logger.info( - "hermes-nodes WSS server listening on %s:%d", + "hermes-node WSS server listening on %s:%d", self._config.host, self._config.port, ) @@ -318,7 +318,7 @@ async def drain(self, *, timeout: float = 5.0) -> None: await asyncio.wait_for(asyncio.shield(sweep_task), timeout=timeout) except asyncio.TimeoutError: logger.warning( - "hermes-nodes stale sweep did not exit within %.1fs; cancelling", + "hermes-node stale sweep did not exit within %.1fs; cancelling", timeout, ) sweep_task.cancel() @@ -327,7 +327,7 @@ async def drain(self, *, timeout: float = 5.0) -> None: except (asyncio.CancelledError, Exception): pass except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes stale sweep raised on drain: %s", exc) + logger.warning("hermes-node stale sweep raised on drain: %s", exc) self._sweep_task = None server = self._server @@ -344,7 +344,7 @@ async def drain(self, *, timeout: float = 5.0) -> None: await asyncio.wait_for(asyncio.shield(task), timeout=timeout) except asyncio.TimeoutError: logger.warning( - "hermes-nodes server did not drain within %.1fs; cancelling", + "hermes-node server did not drain within %.1fs; cancelling", timeout, ) task.cancel() @@ -353,7 +353,7 @@ async def drain(self, *, timeout: float = 5.0) -> None: except (asyncio.CancelledError, Exception): pass except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes server drain raised: %s", exc) + logger.warning("hermes-node server drain raised: %s", exc) finally: self._server = None self._task = None @@ -387,7 +387,7 @@ async def _sweep_stale_connections(self) -> None: try: candidates = await self._registry.stale(older_than=stale_after) except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes stale sweep query failed: %s", exc) + logger.warning("hermes-node stale sweep query failed: %s", exc) candidates = [] for conn in candidates: @@ -401,13 +401,13 @@ async def _sweep_stale_connections(self) -> None: await _safe_close(conn.websocket, code=1000) except Exception as exc: # pragma: no cover — defensive logger.warning( - "hermes-nodes stale sweep: failed to close %r: %s", + "hermes-node stale sweep: failed to close %r: %s", conn.name, exc, ) else: logger.info( - "hermes-nodes stale sweep: closed stale connection %r " + "hermes-node stale sweep: closed stale connection %r " "(idle > %ds)", conn.name, self._config.heartbeat_stale_seconds, @@ -431,7 +431,7 @@ async def _sweep_stale_connections(self) -> None: # Re-raise so the cancelling task sees the cancellation. raise except Exception as exc: # pragma: no cover — defensive - logger.error("hermes-nodes stale sweep crashed: %s", exc) + logger.error("hermes-node stale sweep crashed: %s", exc) # --------------------------------------------------------------------------- @@ -513,30 +513,30 @@ async def _on_session_start() -> None: purged = audit.purge_expired_rotations() if purged: logger.info( - "hermes-nodes: purged %d expired audit-log rotation(s)", purged + "hermes-node: purged %d expired audit-log rotation(s)", purged ) except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: audit purge failed: %s", exc) + logger.warning("hermes-node: audit purge failed: %s", exc) except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: audit writer init failed: %s", exc) + logger.warning("hermes-node: audit writer init failed: %s", exc) try: runner = get_default_runner() except (ConfigError, TokenStoreError) as exc: logger.warning( - "hermes-nodes: cannot start server (%s) — set " - "HERMES_NODES_TOKEN_KEY and check ~/.hermes/hermes-nodes.yaml", + "hermes-node: cannot start server (%s) — set " + "HERMES_NODES_TOKEN_KEY and check ~/.hermes/hermes-node.yaml", exc, ) return except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: unexpected error building runner: %s", exc) + logger.warning("hermes-node: unexpected error building runner: %s", exc) return try: await runner.start() except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: runner.start() failed: %s", exc) + logger.warning("hermes-node: runner.start() failed: %s", exc) async def _on_session_end() -> None: @@ -557,13 +557,13 @@ async def _on_session_end() -> None: try: await runner.drain(timeout=5.0) except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: runner.drain() failed: %s", exc) + logger.warning("hermes-node: runner.drain() failed: %s", exc) try: from .audit import reset_default_audit_writer reset_default_audit_writer() except Exception as exc: # pragma: no cover — defensive - logger.warning("hermes-nodes: audit close failed: %s", exc) + logger.warning("hermes-node: audit close failed: %s", exc) # --------------------------------------------------------------------------- diff --git a/schemas.py b/schemas.py index b5557fc..5de9834 100644 --- a/schemas.py +++ b/schemas.py @@ -10,7 +10,7 @@ "name": "node_exec", "description": ( "Run a shell command on a paired remote node (e.g. a laptop with " - "the hermes-nodes Go binary installed) and return its stdout/stderr " + "the hermes-node Go binary installed) and return its stdout/stderr " "and exit code. The command runs in the node's persistent shell, " "so `cd` and `export` between calls persist. Use `hermes node list` " "(or `node_list()`) to see which nodes are currently connected." diff --git a/scripts/run_server.py b/scripts/run_server.py index d6b837a..6fc1812 100644 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -22,7 +22,7 @@ # ── paths ──────────────────────────────────────────────────────────────────── HERMES_HOME = Path.home() / ".hermes" -PLUGIN_DIR = HERMES_HOME / "plugins" / "hermes-nodes-plugin" +PLUGIN_DIR = HERMES_HOME / "plugins" / "hermes-node-plugin" # Register the hermes_nodes_plugin namespace in sys.modules so that # "from hermes_nodes_plugin.lifecycle import ..." works with flat layout. @@ -42,7 +42,7 @@ format="%(asctime)s %(levelname)s %(name)s: %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) -logger = logging.getLogger("hermes-nodes-server") +logger = logging.getLogger("hermes-node-server") # ── signal handling ─────────────────────────────────────────────────────────── @@ -77,7 +77,7 @@ def _run_server() -> None: runner = get_default_runner() logger.info( - "Starting hermes-nodes server on %s:%s …", + "Starting hermes-node server on %s:%s …", runner.host, runner.port, ) @@ -85,7 +85,7 @@ def _run_server() -> None: # runner.start() is idempotent — safe to call on an already-running runner loop.run_until_complete(runner.start()) logger.info( - "hermes-nodes server is running on %s:%s [pid=%d]", + "hermes-node server is running on %s:%s [pid=%d]", runner.host, runner.port, _get_pid(), @@ -95,12 +95,12 @@ def _run_server() -> None: loop.run_forever() except Exception as exc: - logger.exception("hermes-nodes server failed to start: %s", exc) + logger.exception("hermes-node server failed to start: %s", exc) finally: # Give loop a chance to finish pending tasks, then close loop.close() - logger.info("hermes-nodes server event loop closed") + logger.info("hermes-node server event loop closed") def _get_pid() -> int: @@ -114,11 +114,11 @@ def _get_pid() -> int: def main() -> None: - logger.info("hermes-nodes-server starting …") + logger.info("hermes-node-server starting …") server_thread = threading.Thread( target=_run_server, - name="hermes-nodes-server", + name="hermes-node-server", daemon=True, # systemd gets the main process exit; daemon thread dies with it ) server_thread.start() @@ -129,7 +129,7 @@ def main() -> None: while not _shutdown.is_set(): time.sleep(1) - logger.info("hermes-nodes-server stopped") + logger.info("hermes-node-server stopped") if __name__ == "__main__": diff --git a/tokens.py b/tokens.py index 28a4ee5..ad80bf1 100644 --- a/tokens.py +++ b/tokens.py @@ -1,4 +1,4 @@ -"""Encrypted token store for the hermes-nodes plugin. +"""Encrypted token store for the hermes-node plugin. Persists node→token bindings to a JSON file encrypted at rest with :class:`cryptography.fernet.Fernet` (AES-128-CBC + HMAC-SHA256). The diff --git a/wsserver/server.py b/wsserver/server.py index 18dcb53..45c2713 100644 --- a/wsserver/server.py +++ b/wsserver/server.py @@ -1,7 +1,7 @@ -"""WSS server for paired hermes-nodes connections. +"""WSS server for paired hermes-node connections. -Implements the server half of the auth handshake described in -``../hermes-nodes/PROTOCOL.md`` §1 (connection lifecycle) and §3.1-3.5 +Pair with ``hermes-node`` Go binary (``blaspat/hermes-node``). +``../hermes-node/PROTOCOL.md`` §1 (connection lifecycle) and §3.1-3.5 (message types ``hello`` / ``hello_ack`` / ``auth`` / ``auth_ok`` / ``auth_err``). The server is the dial-ee — nodes are outbound-only and connect to ``/ws/nodes`` on this FastAPI app. @@ -281,7 +281,7 @@ def create_app( clock=clock, ) - app = FastAPI(title="hermes-nodes WSS server") + app = FastAPI(title="hermes-node WSS server") app.state.token_store = token_store app.state.registry = registry app.state.config = config