From 8d9fce1c12d931c729fec92292eb83b50ac153b2 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 13 Mar 2026 22:24:36 +0800 Subject: [PATCH] feat(agent): enhance configuration safety and multiline command handling - Add explicit AAA/password configuration prohibition to safety reminders - Implement multiline command expansion for banner and similar commands - Add error handling for devices with missing device_type tags - Improve logging for configuration errors and multiline expansions --- .../lab_automation_assistant_prompt.py | 1 + .../tools_v2/config_tools_nornir.py | 81 +++++++++++++++++++ .../tools_v2/display_tools_nornir.py | 20 +++++ .../utils/get_gns3_device_port.py | 29 ++++--- 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py b/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py index 84f6ef945..93e606a6c 100644 --- a/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py +++ b/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py @@ -165,6 +165,7 @@ vlan 10 # SAFETY REMINDERS While you have configuration permissions, exercise caution: +- ⚠️ **FORBIDDEN**: AAA/password config (enable secret, username, aaa new-model, service password-encryption, line vty) - Provide guidance only - ⚠️ Avoid destructive commands (reload, erase, format) unless explicitly requested - ⚠️ Warn user before making major changes - ⚠️ Recommend backup for critical configurations diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index ed1ad2984..0e84a9165 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -196,6 +196,13 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) ) + # Expand multiline commands (e.g., banner commands with embedded newlines) + # This converts commands like "banner motd #\nline1\nline2\n#" into + # separate commands: ["banner motd #", "line1", "line2", "#"] + device_configs_list = self._expand_multiline_commands( + device_configs_list + ) + # Create a mapping of device names to their configuration commands device_configs_map = self._configs_map(device_configs_list) @@ -208,6 +215,26 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): logger.error("Failed to prepare device hosts data: %s", e) return [{"error": str(e)}] + # Check if any devices have errors (e.g., missing device_type tag) + error_devices = { + name: data + for name, data in hosts_data.items() + if "error" in data + } + if error_devices: + logger.error( + "Devices with configuration errors: %s", + list(error_devices.keys()) + ) + return [ + { + "device_name": name, + "status": "failed", + "error": data["error"] + } + for name, data in error_devices.items() + ] + # Initialize Nornir try: dynamic_nr = self._initialize_nornir(hosts_data) @@ -445,6 +472,60 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return filtered_list, blocked_commands_map + def _expand_multiline_commands( + self, device_configs_list: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Expand commands that contain embedded newline characters. + + Some commands (like banner) are passed as single strings with embedded + newlines. This method splits them into separate commands for proper + execution by netmiko_send_config. + + Note: netmiko_send_config automatically handles entering config mode, + so any config mode entry commands (configure terminal, system-view, etc.) + are left as-is and will be safely ignored by the device. + + Example: + Input: ["banner motd #\nline1\nline2\n#", "exit"] + Output: ["banner motd #", "line1", "line2", "#", "exit"] + + Args: + device_configs_list: List of device configs with config_commands. + + Returns: + List of device configs with multiline commands expanded. + """ + expanded_list = [] + + for device_config in device_configs_list: + device_name = device_config["device_name"] + config_commands = device_config["config_commands"] + + expanded_commands = [] + for cmd in config_commands: + # Check if command contains embedded newlines + if "\n" in cmd: + # Split by newline and expand + lines = cmd.split("\n") + # Filter out empty lines but keep all content lines + expanded_commands.extend([line for line in lines if line.strip()]) + logger.info( + "Device %s: Expanded multiline command (%d lines)", + device_name, + len(lines), + ) + else: + # Keep single-line commands as-is + expanded_commands.append(cmd) + + # Create updated device config + expanded_config = device_config.copy() + expanded_config["config_commands"] = expanded_commands + expanded_list.append(expanded_config) + + return expanded_list + def _configs_map( self, device_config_list: list[dict[str, Any]] ) -> dict[str, list[str]]: diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index d8f799bf5..673635d39 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -211,6 +211,26 @@ class ExecuteMultipleDeviceCommands(BaseTool): logger.error("Failed to prepare device hosts data: %s", e) return [{"error": str(e)}] + # Check if any devices have errors (e.g., missing device_type tag) + error_devices = { + name: data + for name, data in hosts_data.items() + if "error" in data + } + if error_devices: + logger.error( + "Devices with configuration errors: %s", + list(error_devices.keys()) + ) + return [ + { + "device_name": name, + "status": "failed", + "error": data["error"] + } + for name, data in error_devices.items() + ] + # Initialize Nornir try: dynamic_nr = self._initialize_nornir(hosts_data) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index f38ca70d3..821bddf62 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -104,20 +104,25 @@ def get_device_ports_from_topology( elif tag.startswith("platform:"): platform = tag.split(":", 1)[1].strip() - # Use defaults if not found in tags + # Return error if device_type not found in tags + # Using a default would cause command execution errors if device_type is None: - device_type = "cisco_ios_telnet" - logger.debug( - "Device '%s': device_type not found in tags, using default: " - "cisco_ios_telnet", - device_name, - ) - else: - logger.debug( - "Device '%s': extracted device_type=%s from tags", - device_name, - device_type, + error_msg = ( + f"Device '{device_name}': device_type tag not found. " + f"Please add 'device_type:' tag to this device in GNS3. " + f"Current tags: {tags}" ) + logger.error(error_msg) + hosts_data[device_name] = { + "error": error_msg + } + continue + + logger.debug( + "Device '%s': extracted device_type=%s from tags", + device_name, + device_type, + ) if platform is None: platform = "cisco_ios"