diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index cbd918e..9ba485f 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -8,10 +8,10 @@ system monitoring, troubleshooting, configuration management, and infrastructure tasks. You have access to the following tools — use them directly without hesitation: -- ssh_exec: execute commands on remote servers via SSH. Use named connections from - ssh_hosts.json (e.g. connection="prod") or inline "user@host". This is the primary - tool for any task involving a remote server. ALWAYS use it when the user mentions - a remote host, VPS, or server name. +- ssh_exec: execute commands on remote servers via SSH. Pass host, username, password + (and optionally port, key_path) directly as tool parameters — no config file needed. + The user can give you credentials in their message and you pass them straight to the + tool. ALWAYS use this tool when the user mentions a remote host, VPS, or server. - terminal: run commands on the LOCAL machine - filesystem: read and write files on the local machine - http_request: call REST APIs, monitoring endpoints, or health checks diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index cf1e23a..1b4bd1b 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -12,9 +12,9 @@ - filesystem: read and write automation scripts and configuration files - code_exec: generate and test Home Assistant YAML automations or Python scripts - terminal: run system-level commands on the local machine -- ssh_exec: execute commands on remote hosts via SSH (e.g. the Home Assistant server - or other smart home hubs). Use named connections from ssh_hosts.json or "user@host". - ALWAYS use it for any task involving a remote host — the tool handles the connection. +- ssh_exec: execute commands on remote hosts via SSH. Pass host, username, password + (and optionally port, key_path) directly as tool parameters — no config file needed. + ALWAYS use it for any task involving a remote host. Always confirm before making irreversible changes to device state or automation configuration. When writing automations, prefer clear, well-commented YAML. diff --git a/navi/tools/ssh_exec.py b/navi/tools/ssh_exec.py index 973f07a..b716b03 100644 --- a/navi/tools/ssh_exec.py +++ b/navi/tools/ssh_exec.py @@ -51,43 +51,61 @@ name = "ssh_exec" description = ( "Execute a command on a remote server via SSH. " - "Use a named connection from ssh_hosts.json or specify host/username directly." + "Credentials (host, username, password or key) can be passed directly as parameters. " + "Optionally use a named connection from ssh_hosts.json as a shortcut." ) parameters = { "type": "object", "properties": { - "connection": { - "type": "string", - "description": ( - "Named connection from ssh_hosts.json (e.g. 'prod'), " - "or 'user@host' for a direct connection using default SSH keys." - ), - }, "command": { "type": "string", "description": "Shell command to run on the remote host", }, + "host": { + "type": "string", + "description": "Hostname or IP address of the remote server", + }, + "username": { + "type": "string", + "description": "SSH username", + }, + "password": { + "type": "string", + "description": "SSH password (if using password authentication)", + }, + "port": { + "type": "integer", + "description": "SSH port (default 22)", + }, + "key_path": { + "type": "string", + "description": "Path to private key file, e.g. ~/.ssh/id_rsa (if using key authentication)", + }, + "connection": { + "type": "string", + "description": "Named connection from ssh_hosts.json — shortcut that provides host/user/creds automatically", + }, "timeout": { "type": "integer", "description": f"Timeout in seconds (default {_TIMEOUT})", }, }, - "required": ["connection", "command"], + "required": ["command"], } async def execute(self, params: dict) -> ToolResult: - connection = params["connection"].strip() command = params["command"].strip() timeout = int(params.get("timeout") or _TIMEOUT) - connect_kwargs = self._resolve_connection(connection) + connect_kwargs = self._resolve(params) if connect_kwargs is None: - hosts = list(_load_hosts().keys()) - hint = f"Available named connections: {hosts}" if hosts else "No ssh_hosts.json found." return ToolResult( success=False, - output=f"Unknown connection '{connection}'. {hint}", - error="unknown_connection", + output=( + "No SSH target specified. Provide 'host' (and optionally 'username', " + "'password', 'key_path'), or a named 'connection' from ssh_hosts.json." + ), + error="no_target", ) try: @@ -113,27 +131,44 @@ except asyncssh.DisconnectError as e: return ToolResult(success=False, output=f"SSH disconnected: {e}", error=str(e)) except asyncssh.PermissionDenied: - return ToolResult(success=False, output="SSH permission denied. Check credentials.", error="permission_denied") + return ToolResult(success=False, output="SSH permission denied. Check username and password/key.", error="permission_denied") except (TimeoutError, asyncio.TimeoutError): return ToolResult(success=False, output=f"SSH command timed out after {timeout}s", error="timeout") except Exception as e: return ToolResult(success=False, output=f"SSH error: {e}", error=str(e)) - def _resolve_connection(self, connection: str) -> dict | None: - hosts = _load_hosts() + def _resolve(self, params: dict) -> dict | None: + # Named connection from ssh_hosts.json takes precedence + connection = params.get("connection", "").strip() + if connection: + hosts = _load_hosts() + if connection in hosts: + cfg = dict(hosts[connection]) + # Inline params override stored values + for k in ("host", "username", "password", "port"): + if params.get(k): + cfg[k] = params[k] + if params.get("key_path"): + cfg["client_keys"] = [params["key_path"]] + return self._build_kwargs(cfg) - # Named connection - if connection in hosts: - cfg = hosts[connection] - return self._build_kwargs(cfg) + # Direct params + host = params.get("host", "").strip() + if not host: + return None - # Inline user@host - if "@" in connection: - parts = connection.split("@", 1) - username, host = parts[0], parts[1] - return self._build_kwargs({"host": host, "username": username}) - - return None + cfg: dict = {"host": host} + if params.get("username"): + cfg["username"] = params["username"] + if params.get("password"): + cfg["password"] = params["password"] + if params.get("port"): + cfg["port"] = params["port"] + if params.get("key_path"): + cfg["client_keys"] = [params["key_path"]] + # Skip host key verification by default for ad-hoc connections + cfg.setdefault("known_hosts", "none") + return self._build_kwargs(cfg) def _build_kwargs(self, cfg: dict) -> dict: kwargs: dict = { @@ -146,27 +181,19 @@ password = cfg.get("password") if client_keys: - # Explicit key list — use only those keys kwargs["client_keys"] = [str(Path(k).expanduser()) for k in client_keys] + if password: + kwargs["password"] = password # fallback elif password: - # Password auth — disable key auth entirely so asyncssh doesn't - # try ~/.ssh/* keys and fail before attempting the password - kwargs["client_keys"] = [] + kwargs["client_keys"] = [] # disable key lookup, use password only kwargs["password"] = password - else: - # No credentials specified — try default ~/.ssh/* keys - pass + # else: no creds — asyncssh tries ~/.ssh/* by default - if password and client_keys: - # Both specified — pass password as fallback - kwargs["password"] = password - - known_hosts = cfg.get("known_hosts", None) + known_hosts = cfg.get("known_hosts") if known_hosts == "none": kwargs["known_hosts"] = None elif known_hosts is not None: kwargs["known_hosts"] = str(Path(known_hosts).expanduser()) - # else: omit → asyncssh uses system known_hosts return kwargs