From f2a88878152d02756106ea16c3e470eba68e81cb Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 28 Jul 2026 12:15:38 -0400 Subject: [PATCH] feat(providers): tunnel custom endpoints over SSH Add process-local SSH forwarding for custom OpenAI-compatible endpoints. Persist optional SSH settings through the endpoint API and Desktop form, then rewrite the endpoint only at runtime for CLI, TUI, and Desktop. --- .../settings/custom-endpoints-settings.tsx | 66 +++++- apps/desktop/src/types/hermes.ts | 9 + hermes_cli/config.py | 6 + hermes_cli/runtime_provider.py | 27 ++- hermes_cli/ssh_tunnel.py | 191 ++++++++++++++++++ hermes_cli/web_server.py | 20 ++ .../test_runtime_provider_resolution.py | 23 +++ tests/hermes_cli/test_ssh_tunnel.py | 73 +++++++ 8 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 hermes_cli/ssh_tunnel.py create mode 100644 tests/hermes_cli/test_ssh_tunnel.py diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index b74b30a2909..b6f7514c513 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -32,6 +32,10 @@ interface EndpointForm { makeDefault: boolean model: string name: string + sshHost: string + sshKeyPath: string + sshPort: string + sshUser: string } const EMPTY_FORM: EndpointForm = { @@ -42,7 +46,11 @@ const EMPTY_FORM: EndpointForm = { id: '', makeDefault: true, model: '', - name: '' + name: '', + sshHost: '', + sshKeyPath: '', + sshPort: '', + sshUser: '' } function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { @@ -54,7 +62,11 @@ function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { id: endpoint.id, makeDefault: Boolean(endpoint.is_current), model: endpoint.model, - name: endpoint.name + name: endpoint.name, + sshHost: endpoint.ssh_tunnel?.host ?? '', + sshKeyPath: endpoint.ssh_tunnel?.key_path ?? '', + sshPort: endpoint.ssh_tunnel?.port ? String(endpoint.ssh_tunnel.port) : '', + sshUser: endpoint.ssh_tunnel?.user ?? '' } } @@ -70,7 +82,15 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined, discover_models: form.discoverModels, make_default: form.makeDefault, - models: models?.length ? models : undefined + models: models?.length ? models : undefined, + ssh_tunnel: form.sshHost.trim() + ? { + host: form.sshHost.trim(), + key_path: form.sshKeyPath.trim() || undefined, + port: Number.parseInt(form.sshPort, 10) || undefined, + user: form.sshUser.trim() || undefined + } + : {} } } @@ -320,6 +340,46 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C value={form.baseUrl} /> +
+
+ SSH tunnel (optional) — the endpoint URL is resolved from the remote host; Hermes chooses a private local port. +
+
+ + + + +
+