Compare commits

...

5 Commits

Author SHA1 Message Date
fluzzi32 127c1b9fdb update readme 2026-06-16 18:04:54 -03:00
fluzzi32 744e730672 feat(auth,cli): add SSO/OIDC authentication and provider management
- Introduce `conn sso` CLI suite for managing Identity Providers (IdP).
- Implement `login_sso` and `get_sso_providers` in gRPC AuthService.
- Add auto-provisioning for users logging in via SSO.
- Support JWT validation via shared secrets (HS256) or JWKS (RS256).
- Add domain restriction (`allowed_domains`) and env-var secret resolution.
- Increase JWT session expiration from 8 to 12 hours.
- Add shell autocompletion for SSO commands and configured providers.
- Bump version to 6.0.3.
2026-06-04 18:33:26 -03:00
fluzzi32 61a44d004f feat(core,grpc): add regex support for node expectations and secure thread context sharing
- Implement dynamic regex matching fallback (re.search) in `node.test` with safe handling of invalid patterns.
- Refactor terminal window resizing (setwinsize) to trigger only on non-router devices and handle SIGWINCH re-renders.
- Introduce `contextvars` context copying for background worker threads in gRPC execution and AI servicers.
- Add unit tests for regex validation, malformed expression fallbacks, and variable formatting in node testing.
- Optimize Playbook Builder AI guidelines for single-task test evaluations.
- Unify codebase comments to English.
2026-06-03 16:49:52 -03:00
fluzzi32 2b8e637298 added AI support for yaml/run 2026-06-01 17:49:19 -03:00
fluzzi32 721a3642f3 bug fixes and moving to production 2026-05-29 17:09:27 -03:00
45 changed files with 5334 additions and 1277 deletions
+1
View File
@@ -169,6 +169,7 @@ COPILOT_PLAN.md
ARCHITECTURAL_DEBT_REFACTOR.md
COPILOT_UI_FEATURES.md
MULTI_USER_IMPLEMENTATION_STEPS.md
readme_coverage_analysis.md
#themes
nord.yml
+224 -141
View File
@@ -3,192 +3,275 @@
</p>
# Connpy
# Connpy (v6.0.3)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
The v6 release introduces the **AI Copilot**, an interactive terminal assistant that understands your network context and helps you manage your infrastructure more intelligently.
The v6 release introduces a comprehensive **AI Copilot** and **AI Playbook Engine**, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.
## 🤖 AI Copilot (New in v6)
The AI Copilot is deeply integrated into your terminal workflow:
- **Terminal Context Awareness**: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
- **Dynamic Context Selection**: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
- **Flexible Auth & Keyless AI**: Support for advanced LiteLLM credentials (`--engineer-auth` / `--architect-auth`) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
- **Enhanced Session Management**: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
- **Semantic Prompt Integration**: Emit standard OSC prompt sequences (`\x1b]133;B`) for real-time remote/web front-end command tracking.
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
---
## 1. 🤖 AI System
### 1a. Terminal Copilot (Ctrl+Space)
Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
* **Context Modes**: Cycles through `LINES` (sends raw scroll buffer), `SINGLE` (captures exactly one command + output block), and `RANGE` (logical group of recent commands) using **`Ctrl+Up/Down`**.
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
* `/trust` / `/untrust`: Configures auto-run behavior for suggested non-destructive commands.
* `/os [system]`: Manually overrides target OS parsing rules (e.g. `/os cisco_ios`).
* `/prompt [regex]`: Overrides command prompt detection bounds.
* `/clear`: Clear context history.
### 1b. AI Chat (conn ai)
Start a standalone persistent session with the AI Copilot. Manage sessions using `--list`, `--resume`, `--session <id>` (to restore a specific history), `--delete <id>`, or send a quick single-shot question directly from the terminal prompt:
```bash
conn ai "how do i check bgp summary on cisco?"
```
### 1c. MCP Integration
Connect to external data sources and tools dynamically via the Model Context Protocol (MCP). Use the interactive wizard or command actions to configure MCP servers:
```bash
conn ai --mcp
```
## Core Features
- **Multi-Protocol**: Native support for SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
- **Context Management**: Set regex-based contexts to manage specific nodes across different environments (work, home, clients).
- **Advanced Inventory**:
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
- Bulk creation, copying, moving, and export/import of nodes.
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
- Fuzzy search integration with `fzf`.
- Advanced tab completion.
- Syntax highlighting and customizable themes.
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
- **Plugin System**: Build and execute custom Python scripts locally or on a remote gRPC server.
- **gRPC Architecture**: Fully decoupled Client/Server model for distributed management.
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
---
## 2. ⚙️ Automation & Playbooks
### 2a. Quick Run (conn run)
Run commands in parallel directly on target nodes or folder structures:
```bash
conn run router1 "show interface"
```
### 2b. YAML Playbook Engine
Execute complex structured automation playbooks defined in YAML configuration files. Supports multi-task execution, variables (using global, per-node, or regex matching definitions), timeouts, and variable parallel execution bounds.
```yaml
# example_playbook.yaml
- name: Verify Network Operations
hosts: "@office"
parallel: true
tasks:
- name: Get interface brief
run: "show ip interface brief"
- name: Check OSPF state
run: "show ip ospf neighbor"
test: "FULL"
```
Execute using the playbooks runner:
```bash
conn run example_playbook.yaml
```
### 2c. AI-Assisted Automation
Leverage AI to generate playbook templates (`--generate-ai`), simulate command changes before execution (`--preflight-ai`), or analyze consolidated execution logs post-run (`--analyze`). Use `--test "expected text1" "expected text2"` to specify assert-style output validations.
* *To generate an empty template:* `conn run --generate`
## Installation
---
## 3. 📂 Inventory Management
### 3a. Nodes
Manage connections using standard commands: add (`conn --add node1`), edit (`conn --mod node1`), delete (`conn --del node1`), show configuration (`conn --show node1`), or connect (`conn node1`).
### 3b. Profiles
Define credentials and templates globally and reference them inside node fields using the `@profile_name` placeholder. Manage profiles interactively or via commands:
```bash
conn profile -a profile_name
# Or equivalently:
conn -a profile profile_name
```
During the interactive `conn --add` prompt, you can input `@profile_name` in the **username** or **password** fields to reference it.
### 3c. Folders, Move, Copy, List
Organize nodes into logical folder hierarchies (`@office`, `@datacenter@office`). Move items (`conn move [src] [dst]`), copy (`conn copy [src] [dst]`), or list items with custom filters and formatting:
```bash
conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
```
### 3d. Bulk, Export, Import
Bulk import connections from formatted text files (`conn bulk -f nodes.txt`), or export/import connection folders using YAML configurations (`conn export @folder > backup.yaml` / `conn import backup.yaml`).
### 3e. Tags System
Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (`os`), prompt regex rules (`prompt`), and page length triggers (`screen_length_command`).
```yaml
# Custom tags dictionary (YANG / VSR context)
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
```
---
## 4. 🔌 Protocols & Connection Features
### 4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM
Connect to various architectures using native protocols:
* **SSH / Telnet**: Standard CLI protocols.
* **SFTP**: Transfer files securely (`conn --sftp node`).
* **Docker**: Connect directly to local container names (host set to container name/ID).
* **Kubernetes (kubectl)**: Connect to pods (namespace customizable via options).
* **AWS SSM**: Connect to EC2 instances using Instance IDs as hosts.
### 4b. Jumphosts
Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.
### 4c. Debug Mode, Keepalive, Logging
Track connection steps (`conn --debug node`), set idle keepalive intervals (`conn config --keepalive <seconds>`), or define dynamic output log files using variables like `${unique}`, `${host}`, `${port}`, `${user}`, `${protocol}`, or `${date 'format'}`.
---
## 5. 🖥️ Remote Capture (conn capture - Core Plugin)
Perform remote packet capture (`tcpdump`) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:
```bash
conn capture router1 eth0 -w -f "port 80"
```
* **Requirements**: Local installation of Wireshark or `tshark` is required for live piping (`-w`).
* **Advanced flags**: Specify network namespaces (`--ns <name>`), custom filters (`-f <filter>`), or configure the Wireshark local path (`--set-wireshark-path`).
---
## 6. 🛡️ Context Filtering
Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:
```bash
conn context production -a --regex ".*-prod"
conn context production --set
```
* **Manage Contexts**: List defined filters (`conn context --ls`), show context details (`conn context production -s`), or delete contexts (`conn context production -r`).
---
## 7. 🔌 Plugin System
Extend `connpy` features and hook into core execution events (pre/post hooks) by writing Python scripts. Add, update, delete, or list plugins locally, or execute them on remote instances:
```bash
conn plugin --add my_plugin script.py
conn plugin --update my_plugin script.py
conn plugin --remote --sync
```
---
## 8. ⚙️ gRPC Client-Server Architecture
### 8a. Server (start/stop/restart/debug)
Execute tasks on a centralized remote host. Start gRPC server (`conn api -s 50051`), stop (`conn api -x`), restart (`conn api -r`), or debug in the foreground (`conn api -d`).
### 8b. Client Config
Shift the local CLI to communicate with a remote server instance:
```bash
conn config --service-mode remote
conn config --remote localhost:50051
```
### 8c. User Management
Manage server-side user credentials for distributed setups:
```bash
conn user --add username
conn user --list
conn user --regen-password username
```
Use `--path` to specify custom configuration folders in server Mode B.
### 8d. SSO / OIDC
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
```bash
conn sso --add provider_name
```
### 8e. Login / Logout
Authenticate client sessions (`conn login [username]`), check connection status (`conn login --status`), or close sessions (`conn logout`).
---
## 9. ⚡ Installation & Configuration
### 9a. pip install
```bash
pip install connpy
```
### Run it in Windows/Linux using Docker
### 9b. Shell Completion + FZF
Install autocompletions and fuzzy-search wrappers into your shell profile:
```bash
git clone https://github.com/fluzzi/connpy
cd connpy
docker compose build
# Run it like a native app (completely silent)
docker compose run --rm --remove-orphans connpy-app [command]
# Pro Tip: Add this alias for a 100% native experience from any folder
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
---
## 🔒 Privacy & Integration
### Privacy Policy
Connpy is committed to protecting your privacy:
- **Local Storage**: All server addresses, usernames, and passwords are encrypted and stored **only** on your machine. No data is transmitted to our servers.
- **Data Access**: Data is used solely for managing and automating your connections.
### Google Integration
Used strictly for backup:
- **Backup**: Sync your encrypted configuration with your Google Drive account.
- **Scoped Access**: Connpy only accesses its own backup files.
---
## Usage
### 9c. conn config options
View configuration details (`conn config`) or customize variables like case sensitivity (`--allow-uppercase`), FZF list picker (`--fzf true`), configurations directory (`--configfolder`), or persistent AI API keys and models (`--engineer-model`).
### 9d. Theming
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
```bash
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
conn config --theme /path/to/theme.yaml
```
### Basic Examples:
```bash
# Add a folder and subfolder
conn --add @office
conn --add @datacenter@office
# Add a node with a profile
conn --add server1@datacenter@office --profile @myuser
# Connect to a node (fuzzy match)
conn server1
# Start the AI Copilot
conn ai
# Run a command on all nodes in a folder
conn run @office "uptime"
```
---
## 🔌 Plugin System
Connpy supports a robust plugin architecture where scripts can run transparently on a remote gRPC server.
## 10. 🔒 Privacy, Security & Synchronization (conn sync)
Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (`conn sync --once`, `--list`, `--restore`) or schedule auto-sync. Segregate restores (`--nodes` / `--config`) or sync remote nodes with `--sync-remote`.
### Structure
Plugins must be Python files containing:
- **Class `Parser`**: Defines `argparse` arguments.
- **Class `Entrypoint`**: Execution logic.
- **Class `Preload`**: (Optional) Hooks and modifications to the core app.
See the [Plugin Requirements section](#plugin-requirements-for-connpy) for full technical details.
---
## Plugin Requirements for Connpy
## 11. 🐍 Python API
Embed connection and automation routines programmatically in Python:
### Remote Plugin Execution
When Connpy operates in remote mode, plugins are executed **transparently on the server**:
- The client automatically downloads the plugin source code (`Parser` class context) to generate the local `argparse` structure and provide autocompletion.
- The execution phase (`Entrypoint` class) is redirected via gRPC streams to execute in the server's memory.
- You can manage remote plugins using the `--remote` flag.
### General Structure
- The plugin script must define specific classes:
1. **Class `Parser`**: Handles `argparse.ArgumentParser` initialization.
2. **Class `Entrypoint`**: Main execution logic (receives `args`, `parser`, and `connapp`).
3. **Class `Preload`**: (Optional) For modifying core app behavior or registering hooks.
### Preload Modifications and Hooks
You can customize the behavior of core classes using hooks:
- **`modify(method)`**: Alter class instances (e.g., `connapp.config`, `connapp.ai`).
- **`register_pre_hook(method)`**: Logic to run before a method execution.
- **`register_post_hook(method)`**: Logic to run after a method execution.
### Command Completion Support
Plugins can provide intelligent tab completion:
1. **Tree-based Completion (Recommended)**: Define `_connpy_tree(info)` returning a navigation dictionary.
2. **Legacy Completion**: Define `_connpy_completion(wordsnumber, words, info)`.
---
## ⚙️ gRPC Service Architecture
Connpy can operate in a decoupled mode:
1. **Start the API (Server)**: `conn api -s 50051`
2. **Configure the Client**:
```bash
conn config --service-mode remote
conn config --remote-host localhost:50051
```
All inventory management and execution will now happen on the server.
---
## 🐍 Automation Module (API)
You can use `connpy` as a Python library for your own scripts.
### Basic Execution
```python
import connpy
router = connpy.node("uniqueName", "1.1.1.1", user="admin")
# 1. Direct single node interaction
router = connpy.node("router1", "1.1.1.1", user="admin")
router.run(["show ip int brief"])
print(router.output)
```
### Parallel Tasks with Variables
```python
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes, config=config)
nodes_info = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes_info, config=config)
variables = {
"router1@office": {"id": "1"},
"__global__": {"mask": "255.255.255.0"}
}
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
```
### AI Programmatic Use
```python
import connpy
# 3. AI Copilot prompts
myai = connpy.ai(connpy.configfile())
response = myai.ask("What is the status of the BGP neighbors in the office?")
response = myai.ask("Show BGP status.")
print(response)
```
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
---
*For detailed developer notes and plugin hooks documentation, see the [Documentation](https://fluzzi.github.io/connpy/).*
## 12. 🐳 Docker Deployment
Run `connpy` containerized and silent:
```bash
docker compose run --rm connpy-app [command]
```
Add `alias conn='docker compose run --rm connpy-app'` to your shell for a transparent container experience.
---
## 13. 📜 License
[PolyForm Noncommercial 1.0.0](LICENSE)
+224 -128
View File
@@ -5,182 +5,278 @@
</p>
# Connpy
# Connpy (v6.0.3)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
The v6 release introduces the **AI Copilot**, an interactive terminal assistant that understands your network context and helps you manage your infrastructure more intelligently.
The v6 release introduces a comprehensive **AI Copilot** and **AI Playbook Engine**, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.
## 🤖 AI Copilot (New in v6)
The AI Copilot is deeply integrated into your terminal workflow:
- **Terminal Context Awareness**: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
- **Dynamic Context Selection**: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
- **Flexible Auth & Keyless AI**: Support for advanced LiteLLM credentials (`--engineer-auth` / `--architect-auth`) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
- **Enhanced Session Management**: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
- **Semantic Prompt Integration**: Emit standard OSC prompt sequences (`\x1b]133;B`) for real-time remote/web front-end command tracking.
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
---
## 1. 🤖 AI System
### 1a. Terminal Copilot (Ctrl+Space)
Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
* **Context Modes**: Cycles through `LINES` (sends raw scroll buffer), `SINGLE` (captures exactly one command + output block), and `RANGE` (logical group of recent commands) using **`Ctrl+Up/Down`**.
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
* `/trust` / `/untrust`: Configures auto-run behavior for suggested non-destructive commands.
* `/os [system]`: Manually overrides target OS parsing rules (e.g. `/os cisco_ios`).
* `/prompt [regex]`: Overrides command prompt detection bounds.
* `/clear`: Clear context history.
### 1b. AI Chat (conn ai)
Start a standalone persistent session with the AI Copilot. Manage sessions using `--list`, `--resume`, `--session <id>` (to restore a specific history), `--delete <id>`, or send a quick single-shot question directly from the terminal prompt:
```bash
conn ai "how do i check bgp summary on cisco?"
```
### 1c. MCP Integration
Connect to external data sources and tools dynamically via the Model Context Protocol (MCP). Use the interactive wizard or command actions to configure MCP servers:
```bash
conn ai --mcp
```
## Core Features
- **Multi-Protocol**: Native support for SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
- **Context Management**: Set regex-based contexts to manage specific nodes across different environments (work, home, clients).
- **Advanced Inventory**:
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
- Bulk creation, copying, moving, and export/import of nodes.
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
- Fuzzy search integration with `fzf`.
- Advanced tab completion.
- Syntax highlighting and customizable themes.
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
- **Plugin System**: Build and execute custom Python scripts locally or on a remote gRPC server.
- **gRPC Architecture**: Fully decoupled Client/Server model for distributed management.
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
---
## 2. ⚙️ Automation & Playbooks
### 2a. Quick Run (conn run)
Run commands in parallel directly on target nodes or folder structures:
```bash
conn run router1 "show interface"
```
### 2b. YAML Playbook Engine
Execute complex structured automation playbooks defined in YAML configuration files. Supports multi-task execution, variables (using global, per-node, or regex matching definitions), timeouts, and variable parallel execution bounds.
```yaml
# example_playbook.yaml
- name: Verify Network Operations
hosts: "@office"
parallel: true
tasks:
- name: Get interface brief
run: "show ip interface brief"
- name: Check OSPF state
run: "show ip ospf neighbor"
test: "FULL"
```
Execute using the playbooks runner:
```bash
conn run example_playbook.yaml
```
### 2c. AI-Assisted Automation
Leverage AI to generate playbook templates (`--generate-ai`), simulate command changes before execution (`--preflight-ai`), or analyze consolidated execution logs post-run (`--analyze`). Use `--test "expected text1" "expected text2"` to specify assert-style output validations.
* *To generate an empty template:* `conn run --generate`
## Installation
---
## 3. 📂 Inventory Management
### 3a. Nodes
Manage connections using standard commands: add (`conn --add node1`), edit (`conn --mod node1`), delete (`conn --del node1`), show configuration (`conn --show node1`), or connect (`conn node1`).
### 3b. Profiles
Define credentials and templates globally and reference them inside node fields using the `@profile_name` placeholder. Manage profiles interactively or via commands:
```bash
conn profile -a profile_name
# Or equivalently:
conn -a profile profile_name
```
During the interactive `conn --add` prompt, you can input `@profile_name` in the **username** or **password** fields to reference it.
### 3c. Folders, Move, Copy, List
Organize nodes into logical folder hierarchies (`@office`, `@datacenter@office`). Move items (`conn move [src] [dst]`), copy (`conn copy [src] [dst]`), or list items with custom filters and formatting:
```bash
conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
```
### 3d. Bulk, Export, Import
Bulk import connections from formatted text files (`conn bulk -f nodes.txt`), or export/import connection folders using YAML configurations (`conn export @folder > backup.yaml` / `conn import backup.yaml`).
### 3e. Tags System
Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (`os`), prompt regex rules (`prompt`), and page length triggers (`screen_length_command`).
```yaml
# Custom tags dictionary (YANG / VSR context)
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
```
---
## 4. 🔌 Protocols & Connection Features
### 4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM
Connect to various architectures using native protocols:
* **SSH / Telnet**: Standard CLI protocols.
* **SFTP**: Transfer files securely (`conn --sftp node`).
* **Docker**: Connect directly to local container names (host set to container name/ID).
* **Kubernetes (kubectl)**: Connect to pods (namespace customizable via options).
* **AWS SSM**: Connect to EC2 instances using Instance IDs as hosts.
### 4b. Jumphosts
Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.
### 4c. Debug Mode, Keepalive, Logging
Track connection steps (`conn --debug node`), set idle keepalive intervals (`conn config --keepalive <seconds>`), or define dynamic output log files using variables like `${unique}`, `${host}`, `${port}`, `${user}`, `${protocol}`, or `${date 'format'}`.
---
## 5. 🖥️ Remote Capture (conn capture - Core Plugin)
Perform remote packet capture (`tcpdump`) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:
```bash
conn capture router1 eth0 -w -f "port 80"
```
* **Requirements**: Local installation of Wireshark or `tshark` is required for live piping (`-w`).
* **Advanced flags**: Specify network namespaces (`--ns <name>`), custom filters (`-f <filter>`), or configure the Wireshark local path (`--set-wireshark-path`).
---
## 6. 🛡️ Context Filtering
Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:
```bash
conn context production -a --regex ".*-prod"
conn context production --set
```
* **Manage Contexts**: List defined filters (`conn context --ls`), show context details (`conn context production -s`), or delete contexts (`conn context production -r`).
---
## 7. 🔌 Plugin System
Extend `connpy` features and hook into core execution events (pre/post hooks) by writing Python scripts. Add, update, delete, or list plugins locally, or execute them on remote instances:
```bash
conn plugin --add my_plugin script.py
conn plugin --update my_plugin script.py
conn plugin --remote --sync
```
---
## 8. ⚙️ gRPC Client-Server Architecture
### 8a. Server (start/stop/restart/debug)
Execute tasks on a centralized remote host. Start gRPC server (`conn api -s 50051`), stop (`conn api -x`), restart (`conn api -r`), or debug in the foreground (`conn api -d`).
### 8b. Client Config
Shift the local CLI to communicate with a remote server instance:
```bash
conn config --service-mode remote
conn config --remote localhost:50051
```
### 8c. User Management
Manage server-side user credentials for distributed setups:
```bash
conn user --add username
conn user --list
conn user --regen-password username
```
Use `--path` to specify custom configuration folders in server Mode B.
### 8d. SSO / OIDC
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
```bash
conn sso --add provider_name
```
### 8e. Login / Logout
Authenticate client sessions (`conn login [username]`), check connection status (`conn login --status`), or close sessions (`conn logout`).
---
## 9. ⚡ Installation & Configuration
### 9a. pip install
```bash
pip install connpy
```
### Run it in Windows/Linux using Docker
### 9b. Shell Completion + FZF
Install autocompletions and fuzzy-search wrappers into your shell profile:
```bash
git clone https://github.com/fluzzi/connpy
cd connpy
docker compose build
# Run it like a native app (completely silent)
docker compose run --rm --remove-orphans connpy-app [command]
# Pro Tip: Add this alias for a 100% native experience from any folder
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
---
## 🔒 Privacy & Integration
### Privacy Policy
Connpy is committed to protecting your privacy:
- **Local Storage**: All server addresses, usernames, and passwords are encrypted and stored **only** on your machine. No data is transmitted to our servers.
- **Data Access**: Data is used solely for managing and automating your connections.
### Google Integration
Used strictly for backup:
- **Backup**: Sync your encrypted configuration with your Google Drive account.
- **Scoped Access**: Connpy only accesses its own backup files.
---
## Usage
### 9c. conn config options
View configuration details (`conn config`) or customize variables like case sensitivity (`--allow-uppercase`), FZF list picker (`--fzf true`), configurations directory (`--configfolder`), or persistent AI API keys and models (`--engineer-model`).
### 9d. Theming
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
```bash
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
conn config --theme /path/to/theme.yaml
```
### Basic Examples:
```bash
# Add a folder and subfolder
conn --add @office
conn --add @datacenter@office
# Add a node with a profile
conn --add server1@datacenter@office --profile @myuser
# Connect to a node (fuzzy match)
conn server1
# Start the AI Copilot
conn ai
# Run a command on all nodes in a folder
conn run @office "uptime"
```
---
## Plugin Requirements for Connpy
## 10. 🔒 Privacy, Security & Synchronization (conn sync)
Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (`conn sync --once`, `--list`, `--restore`) or schedule auto-sync. Segregate restores (`--nodes` / `--config`) or sync remote nodes with `--sync-remote`.
### Remote Plugin Execution
When Connpy operates in remote mode, plugins are executed **transparently on the server**:
- The client automatically downloads the plugin source code (`Parser` class context) to generate the local `argparse` structure and provide autocompletion.
- The execution phase (`Entrypoint` class) is redirected via gRPC streams to execute in the server's memory.
- You can manage remote plugins using the `--remote` flag.
### General Structure
- The plugin script must define specific classes:
1. **Class `Parser`**: Handles `argparse.ArgumentParser` initialization.
2. **Class `Entrypoint`**: Main execution logic (receives `args`, `parser`, and `connapp`).
3. **Class `Preload`**: (Optional) For modifying core app behavior or registering hooks.
### Preload Modifications and Hooks
You can customize the behavior of core classes using hooks:
- **`modify(method)`**: Alter class instances (e.g., `connapp.config`, `connapp.ai`).
- **`register_pre_hook(method)`**: Logic to run before a method execution.
- **`register_post_hook(method)`**: Logic to run after a method execution.
### Command Completion Support
Plugins can provide intelligent tab completion:
1. **Tree-based Completion (Recommended)**: Define `_connpy_tree(info)` returning a navigation dictionary.
2. **Legacy Completion**: Define `_connpy_completion(wordsnumber, words, info)`.
---
## ⚙️ gRPC Service Architecture
Connpy can operate in a decoupled mode:
1. **Start the API (Server)**: `conn api -s 50051`
2. **Configure the Client**:
```bash
conn config --service-mode remote
conn config --remote-host localhost:50051
```
All inventory management and execution will now happen on the server.
## 11. 🐍 Python API
Embed connection and automation routines programmatically in Python:
---
## 🐍 Automation Module (API)
You can use `connpy` as a Python library for your own scripts.
### Basic Execution
```python
import connpy
router = connpy.node("uniqueName", "1.1.1.1", user="admin")
# 1. Direct single node interaction
router = connpy.node("router1", "1.1.1.1", user="admin")
router.run(["show ip int brief"])
print(router.output)
```
### Parallel Tasks with Variables
```python
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes, config=config)
nodes_info = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes_info, config=config)
variables = {
"router1@office": {"id": "1"},
"__global__": {"mask": "255.255.255.0"}
}
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
```
### AI Programmatic Use
```python
import connpy
# 3. AI Copilot prompts
myai = connpy.ai(connpy.configfile())
response = myai.ask("What is the status of the BGP neighbors in the office?")
response = myai.ask("Show BGP status.")
print(response)
```
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
---
*For detailed developer notes and plugin hooks documentation, see the [Documentation](https://fluzzi.github.io/connpy/).*
## 12. 🐳 Docker Deployment
Run `connpy` containerized and silent:
```bash
docker compose run --rm connpy-app [command]
```
Add `alias conn='docker compose run --rm connpy-app'` for a transparent container experience.
---
## 13. 📜 License
[PolyForm Noncommercial 1.0.0](LICENSE)
'''
from .core import node,nodes
from .configfile import configfile
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b13"
__version__ = "6.0.3"
+362 -41
View File
@@ -17,7 +17,7 @@ def _init_litellm():
global _litellm_initialized
if not _litellm_initialized:
import litellm
# Silenciar feedback de litellm
# Silence litellm feedback
litellm.suppress_debug_info = True
litellm.set_verbose = False
_litellm_initialized = True
@@ -114,9 +114,10 @@ class ai:
self.confirm_handler = confirm_handler or self._local_confirm_handler
self.trusted_session = trust # Trust mode for the entire session
self.interrupted = False
self.one_shot = kwargs.get("one_shot", False)
# 1. Cargar configuración genérica con herencia/merge global
# 1. Load generic configuration with global inheritance/merge
if hasattr(self.config, "get_effective_setting"):
aiconfig = self.config.get_effective_setting("ai", {})
else:
@@ -159,7 +160,7 @@ class ai:
custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()]
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
# Límites
# Limits
self.max_history = 30
self.max_truncate = 50000
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
@@ -196,7 +197,7 @@ class ai:
self.session_id = getattr(self.config, "session_id", None)
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
# Prompts base agnósticos
# Agnostic base prompts
architect_instructions = ""
if self.has_architect:
architect_instructions = """
@@ -285,10 +286,13 @@ class ai:
@property
def architect_system_prompt(self):
"""Build architect system prompt with plugin extensions."""
prompt = self._architect_base_prompt
if getattr(self, "one_shot", False):
prompt += "\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer."
if self.architect_prompt_extensions:
extensions = "\n".join(self.architect_prompt_extensions)
return self._architect_base_prompt + f"\n\nPlugin Capabilities:\n{extensions}"
return self._architect_base_prompt
return prompt + f"\n\nPlugin Capabilities:\n{extensions}"
return prompt
def register_ai_tool(self, tool_definition, handler, target="engineer", engineer_prompt=None, architect_prompt=None, status_formatter=None):
"""Register an external tool for the AI system.
@@ -733,7 +737,7 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
"""Internal loop where the Engineer executes technical tasks for the Architect."""
# Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
# Cache optimization for the Engineer (Only for direct Anthropic, Vertex has different rules)
if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else:
@@ -765,13 +769,11 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f"[warning]⚠ Engineer has performed {iteration} steps. This is taking longer than expected.[/warning]")
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary.[/warning]")
soft_limit_warned = True
if status and not chat_history: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
if status and not chat_history:
status_text = f"[ai_status]Engineer: Analyzing mission... (step {iteration})"
if iteration >= self.soft_limit_iterations:
status_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
status.update(status_text)
try:
safe_messages = self._sanitize_messages(messages)
@@ -794,19 +796,25 @@ class ai:
for tc in resp_msg.tool_calls:
fn, args = tc.function.name, json.loads(tc.function.arguments)
# Notificación en tiempo real de la tarea técnica (Only if not in Architect loop)
# Real-time notification of the technical task (Only if not in Architect loop)
if status and not chat_history:
if fn == "list_nodes": status.update(f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}")
s_text = ""
if fn == "list_nodes": s_text = f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}"
elif fn == "run_commands":
cmds = args.get('commands', [])
cmd_str = cmds[0] if cmds else ""
status.update(f"[ai_status]Engineer: [CMD] {cmd_str}")
elif fn == "get_node_info": status.update(f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}")
s_text = f"[ai_status]Engineer: [CMD] {cmd_str}"
elif fn == "get_node_info": s_text = f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}"
elif fn.startswith("mcp_"):
server = fn.split("__")[0].replace("mcp_", "")
tool = fn.split("__")[1] if "__" in fn else fn
status.update(f"[ai_status]Engineer: [MCP:{server}] {tool}")
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
s_text = f"[ai_status]Engineer: [MCP:{server}] {tool}"
elif fn in self.tool_status_formatters: s_text = self.tool_status_formatters[fn](args)
if s_text:
if iteration >= self.soft_limit_iterations:
s_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
status.update(s_text)
if debug:
self._print_debug_observation(f"Decision: {fn}", args, status=status)
@@ -876,6 +884,8 @@ class ai:
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
]
if getattr(self, "one_shot", False):
base_tools = [t for t in base_tools if t["function"]["name"] not in ("delegate_to_engineer", "return_to_engineer")]
all_tools = base_tools + self.external_architect_tools
seen_names = set()
@@ -1011,11 +1021,18 @@ class ai:
@MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
def update_status(text):
if not status:
return
if iteration >= self.soft_limit_iterations:
warning_suffix = " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
if warning_suffix not in text:
text += warning_suffix
status.update(text)
if chat_history is None: chat_history = []
@@ -1034,7 +1051,7 @@ class ai:
usage = {"input": 0, "output": 0, "total": 0}
# 1. Selector de Rol inicial (Sticky Brain)
# 1. Initial Role Selector (Sticky Brain)
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
@@ -1043,7 +1060,7 @@ class ai:
elif explicit_engineer:
current_brain = "engineer"
else:
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
# Sticky Brain: Detect if the Architect was in control in recent history
is_architect_active = False
for msg in reversed(chat_history[-5:]):
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
@@ -1057,7 +1074,7 @@ class ai:
if is_architect_active: break
current_brain = "architect" if is_architect_active else "engineer"
# 2. Preparación de mensajes y limpieza
# 2. Message preparation and cleaning
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
@@ -1066,13 +1083,13 @@ class ai:
key = self.architect_key if current_brain == "architect" else self.engineer_key
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
if "claude" in model.lower() and "vertex" not in model.lower():
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else:
messages = [{"role": "system", "content": system_prompt}]
# Interleaving de historial
# History interleaving
last_role = "system"
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -1092,7 +1109,7 @@ class ai:
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
else: messages.append({"role": "user", "content": clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -1106,18 +1123,14 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f"[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]")
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]")
soft_limit_warned = True
# Soft limit warning - handled inline within update_status
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
if status:
# Notify responder identity for web/remote clients
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
status.update(f"__RESPONDER__:{current_brain}")
status.update(f"{label} is thinking... (step {iteration})")
update_status(f"{label} is thinking... (step {iteration})")
streamed_response = False
try:
@@ -1132,7 +1145,7 @@ class ai:
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
except Exception as e:
if current_brain == "architect":
if status: status.update("[unavailable]Architect unavailable! Falling back to Engineer...")
if status: update_status("[unavailable]Architect unavailable! Falling back to Engineer...")
# Preserve context when falling back - use clean_input directly
current_brain = "engineer"
model = self.engineer_model
@@ -1189,8 +1202,8 @@ class ai:
continue
if status:
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
if fn == "delegate_to_engineer": update_status(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
elif fn == "manage_memory_tool": update_status(f"[architect]Architect: [UPDATING MEMORY]")
if debug:
self._print_debug_observation(f"Decision: {fn}", args, status=status)
@@ -1199,7 +1212,7 @@ class ai:
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
elif fn == "consult_architect":
if status: status.update("[architect]Engineer consulting Architect...")
if status: update_status("[architect]Engineer consulting Architect...")
try:
# Consultation only - Engineer stays in control
claude_resp = completion(
@@ -1221,11 +1234,11 @@ class ai:
try: status.start()
except: pass
except Exception as e:
if status: status.update("[unavailable]Architect unavailable! Engineer continuing alone...")
if status: update_status("[unavailable]Architect unavailable! Engineer continuing alone...")
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
elif fn == "escalate_to_architect":
if status: status.update("[architect]Transferring control to Architect...")
if status: update_status("[architect]Transferring control to Architect...")
# Full escalation - Architect takes over
current_brain = "architect"
model = self.architect_model
@@ -1247,7 +1260,7 @@ class ai:
except: pass
elif fn == "return_to_engineer":
if status: status.update("[engineer]Transferring control back to Engineer...")
if status: update_status("[engineer]Transferring control back to Engineer...")
# Architect returns control to Engineer
current_brain = "engineer"
model = self.engineer_model
@@ -1300,7 +1313,7 @@ class ai:
messages.append(resp_msg.model_dump(exclude_none=True))
except Exception as e:
if status:
status.update(f"[error]Error fetching summary: {e}[/error]")
update_status(f"[error]Error fetching summary: {e}[/error]")
printer.warning(f"Failed to fetch final summary from LLM: {e}")
except KeyboardInterrupt:
if status: status.update("[error]Interrupted! Closing pending tasks...")
@@ -1617,3 +1630,311 @@ Node: {node_name}"""
@MethodHook
def confirm(self, user_input): return True
PLAYBOOK_BUILDER_SYSTEM_PROMPT = """
You are a Connpy Playbook Builder Agent, a specialist in creating structured Connpy automation playbooks in YAML format.
Your primary mission is to help the user build, refine, and validate playbooks.
You MUST follow the Connpy canonical playbook format strictly:
The playbook MUST always use the `tasks[]` array structure as the root key, where each task is sequential and independent.
Connpy YAML Playbook Canonical Schema:
---
tasks:
- name: "Task Description"
action: 'run' # Can be 'run' or 'test'. Mandatory.
nodes: # List of nodes filter or regular expressions to work on. Mandatory. Can be a string or array of strings. Supports regex (e.g. 'router.*@office' to match all routers in the 'office' folder).
- 'router1@office'
- 'router.*@office' # Regex filters are fully supported to match multiple nodes dynamically.
- '@aws'
commands: # List of CLI commands to execute. Mandatory.
- 'show version'
variables: # Key-value pairs for variables replacement in commands and expected. Optional.
__global__: # Global variables fallback. Optional.
key: value
node_name@folder: # Node-specific variables. Optional.
key: value
output: stdout # Mandatory. Output configuration. Choices: 'stdout', 'null', or a folder path like '/path/to/folder'.
options: # Execution options. Optional.
prompt: 'regex_prompt' # Optional prompt to expect.
parallel: 10 # Optional number of parallel threads. Default 10.
timeout: 20 # Optional execution timeout in seconds. Default 20.
- name: "Verification Task"
action: 'test'
nodes:
- 'router1@office'
commands:
- 'ping 10.100.100.1'
expected: '!' # Expected text pattern to search in output. Mandatory ONLY for 'test' action.
Connpy Variable Templating & Usage:
- Variables defined under the `variables` key (either globally under `__global__` or for specific nodes) are used in commands or expected output by surrounding the variable name with single curly braces: `{variable_name}`.
- Example: If you define a variable `ip` with a value of `10.100.100.1`, you use it in commands as `'ping {ip}'`.
- Recommendation (Important): Variables are not limited to simple words or values. You can define entire CLI commands as variables to abstract vendor-specific syntax! This is highly recommended when executing the same logical operation across different operating systems (OS) or vendors.
- Example: You can define `show_interface_cmd` under a specific node's variables to be `'show ip interface brief'` for Cisco, and `'show interfaces terse'` for Juniper, and then write a single generic command under `commands`:
`- '{show_interface_cmd}'`
Guidelines:
1. When the user requests a playbook, you should guide them and output the YAML.
2. IMPORTANT: You have access to the `list_nodes` tool. Proactively use it to inspect the user's real inventory. This allows you to discover correct node names, folders, or device tags, and construct precise regex filters for the `nodes` field based on real assets.
3. IMPORTANT: Before presenting the playbook, you MUST call the `validate_playbook` tool with the YAML to let the backend check for syntax and schema correctness.
4. If `validate_playbook` returns errors, fix them in your YAML and validate again before responding to the user.
5. When the playbook is complete, validated, and the user approves it, you MUST call the `return_playbook` tool to return the final YAML.
6. All text responses must be in the same language the user uses in their prompt.
7. EFFICIENT TESTING: When the user asks to verify or check a condition (e.g. verify OS version, check port status), a single task with `action: 'test'` is completely self-sufficient. DO NOT generate an `action: 'run'` task followed by an `action: 'test'` task to perform the same check. The `test` action executes the commands, verifies the expectation, and displays the output if `output: stdout` is configured.
"""
PLAYBOOK_BUILDER_TOOLS = [
{
"type": "function",
"function": {
"name": "list_nodes",
"description": "[Universal Platform] Lists available nodes in the inventory. Use this to discover device names, folders, or operating systems to build proper regex filters.",
"parameters": {
"type": "OBJECT",
"properties": {
"filter_pattern": {
"type": "STRING",
"description": "Regex or pattern to filter nodes (e.g. '.*', 'border.*', '@office')."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "validate_playbook",
"description": "Validates the Connpy YAML playbook structure, syntax, and schema correctness with the backend.",
"parameters": {
"type": "OBJECT",
"properties": {
"playbook_yaml": {
"type": "STRING",
"description": "The YAML content of the playbook to validate."
}
},
"required": ["playbook_yaml"]
}
}
},
{
"type": "function",
"function": {
"name": "return_playbook",
"description": "Returns the final validated YAML playbook to the calling application when the user is satisfied.",
"parameters": {
"type": "OBJECT",
"properties": {
"playbook_yaml": {
"type": "STRING",
"description": "The final YAML content of the playbook."
}
},
"required": ["playbook_yaml"]
}
}
}
]
class PlaybookBuilderAgent:
"""Specialized AI agent for building, validating, and generating Connpy YAML playbooks."""
def __init__(self, config, console=None, confirm_handler=None, trust=False, **kwargs):
self.config = config
self.console = console or printer.console
self.interrupted = False
# Load AI configuration
if hasattr(self.config, "get_effective_setting"):
aiconfig = self.config.get_effective_setting("ai", {})
else:
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
# Default model for technical tasks
self.model = kwargs.get("engineer_model") or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
self.key = kwargs.get("engineer_api_key") or aiconfig.get("engineer_api_key")
self.auth = kwargs.get("engineer_auth") or aiconfig.get("engineer_auth") or {}
if self.key and "api_key" not in self.auth:
self.auth = self.auth.copy()
self.auth["api_key"] = self.key
def validate_playbook(self, playbook_yaml: str) -> dict:
"""Sintactical and schema validation of Connpy Playbook YAML."""
import yaml
try:
# 1. Parse YAML
data = yaml.load(playbook_yaml, Loader=yaml.FullLoader)
except Exception as e:
return {"valid": False, "error": f"YAML Syntax Error: {e}"}
# 2. Check structure
if not isinstance(data, dict):
return {"valid": False, "error": "Playbook must be a YAML dictionary."}
if "tasks" not in data:
return {"valid": False, "error": "Playbook missing mandatory root 'tasks' key."}
tasks = data["tasks"]
if not isinstance(tasks, list):
return {"valid": False, "error": "'tasks' must be a list of tasks."}
# 3. Check individual tasks
for idx, task in enumerate(tasks):
if not isinstance(task, dict):
return {"valid": False, "error": f"Task index {idx} must be a dictionary."}
name = task.get("name", f"Task {idx}")
# Mandatory fields
mandatory = ["name", "action", "nodes", "commands", "output"]
missing = [field for field in mandatory if field not in task]
if missing:
return {"valid": False, "error": f"Task '{name}' (index {idx}) is missing mandatory fields: {missing}"}
# Validate nodes field type (supports string regexes or array of string regexes)
nodes = task["nodes"]
if not isinstance(nodes, (str, list)):
return {"valid": False, "error": f"Task '{name}' (index {idx}) 'nodes' must be a string (regex) or a list of strings (regexes)."}
if isinstance(nodes, list):
for n_idx, node_item in enumerate(nodes):
if not isinstance(node_item, str):
return {"valid": False, "error": f"Task '{name}' (index {idx}) 'nodes' list contains a non-string value at index {n_idx}: {node_item}"}
action = task["action"]
if action not in ["run", "test"]:
return {"valid": False, "error": f"Task '{name}' (index {idx}) has invalid action '{action}'. Choices are: 'run', 'test'."}
if action == "test" and "expected" not in task:
return {"valid": False, "error": f"Task '{name}' (index {idx}) has action 'test' but is missing the mandatory 'expected' key."}
output = task["output"]
if output not in [None, "stdout"] and not output.startswith("/"):
return {"valid": False, "error": f"Task '{name}' (index {idx}) output '{output}' is invalid. Must be 'stdout', 'null' or an absolute path."}
return {"valid": True, "message": "Playbook schema and syntax is valid."}
def ask(self, user_input, chat_history=None, status=None, debug=False, chunk_callback=None):
"""Standard conversation step with tool loop for PlaybookBuilderAgent."""
if chat_history is None:
chat_history = []
# System prompt and tool definition
system_prompt = PLAYBOOK_BUILDER_SYSTEM_PROMPT
tools = PLAYBOOK_BUILDER_TOOLS
messages = [{"role": "system", "content": system_prompt}]
for msg in chat_history:
m = msg if isinstance(msg, dict) else msg.copy()
if m.get('role') == 'assistant' and m.get('tool_calls') and m.get('content') == "":
m['content'] = None
messages.append(m)
messages.append({"role": "user", "content": user_input})
final_playbook_yaml = None
iteration = 0
max_iterations = 10
while iteration < max_iterations:
iteration += 1
if status:
status.update(f"Playbook Agent is thinking... (step {iteration})")
# Call LiteLLM completion
from connpy.ai import completion
try:
response = completion(
model=self.model,
messages=messages,
tools=tools,
num_retries=3,
**self.auth
)
except Exception as e:
return {"response": f"Playbook Agent failed: {str(e)}", "chat_history": messages[1:]}
resp_msg = response.choices[0].message
msg_dict = resp_msg.model_dump(exclude_none=True)
if msg_dict.get("tool_calls") and msg_dict.get("content") == "":
msg_dict["content"] = None
messages.append(msg_dict)
# If the model sends content, stream or yield it
if resp_msg.content:
if chunk_callback:
chunk_callback(resp_msg.content)
elif not resp_msg.tool_calls:
# In direct non-streaming output, print markdown
self.console.print(Markdown(resp_msg.content))
if not resp_msg.tool_calls:
break
for tc in resp_msg.tool_calls:
fn = tc.function.name
args = json.loads(tc.function.arguments)
if fn == "list_nodes":
filter_pattern = args.get("filter_pattern", ".*")
try:
matched_names = self.config._getallnodes(filter_pattern)
if not matched_names:
obs = "No nodes found matching the filter."
else:
if len(matched_names) <= 5:
matched_data = self.config.getitems(matched_names, extract=True)
res = {}
for name, data in matched_data.items():
os_tag = "unknown"
if isinstance(data, dict):
ts = data.get("tags")
if isinstance(ts, dict): os_tag = ts.get("os", "unknown")
res[name] = {"os": os_tag}
obs = json.dumps(res)
else:
obs = json.dumps({
"matched_count": len(matched_names),
"message": "Too many nodes matched. Showing names only.",
"node_names": matched_names
})
except Exception as e:
obs = f"Error listing nodes: {e}"
messages.append({
"tool_call_id": tc.id,
"role": "tool",
"name": fn,
"content": obs
})
elif fn == "validate_playbook":
playbook_yaml = args.get("playbook_yaml", "")
validation_res = self.validate_playbook(playbook_yaml)
messages.append({
"tool_call_id": tc.id,
"role": "tool",
"name": fn,
"content": json.dumps(validation_res)
})
elif fn == "return_playbook":
final_playbook_yaml = args.get("playbook_yaml", "")
messages.append({
"tool_call_id": tc.id,
"role": "tool",
"name": fn,
"content": json.dumps({"success": True, "message": "Playbook returned successfully."})
})
# If return_playbook was called, we can terminate early
if final_playbook_yaml is not None:
break
return {
"response": resp_msg.content or "",
"chat_history": messages[1:],
"playbook_yaml": final_playbook_yaml
}
+1
View File
@@ -7,4 +7,5 @@ from .api_handler import APIHandler
from .plugin_handler import PluginHandler
from .import_export_handler import ImportExportHandler
from .context_handler import ContextHandler
from .sso_handler import SSOHandler
+6 -6
View File
@@ -44,7 +44,7 @@ class AIHandler:
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
if args.resume:
sessions, _ = self.app.services.ai.list_sessions()
@@ -54,8 +54,8 @@ class AIHandler:
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args > Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args > Local Config
settings = self.app.services.config_svc.get_settings().get("ai", {})
arguments = {}
@@ -83,7 +83,7 @@ class AIHandler:
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
# El resto de la interacción el CLI la maneja con el agente subyacente
# The rest of the interaction is handled by the CLI with the underlying agent
self.app.myai = self.app.services.ai
self.ai_overrides = arguments
@@ -94,7 +94,7 @@ class AIHandler:
def single_question(self, args, session_id):
query = " ".join(args.ask)
with console.status("[ai_status]Agent is thinking and analyzing...") as status:
with console.status("[ai_status]Agent is thinking and analyzing...[/ai_status]") as status:
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
responder = result.get("responder", "engineer")
@@ -131,7 +131,7 @@ class AIHandler:
if not user_query.strip(): continue
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
with console.status("[ai_status]Agent is thinking...") as status:
with console.status("[ai_status]Agent is thinking...[/ai_status]") as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get("chat_history")
+286 -2
View File
@@ -15,7 +15,12 @@ class RunHandler:
def dispatch(self, args):
if len(args.data) > 1:
args.action = "noderun"
actions = {"noderun": self.node_run, "generate": self.yaml_generate, "run": self.yaml_run}
actions = {
"noderun": self.node_run,
"generate": self.yaml_generate,
"generate_ai": self.ai_generate,
"run": self.yaml_run
}
return actions.get(args.action)(args)
def node_run(self, args):
@@ -33,6 +38,41 @@ class RunHandler:
commands = [" ".join(args.data[1:])]
# Check for Preflight AI simulation
if getattr(args, "preflight_ai", False):
matched_node_names = [n.get("name") if isinstance(n, dict) else n for n in matched_nodes]
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
matched_node_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
renderer.flush()
printer.console.print(Rule(style="engineer"))
except Exception as e:
printer.error(f"Preflight AI simulation failed: {e}")
sys.exit(1)
sys.exit(0)
try:
header_printed = False
@@ -70,6 +110,40 @@ class RunHandler:
)
printer.run_summary(results)
# Analyze execution results if requested
if getattr(args, "analyze", None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Analyzing execution results...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else " ".join(args.data[1:])
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
renderer.flush()
printer.console.print(Rule(style="architect"))
except Exception as e:
printer.error(f"AI Analysis failed: {e}")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
@@ -90,8 +164,105 @@ class RunHandler:
with open(path, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
# Check preflight first before any task runs
if getattr(args, "preflight_ai", False):
preflight_failed = False
for task in playbook.get("tasks", []):
name = task.get("name", "Task")
nodelist = task.get("nodes", [])
commands = task.get("commands", [])
# Resolve nodes to names
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
resolved_names = [n.get("name") if isinstance(n, dict) else n for n in resolved_nodes]
printer.console.print(f"\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)")
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
resolved_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
renderer.flush()
printer.console.print(Rule(style="engineer"))
except Exception as e:
printer.error(f"Preflight AI simulation failed for task {name}: {e}")
preflight_failed = True
if preflight_failed:
sys.exit(1)
sys.exit(0)
# Standard run
results_all = {}
for task in playbook.get("tasks", []):
self.cli_run(task)
task_res = self.cli_run(task)
if task_res:
results_all.update(task_res)
# If analyze is enabled, run analysis on accumulated results
if getattr(args, "analyze", None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Analyzing playbook execution results...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f"Playbook: {path}"
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results_all,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
renderer.flush()
printer.console.print(Rule(style="architect"))
except Exception as e:
printer.error(f"AI Analysis failed: {e}")
except Exception as e:
printer.error(f"Failed to run playbook {path}: {e}")
@@ -136,6 +307,7 @@ class RunHandler:
nodelist = resolved_nodes
results = {}
try:
header_printed = False
if action == "run":
@@ -195,6 +367,118 @@ class RunHandler:
)
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
except ConnpyError as e:
printer.error(str(e))
return {}
def ai_generate(self, args):
from rich.prompt import Prompt
from rich.rule import Rule
from rich.panel import Panel
from rich.syntax import Syntax
dest_file = args.data[0]
if os.path.exists(dest_file):
printer.error(f"File '{dest_file}' already exists.")
sys.exit(14)
chat_history = []
# Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown
printer.console.print(Rule(style="engineer"))
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
printer.console.print(Rule(style="engineer"))
while True:
try:
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning("Operation cancelled by user.")
break
if user_prompt.strip().lower() in ["exit", "quit"]:
printer.info("Exiting AI Assistant.")
break
if not user_prompt.strip():
continue
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try:
status_context.stop()
except:
pass
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
res = self.app.services.ai.build_playbook_chat(
user_prompt,
chat_history=chat_history,
chunk_callback=callback
)
if first_chunk:
try:
status_context.stop()
except:
pass
renderer.flush()
if not first_chunk:
printer.console.print(Rule(style="engineer"))
# Update history
if res and "chat_history" in res:
chat_history = res["chat_history"]
# Check if the agent returned a validated playbook YAML
if res and "playbook_yaml" in res and res["playbook_yaml"]:
yaml_content = res["playbook_yaml"]
printer.console.print()
printer.success("Playbook YAML successfully generated and validated.")
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
printer.console.print(panel)
# Ask if the user wants to save it
try:
save_confirm = Prompt.ask(
f"\nDo you want to save this playbook to '{dest_file}'?",
choices=["y", "n", "run"],
default="y"
)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning("Saving skipped.")
break
choice = save_confirm.strip().lower()
if choice in ["y", "yes", "run"]:
with open(dest_file, "w") as f:
f.write(yaml_content)
printer.success(f"Playbook saved successfully to '{dest_file}'")
if choice == "run":
printer.console.print()
printer.info("Executing the saved playbook...")
self.yaml_run(args)
break
else:
printer.warning("Playbook not saved. You can continue describing changes or exit.")
except Exception as e:
printer.error(f"Error in AI chat: {e}")
+162
View File
@@ -0,0 +1,162 @@
import sys
import yaml
import inquirer
from .. import printer
class SSOHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == "remote":
printer.error("SSO management commands are only available in local/server-side mode.")
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, "add", None):
args.action = "add"
args.provider = args.add[0]
elif getattr(args, "delete", None):
args.action = "del"
args.provider = args.delete[0]
elif getattr(args, "list", False):
args.action = "list"
elif getattr(args, "show", None):
args.action = "show"
args.provider = args.show[0]
action = getattr(args, "action", None)
if action == "add":
return self.add_provider(args)
elif action == "del":
return self.delete_provider(args)
elif action == "list":
return self.list_providers(args)
elif action == "show":
return self.show_provider(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get("sso", {})
providers = sso.setdefault("providers", {})
existing = providers.get(provider, {})
if existing:
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
# Interactive questionnaire
questions = [
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning("Operation cancelled.")
sys.exit(130)
jwks_url = answers["jwks_url"].strip()
secret = answers["secret"].strip()
username_claim = answers["username_claim"].strip()
algorithms_str = answers["algorithms"].strip()
allowed_domains_str = answers.get("allowed_domains", "").strip()
if not jwks_url and not secret:
printer.error("You must configure either a JWKS URL or a Secret.")
sys.exit(1)
if not username_claim:
printer.error("Username claim cannot be empty.")
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
if not algorithms:
algorithms = ["RS256"]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
provider_data = {
"username_claim": username_claim,
"algorithms": algorithms
}
if jwks_url:
provider_data["jwks_url"] = jwks_url
if secret:
provider_data["secret"] = secret
if allowed_domains:
provider_data["allowed_domains"] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting("sso", sso)
printer.success(f"SSO Provider '{provider}' saved successfully.")
except Exception as e:
printer.error(f"Failed to save SSO configuration: {e}")
sys.exit(1)
def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get("sso", {})
providers = sso.get("providers", {})
if provider not in providers:
printer.error(f"SSO Provider '{provider}' not found.")
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers["confirm"]:
printer.info("Delete cancelled.")
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting("sso", sso)
printer.success(f"SSO Provider '{provider}' deleted successfully.")
except Exception as e:
printer.error(f"Failed to save SSO configuration: {e}")
sys.exit(1)
def list_providers(self, args):
sso = self.app.config.config.get("sso", {})
providers = sso.get("providers", {})
if not providers:
printer.warning("No SSO providers configured.")
return
# Print list in YAML format
providers_list = list(providers.keys())
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
printer.data("Configured SSO Providers", yaml_str)
def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get("sso", {})
providers = sso.get("providers", {})
if provider not in providers:
printer.error(f"SSO Provider '{provider}' not found.")
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it's sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get("secret")
if secret and not secret.startswith("$"):
display_data["secret"] = "********"
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f"SSO Provider: {provider}", yaml_str)
+11 -11
View File
@@ -87,14 +87,14 @@ class CopilotInterface:
}
# 1. Visual Separation
self.console.print("") # Salto de línea real
self.console.print("") # Real line break
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
self.console.print(Panel(
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
border_style="cyan"
))
self.console.print("\n") # Pequeño espacio antes del prompt del copilot
self.console.print("\n") # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add('c-up')
@@ -161,7 +161,7 @@ class CopilotInterface:
if app and app.current_buffer:
text = app.current_buffer.text
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
# Only show command help if typing the first command and there are no spaces
if text.startswith('/') and ' ' not in text:
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
matches = [c for c in commands if c.startswith(text.lower())]
@@ -176,19 +176,19 @@ class CopilotInterface:
idx = max(0, state['total_cmds'] - state['context_cmd'])
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
# Clean newlines and the initial prompt (all up to #, > or $) to leave only the command
original = text.strip().replace('\r', '').replace('\n', ' ')
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
# If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original
return cleaned if cleaned else original
if state['context_mode'] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
if len(range_blocks) > 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
# Clean and truncate very long commands so they don't break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
@@ -266,8 +266,8 @@ class CopilotInterface:
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don't leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -299,12 +299,12 @@ class CopilotInterface:
except: pass
asyncio.create_task(delayed_refresh())
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
# Move the cursor up and clean the line so the new prompt replaces the previous one
sys.stdout.write('\x1b[1A\x1b[2K')
sys.stdout.flush()
continue
else:
# Limpiar el mensaje de la barra cuando se hace una pregunta real
# Clean the toolbar message when a real question is asked
state['toolbar_msg'] = ''
clean_question = directive.get("clean_prompt", question)
+39
View File
@@ -120,6 +120,27 @@ def _get_users(configdir):
return []
def _get_sso_providers(configdir):
import yaml
config_file = os.path.join(configdir, "config.yaml")
if not os.path.exists(config_file):
return []
try:
with open(config_file, "r") as f:
data = yaml.safe_load(f) or {}
config_data = data.get("config", {})
if isinstance(config_data, dict):
sso = config_data.get("sso", {})
if isinstance(sso, dict):
providers = sso.get("providers", {})
if isinstance(providers, dict):
return list(providers.keys())
except Exception:
pass
return []
def _build_tree(nodes, folders, profiles, plugins, configdir):
"""Build the declarative CLI navigation tree.
@@ -169,12 +190,17 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
run_after_node.update({
"--test": {"*": run_after_node},
"-t": {"*": run_after_node},
"--analyze": {"*": run_after_node},
"--preflight-ai": run_after_node,
"*": run_after_node # Consume commands
})
run_dict = {
"--generate": {"__extra__": lambda w: get_cwd(w, "--generate")},
"-g": {"__extra__": lambda w: get_cwd(w, "-g")},
"--generate-ai": {"__extra__": lambda w: get_cwd(w, "--generate-ai")},
"--analyze": {"*": run_after_node},
"--preflight-ai": run_after_node,
"--test": {"*": None},
"-t": {"*": None},
"--help": None,
@@ -231,6 +257,18 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"--help": None, "-h": None
}
_sso_providers = lambda w=None: _get_sso_providers(configdir)
sso_dict = {
"--add": {"__extra__": _sso_providers, "*": None},
"--del": {"__extra__": _sso_providers},
"--rm": {"__extra__": _sso_providers},
"--show": {"__extra__": _sso_providers},
"--list": None,
"--ls": None,
"--help": None, "-h": None
}
mv_state = {"__extra__": _nodes, "--help": None, "-h": None}
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
ls_state = {
@@ -326,6 +364,7 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"-h": None,
},
"user": user_dict,
"sso": sso_dict,
"login": {"--help": None, "-h": None, "*": None},
"logout": {"--help": None, "-h": None},
"config": config_dict,
+16 -1
View File
@@ -37,7 +37,7 @@ RichHelpFormatter.group_name_formatter = str.upper
from .cli import (
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
ContextHandler
ContextHandler, SSOHandler
)
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
from .cli.help_text import get_help
@@ -141,6 +141,7 @@ class connapp:
from .cli.sync_handler import SyncHandler
from .cli.user_handler import UserHandler
from .cli.login_handler import LoginHandler
from .cli.sso_handler import SSOHandler
# Instantiate Handlers
self._node = NodeHandler(self)
@@ -155,6 +156,7 @@ class connapp:
self._sync = SyncHandler(self)
self._user = UserHandler(self)
self._login = LoginHandler(self)
self._sso = SSOHandler(self)
# Register auto-sync hook to trigger after config saves
from .configfile import configfile
@@ -303,6 +305,9 @@ class connapp:
runparser.add_argument("run", nargs='+', action=self._store_type, help=get_help("run"), default="run").completer = nodes_completer
runparser.add_argument("-t", "--test", dest="test_expected", nargs='+', help="Expected text(s) to validate in output. Converts the action from 'run' to 'test'")
runparser.add_argument("-g","--generate", dest="action", action="store_const", help="Generate yaml file template", const="generate", default="run")
runparser.add_argument("--generate-ai", dest="action", action="store_const", help="Generate a playbook interactively with AI assistance", const="generate_ai")
runparser.add_argument("--analyze", nargs='?', const="", help="Analyze actual command execution results using AI")
runparser.add_argument("--preflight-ai", action="store_true", help="Simulate and predict command execution on devices using AI preventively")
runparser.set_defaults(func=self._run.dispatch)
#APIPARSER
apiparser = subparsers.add_parser("api", help="Start and stop connpy API", description="Start and stop connpy API", formatter_class=RichHelpFormatter)
@@ -375,6 +380,16 @@ class connapp:
userparser.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)")
userparser.set_defaults(func=self._user.dispatch)
#SSOPARSER
ssoparser = subparsers.add_parser("sso", help="Manage SSO providers", description="Manage SSO providers", formatter_class=RichHelpFormatter)
ssoparser.error = self._custom_error
ssocrud = ssoparser.add_mutually_exclusive_group(required=True)
ssocrud.add_argument("--add", nargs=1, dest="add", help="Add or update SSO provider", metavar="PROVIDER_NAME")
ssocrud.add_argument("--del", "--rm", nargs=1, dest="delete", help="Delete SSO provider", metavar="PROVIDER_NAME")
ssocrud.add_argument("--list", "--ls", dest="list", action="store_true", help="List all configured SSO providers")
ssocrud.add_argument("--show", nargs=1, dest="show", help="Show SSO provider details", metavar="PROVIDER_NAME")
ssoparser.set_defaults(func=self._sso.dispatch)
#LOGINPARSER
loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter)
loginparser.error = self._custom_error
+47 -25
View File
@@ -27,10 +27,10 @@ def copilot_terminal_mode():
try:
old_settings = termios.tcgetattr(fd)
# Primero pasamos a raw mode absoluto para matar ISIG, ICANON, ECHO, etc.
# First we switch to absolute raw mode to disable ISIG, ICANON, ECHO, etc.
tty.setraw(fd)
# Luego rehabilitamos OPOST para que rich.Live se dibuje correctamente
# Then we re-enable OPOST so rich.Live renders correctly
new_settings = termios.tcgetattr(fd)
new_settings[1] = new_settings[1] | termios.OPOST
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
@@ -686,12 +686,12 @@ class node:
# Get raw bytes from BytesIO
raw_bytes = self.mylog.getvalue()
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
# tenga control exclusivo del stdin sin interferencias de LocalStream.
# Stop terminal reading so prompt_toolkit (in run_session)
# has exclusive control of stdin without LocalStream interference.
if hasattr(stream, 'stop_reading'):
stream.stop_reading()
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
# Fallback si no tiene el método (en LocalStream)
# Fallback if the method is missing (in LocalStream)
stream._loop.remove_reader(stream.stdin_fd)
try:
@@ -708,7 +708,7 @@ class node:
break
finally:
print("\033[2m Returning to session...\033[0m", flush=True)
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
# Restart terminal reading to return to interactive SSH/Telnet mode
if hasattr(stream, 'start_reading'):
stream.start_reading()
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
@@ -776,14 +776,6 @@ class node:
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if "prompt" in self.tags:
prompt = self.tags["prompt"]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -804,6 +796,20 @@ class node:
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
# Only set terminal size on devices without a
# screen_length_command (e.g. Linux/bash servers).
# Routers already disable pagination via that command.
# After setwinsize, consume any SIGWINCH re-render
# prompt (~40ms on bash) with a short timeout.
if c == commands[0] and "screen_length_command" not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -886,14 +892,6 @@ class node:
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if "prompt" in self.tags:
prompt = self.tags["prompt"]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -915,6 +913,15 @@ class node:
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and "screen_length_command" not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -940,13 +947,28 @@ class node:
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = output
cleaned_output = re.sub(newpattern, '', cleaned_output)
try:
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2:
File diff suppressed because one or more lines are too long
+215 -43
View File
@@ -1542,11 +1542,6 @@ class ExecutionServiceStub(object):
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.run_yaml_playbook = channel.unary_unary(
'/connpy.ExecutionService/run_yaml_playbook',
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
class ExecutionServiceServicer(object):
@@ -1570,12 +1565,6 @@ class ExecutionServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def run_yaml_playbook(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ExecutionServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -1594,11 +1583,6 @@ def add_ExecutionServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
'run_yaml_playbook': grpc.unary_unary_rpc_method_handler(
servicer.run_yaml_playbook,
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.ExecutionService', rpc_method_handlers)
@@ -1691,33 +1675,6 @@ class ExecutionService(object):
metadata,
_registered_method=True)
@staticmethod
def run_yaml_playbook(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.ExecutionService/run_yaml_playbook',
connpy__pb2.ScriptRequest.SerializeToString,
connpy__pb2.StructResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class ImportExportServiceStub(object):
"""Missing associated documentation comment in .proto file."""
@@ -1931,6 +1888,21 @@ class AIServiceStub(object):
request_serializer=connpy__pb2.StringRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.build_playbook_chat = channel.stream_stream(
'/connpy.AIService/build_playbook_chat',
request_serializer=connpy__pb2.AskRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.analyze_execution_results = channel.unary_stream(
'/connpy.AIService/analyze_execution_results',
request_serializer=connpy__pb2.AnalyzeRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.predict_execution_results = channel.unary_stream(
'/connpy.AIService/predict_execution_results',
request_serializer=connpy__pb2.PreflightRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
class AIServiceServicer(object):
@@ -1990,6 +1962,24 @@ class AIServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def build_playbook_chat(self, request_iterator, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def analyze_execution_results(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def predict_execution_results(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AIServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -2038,6 +2028,21 @@ def add_AIServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.StringRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
'build_playbook_chat': grpc.stream_stream_rpc_method_handler(
servicer.build_playbook_chat,
request_deserializer=connpy__pb2.AskRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
'analyze_execution_results': grpc.unary_stream_rpc_method_handler(
servicer.analyze_execution_results,
request_deserializer=connpy__pb2.AnalyzeRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
'predict_execution_results': grpc.unary_stream_rpc_method_handler(
servicer.predict_execution_results,
request_deserializer=connpy__pb2.PreflightRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.AIService', rpc_method_handlers)
@@ -2292,6 +2297,87 @@ class AIService(object):
metadata,
_registered_method=True)
@staticmethod
def build_playbook_chat(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/connpy.AIService/build_playbook_chat',
connpy__pb2.AskRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def analyze_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/connpy.AIService/analyze_execution_results',
connpy__pb2.AnalyzeRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def predict_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/connpy.AIService/predict_execution_results',
connpy__pb2.PreflightRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class SystemServiceStub(object):
"""Missing associated documentation comment in .proto file."""
@@ -2551,11 +2637,21 @@ class AuthServiceStub(object):
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.login_sso = channel.unary_unary(
'/connpy.AuthService/login_sso',
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
'/connpy.AuthService/change_password',
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
self.get_sso_providers = channel.unary_unary(
'/connpy.AuthService/get_sso_providers',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True)
class AuthServiceServicer(object):
@@ -2567,12 +2663,24 @@ class AuthServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def login_sso(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def change_password(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def get_sso_providers(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AuthServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -2581,11 +2689,21 @@ def add_AuthServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
'login_sso': grpc.unary_unary_rpc_method_handler(
servicer.login_sso,
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
'change_password': grpc.unary_unary_rpc_method_handler(
servicer.change_password,
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
'get_sso_providers': grpc.unary_unary_rpc_method_handler(
servicer.get_sso_providers,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.AuthService', rpc_method_handlers)
@@ -2624,6 +2742,33 @@ class AuthService(object):
metadata,
_registered_method=True)
@staticmethod
def login_sso(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/login_sso',
connpy__pb2.LoginSSORequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def change_password(request,
target,
@@ -2650,3 +2795,30 @@ class AuthService(object):
timeout,
metadata,
_registered_method=True)
@staticmethod
def get_sso_providers(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/get_sso_providers',
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.SSOProvidersResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+244 -30
View File
@@ -719,7 +719,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -768,7 +770,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -791,11 +795,6 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
return connpy_pb2.StructResponse(data=to_struct(res))
@handle_errors
def run_yaml_playbook(self, request, context):
res = self.service.run_yaml_playbook(request.param1, request.parallel)
return connpy_pb2.StructResponse(data=to_struct(res))
class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
def __init__(self, provider, registry=None):
if not hasattr(provider, "mode"):
@@ -955,12 +954,11 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
def service(self):
return self._get_provider().ai
@handle_errors
def ask(self, request_iterator, context):
def _handle_chat_stream(self, request_iterator, context, service_method):
import queue
import threading
import contextvars
ai_service = self.service
chunk_queue = queue.Queue()
request_queue = queue.Queue()
bridge = None
@@ -978,21 +976,29 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
nonlocal history, bridge, agent_instance
try:
# Run the AI interaction (this blocks this specific thread)
res = ai_service.ask(
input_text,
chat_history=history if history else None,
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
**overrides
)
if getattr(service_method, "__name__", None) == "build_playbook_chat":
res = service_method(
input_text,
chat_history=history if history else None,
status=bridge,
chunk_callback=callback
)
else:
res = service_method(
input_text,
chat_history=history if history else None,
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
**overrides
)
# Update history for next message
if "chat_history" in res:
if res and "chat_history" in res:
history = res["chat_history"]
# Send final chunk marker
@@ -1046,10 +1052,10 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
# Start AI in its own thread so we can keep listening for interrupts
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
ctx_ai = contextvars.copy_context()
ai_thread = threading.Thread(
target=run_ai_task,
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
daemon=True
)
ai_thread.start()
@@ -1061,8 +1067,9 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
# When client closes stream, send sentinel
chunk_queue.put((None, None))
# Start listening for client requests/signals
threading.Thread(target=request_listener, daemon=True).start()
# Start listening for client requests/signals with a copied context
ctx_listener = contextvars.copy_context()
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
# Main response loop (yields to gRPC)
while True:
@@ -1086,6 +1093,73 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
elif msg_type == "final_mark":
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
def _handle_unary_stream(self, service_method, *args, **kwargs):
import queue
import threading
chunk_queue = queue.Queue()
bridge = StatusBridge(chunk_queue, is_web=False)
def callback(chunk):
chunk_queue.put(("text", chunk))
def _worker():
try:
res = service_method(*args, chunk_callback=callback, status=bridge, **kwargs)
chunk_queue.put(("final_mark", res))
except Exception as e:
import traceback
print(f"gRPC Unary Stream error: {e}")
traceback.print_exc()
chunk_queue.put(("status", f"Error: {str(e)}"))
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "error": True}))
finally:
chunk_queue.put((None, None))
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = chunk_queue.get()
if item == (None, None):
break
msg_type, val = item
if msg_type == "text":
yield connpy_pb2.AIResponse(text_chunk=val, is_final=False)
elif msg_type == "status":
clean_val = val.replace("[ai_status]", "").replace("[/ai_status]", "")
yield connpy_pb2.AIResponse(status_update=clean_val, is_final=False)
elif msg_type == "debug":
yield connpy_pb2.AIResponse(debug_message=val, is_final=False)
elif msg_type == "important":
yield connpy_pb2.AIResponse(important_message=val, is_final=False)
elif msg_type == "confirm":
yield connpy_pb2.AIResponse(status_update=val, requires_confirmation=True, is_final=False)
elif msg_type == "final_mark":
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
@handle_errors
def ask(self, request_iterator, context):
yield from self._handle_chat_stream(request_iterator, context, self.service.ask)
@handle_errors
def build_playbook_chat(self, request_iterator, context):
yield from self._handle_chat_stream(request_iterator, context, self.service.build_playbook_chat)
@handle_errors
def analyze_execution_results(self, request, context):
results = from_struct(request.results)
query = request.query if request.query else None
yield from self._handle_unary_stream(self.service.analyze_execution_results, results, query=query)
@handle_errors
def predict_execution_results(self, request, context):
target_nodes = list(request.target_nodes)
commands = list(request.commands)
yield from self._handle_unary_stream(self.service.predict_execution_results, target_nodes, commands)
@handle_errors
def confirm(self, request, context):
res = self.service.confirm(request.value)
@@ -1199,7 +1273,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
token = self.registry.user_service.generate_jwt(username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
@@ -1207,6 +1281,137 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
expires_at=expires_at
)
@handle_errors
def login_sso(self, request, context):
username = request.username
id_token = request.id_token
provider = request.provider
if not id_token or not provider:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "id_token and provider are required")
# Load SSO configuration
sso_config = {}
if self.registry:
shared_config = self.registry.get_shared_config()
if shared_config:
sso_config = shared_config.config.get("sso", {})
providers = sso_config.get("providers", {})
if provider not in providers:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"SSO Provider '{provider}' not configured in config.yaml")
p_config = providers[provider]
jwks_url = p_config.get("jwks_url")
secret = p_config.get("secret")
if secret and secret.startswith("$"):
import os
secret = os.getenv(secret[1:])
if not jwks_url and not secret:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"Provider '{provider}' has no jwks_url or secret configured")
# Validate token
import jwt
try:
algorithms = p_config.get("algorithms", ["RS256"] if jwks_url else ["HS256"])
verify_aud = "audience" in p_config
audience = p_config.get("audience")
verify_iss = "issuer" in p_config
issuer = p_config.get("issuer")
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": verify_aud,
"verify_iss": verify_iss
}
decode_kwargs = {
"algorithms": algorithms,
"options": options
}
if verify_aud:
decode_kwargs["audience"] = audience
if verify_iss:
decode_kwargs["issuer"] = issuer
if jwks_url:
from jwt import PyJWKClient
jwks_client = PyJWKClient(jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
payload = jwt.decode(id_token, signing_key.key, **decode_kwargs)
else:
payload = jwt.decode(id_token, secret, **decode_kwargs)
except Exception as e:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO Token validation failed: {str(e)}")
# Extract username from claim
username_claim = p_config.get("username_claim", "sub")
claim_username = payload.get(username_claim)
if not claim_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Username claim '{username_claim}' not found in SSO Token")
# Check domain restrictions (allowed_domains)
allowed_domains = p_config.get("allowed_domains", [])
if allowed_domains:
email = payload.get("email")
if not email and claim_username and "@" in claim_username:
email = claim_username
if not email:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Domain restriction enabled but no email claim found in SSO Token")
try:
user_domain = email.split("@")[-1].strip().lower()
except Exception:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Invalid email format in SSO Token: '{email}'")
allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d]
if user_domain not in allowed_domains_lower:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO user domain '{user_domain}' not allowed")
# Normalize username to alphanumeric/dashes/underscores to match connpy's username regex
import re
normalized_username = re.sub(r'[^a-zA-Z0-9_-]', '_', claim_username.split('@')[0])
# If a requested username was sent, verify it matches
if username and username != normalized_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Mismatched username. Expected '{normalized_username}', got '{username}'")
# Check if user exists in connpy registry, otherwise auto-provision
try:
user_exists = any(u["username"] == normalized_username for u in self.registry.user_service.list_users())
if not user_exists:
import secrets
# Provision new user with random password (never used directly)
self.registry.user_service.create_user(normalized_username, secrets.token_hex(32))
except Exception as e:
context.abort(grpc.StatusCode.INTERNAL, f"Failed to auto-provision user: {str(e)}")
# Generate native connpy JWT token
token = self.registry.user_service.generate_jwt(normalized_username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
username=normalized_username,
expires_at=expires_at
)
@handle_errors
def get_sso_providers(self, request, context):
sso_config = {}
if self.registry:
shared_config = self.registry.get_shared_config()
if shared_config:
sso_config = shared_config.config.get("sso", {})
providers = list(sso_config.get("providers", {}).keys())
external_providers = [p for p in providers if p != "trusted_gateway"]
return connpy_pb2.SSOProvidersResponse(providers=external_providers)
@handle_errors
def change_password(self, request, context):
username = _current_user.get()
@@ -1222,7 +1427,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
return Empty()
class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = ["/connpy.AuthService/login"]
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
def __init__(self, registry):
self.registry = registry
@@ -1348,6 +1553,15 @@ def serve(config, port=8048, debug=False):
fallback_provider = ServiceProvider(config, mode="local")
registry = UserRegistry(config.defaultdir)
# Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env
import os
if os.getenv("CONN_SSO_GATEWAY_SECRET") and registry._shared_config:
sso_config = registry._shared_config.config.get("sso", {})
providers = sso_config.get("providers", {})
if "trusted_gateway" not in providers:
from connpy import printer
printer.warning("CONN_SSO_GATEWAY_SECRET is defined in environment, but 'trusted_gateway' is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.")
interceptors = []
if debug:
interceptors.append(LoggingInterceptor())
+122 -28
View File
@@ -692,11 +692,6 @@ class ExecutionStub:
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
return from_struct(self.stub.run_cli_script(req).data)
@handle_errors
def run_yaml_playbook(self, playbook_path, parallel=10):
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
return from_struct(self.stub.run_yaml_playbook(req).data)
class ImportExportStub:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.ImportExportServiceStub(channel)
@@ -724,8 +719,7 @@ class AIStub:
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
def _ai_chat_stream(self, stub_method, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, chunk_callback=None, **overrides):
import queue
from rich.prompt import Prompt
from rich.text import Text
@@ -760,7 +754,7 @@ class AIStub:
if req is None: break
yield req
responses = self.stub.ask(request_generator())
responses = stub_method(request_generator())
full_content = ""
header_printed = False
@@ -859,26 +853,32 @@ class AIStub:
try: status.stop()
except: pass
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk
alias = "architect" if current_responder == "architect" else "engineer"
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk
alias = "architect" if current_responder == "architect" else "engineer"
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
md_parser.feed(response.text_chunk)
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if header_printed:
if not chunk_callback and header_printed:
from rich.rule import Rule
md_parser.flush()
@@ -887,12 +887,8 @@ class AIStub:
except: pass
final_result = from_struct(response.full_result)
responder = final_result.get("responder", "engineer")
alias = "architect" if responder == "architect" else "engineer"
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
title = f"[bold {alias}]{role_label}[/bold {alias}]"
if header_printed:
if not chunk_callback and header_printed:
from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
@@ -911,6 +907,104 @@ class AIStub:
return final_result
@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)
@handle_errors
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
def _process_unary_stream(self, responses, status=None, chunk_callback=None):
full_content = ""
header_printed = False
final_result = {"response": "", "chat_history": []}
md_parser = None
try:
for response in responses:
if response.status_update:
if status:
status.update(response.status_update)
continue
if response.important_message:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if status:
try: status.start()
except: pass
continue
if not response.is_final:
if response.text_chunk:
if not header_printed:
if status:
try: status.stop()
except: pass
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print default header
stable_console.print(Rule("[bold engineer]AI Analysis[/bold engineer]", style="engineer"))
header_printed = True
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if md_parser:
md_parser.flush()
if status:
try: status.stop()
except: pass
final_result = from_struct(response.full_result)
if md_parser:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style="engineer"))
break
except Exception as e:
if isinstance(e, grpc.RpcError):
raise
printer.warning(f"Stream interrupted: {e}")
if full_content:
final_result["streamed"] = True
return final_result
@handle_errors
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
req = connpy_pb2.AnalyzeRequest(query=query or "")
req.results.CopyFrom(to_struct(results))
responses = self.stub.analyze_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)
@handle_errors
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
responses = self.stub.predict_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)
@handle_errors
def confirm(self, input_text, console=None):
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
+6
View File
@@ -92,6 +92,12 @@ class UserRegistry:
def has_users(self) -> bool:
"""Check if any users are registered (enables auth enforcement)."""
return bool(self.user_service.list_users())
def get_shared_config(self):
"""Thread-safe access to the hot-reloaded shared configuration."""
with self._lock:
self._refresh_shared()
return self._shared_config
def evict(self, username):
"""Remove and cleanly shut down cached provider (after delete or password change)."""
+1 -1
View File
@@ -573,7 +573,7 @@ class BlockMarkdownRenderer:
if not block_text:
return
from rich.markdown import Markdown
self._console.print(Markdown(block_text))
self._console.print(Markdown(block_text, code_theme="ansi_dark"))
# Alias for backward compatibility
IncrementalMarkdownParser = BlockMarkdownRenderer
+25 -1
View File
@@ -53,7 +53,6 @@ service ExecutionService {
rpc run_commands (RunRequest) returns (stream NodeRunResult) {}
rpc test_commands (TestRequest) returns (stream NodeRunResult) {}
rpc run_cli_script (ScriptRequest) returns (StructResponse) {}
rpc run_yaml_playbook (ScriptRequest) returns (StructResponse) {}
}
service ImportExportService {
@@ -72,6 +71,9 @@ service AIService {
rpc configure_mcp (MCPRequest) returns (google.protobuf.Empty) {}
rpc list_mcp_servers (google.protobuf.Empty) returns (ValueResponse) {}
rpc load_session_data (StringRequest) returns (StructResponse) {}
rpc build_playbook_chat (stream AskRequest) returns (stream AIResponse) {}
rpc analyze_execution_results (AnalyzeRequest) returns (stream AIResponse) {}
rpc predict_execution_results (PreflightRequest) returns (stream AIResponse) {}
}
service SystemService {
@@ -299,7 +301,13 @@ message MCPRequest {
service AuthService {
rpc login (LoginRequest) returns (LoginResponse) {}
rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {}
}
message SSOProvidersResponse {
repeated string providers = 1;
}
message LoginRequest {
@@ -307,6 +315,12 @@ message LoginRequest {
string password = 2;
}
message LoginSSORequest {
string username = 1;
string id_token = 2;
string provider = 3;
}
message LoginResponse {
string token = 1;
string username = 2;
@@ -317,3 +331,13 @@ message ChangePasswordRequest {
string old_password = 1;
string new_password = 2;
}
message AnalyzeRequest {
google.protobuf.Struct results = 1;
string query = 2;
}
message PreflightRequest {
repeated string target_nodes = 1;
repeated string commands = 2;
}
+34
View File
@@ -319,3 +319,37 @@ class AIService(BaseService):
agent = ai(self.config)
return agent.load_session_data(session_id)
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
"""Interact with the specialized Playbook Builder Agent."""
from connpy.ai import PlaybookBuilderAgent
agent = PlaybookBuilderAgent(self.config)
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
"""Analyze actual command execution results using Network Architect 1-shot."""
import json
results_str = json.dumps(results, indent=2)
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
if query:
prompt += f"\nSpecific user request: {query}"
prompt += f"\n\nResults Data:\n{results_str}"
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
nodes_str = ", ".join(target_nodes)
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
prompt += f"\n\nTarget Nodes: {nodes_str}"
prompt += f"\nCommands to simulate:\n{commands_str}"
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
return self.ask(prompt, status=status, chunk_callback=chunk_callback)
-50
View File
@@ -1,6 +1,5 @@
from typing import List, Dict, Any, Callable, Optional
import os
import yaml
from .base import BaseService
from connpy.core import nodes as Nodes
from .exceptions import ConnpyError
@@ -108,52 +107,3 @@ class ExecutionService(BaseService):
return self.run_commands(nodes_filter, commands, parallel=parallel)
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
"""Run a structured Connpy YAML automation playbook (from path or content)."""
playbook = None
if playbook_data.startswith("---YAML---\n"):
try:
content = playbook_data[len("---YAML---\n"):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to parse YAML content: {e}")
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f"Playbook file not found: {playbook_data}")
try:
with open(playbook_data, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
# Basic validation
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
action = playbook.get("action", "run")
options = playbook.get("options", {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
"nodes_filter": playbook["nodes"],
"commands": playbook["commands"],
"variables": playbook.get("variables"),
"parallel": options.get("parallel", parallel),
"timeout": playbook.get("timeout", options.get("timeout", 20)),
"prompt": options.get("prompt"),
"name": playbook.get("name", "Task")
}
# Map 'output' field to folder path if it's not stdout/null
output_cfg = playbook.get("output")
if output_cfg not in [None, "stdout"]:
exec_args["folder"] = output_cfg
if action == "run":
return self.run_commands(**exec_args)
elif action == "test":
exec_args["expected"] = playbook.get("expected", [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f"Unsupported playbook action: {action}")
+6 -4
View File
@@ -210,18 +210,19 @@ class UserService:
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
def generate_jwt(self, username) -> str:
"""Generates a secure JSON Web Token for the user expiring in 8 hours."""
"""Generates a secure JSON Web Token for the user expiring in 12 hours."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
"sub": username,
"exp": expiration
}
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
token = jwt.encode(payload, secret, algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
@@ -231,7 +232,8 @@ class UserService:
"""Decodes JWT and returns username if token is valid and unexpired."""
registry = self._load_registry()
try:
payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
payload = jwt.decode(token, secret, algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None
+9
View File
@@ -480,6 +480,15 @@ class TestToolDefinitions:
names = [t["function"]["name"] for t in tools]
assert "arch_tool" in names
def test_architect_tools_one_shot(self, ai_config):
from connpy.ai import ai
one_shot_ai = ai(ai_config, one_shot=True)
tools = one_shot_ai._get_architect_tools()
names = [t["function"]["name"] for t in tools]
assert "delegate_to_engineer" not in names
assert "return_to_engineer" not in names
assert "manage_memory_tool" in names
# =========================================================================
# AI Session Management tests
+136
View File
@@ -0,0 +1,136 @@
import pytest
from unittest.mock import patch, MagicMock, ANY
from connpy.connapp import connapp
import os
@pytest.fixture
def app(populated_config):
"""Returns an instance of connapp initialized with mock config."""
return connapp(populated_config)
def test_run_generate_ai_dispatch(app):
"""Test that connpy run --generate-ai parses and calls ai_generate."""
with patch("connpy.cli.run_handler.RunHandler.ai_generate") as mock_ai_gen:
app.start(["run", "--generate-ai", "new_playbook.yaml"])
mock_ai_gen.assert_called_once()
args = mock_ai_gen.call_args[0][0]
assert args.data == ["new_playbook.yaml"]
assert args.action == "generate_ai"
def test_run_preflight_ai_node(app):
"""Test that connpy run --preflight-ai calls predict_execution_results and exits."""
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
with pytest.raises(SystemExit) as exc:
app.start(["run", "router1", "show version", "--preflight-ai"])
assert exc.value.code == 0
mock_predict.assert_called_once_with(["router1"], ["show version"], chunk_callback=ANY)
def test_run_analyze_node(app):
"""Test that connpy run --analyze calls analyze_execution_results after execution."""
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "success"}})
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
app.start(["run", "router1", "show version", "--analyze"])
mock_run.assert_called_once()
mock_analyze.assert_called_once_with(
{"router1": {"status": 0, "output": "success"}},
query="show version",
chunk_callback=ANY
)
def test_run_preflight_ai_playbook(app, tmp_path):
"""Test that running a playbook with --preflight-ai predicts results per task."""
playbook_path = tmp_path / "test_playbook.yaml"
playbook_content = """
tasks:
- name: test-task
action: run
nodes: "router1"
commands: ["show ip interface brief"]
output: stdout
"""
playbook_path.write_text(playbook_content)
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
with pytest.raises(SystemExit) as exc:
app.start(["run", str(playbook_path), "--preflight-ai"])
assert exc.value.code == 0
mock_predict.assert_called_once_with(["router1"], ["show ip interface brief"], chunk_callback=ANY)
def test_run_analyze_playbook(app, tmp_path):
"""Test that running a playbook with --analyze triggers strategic analysis on all task outcomes."""
playbook_path = tmp_path / "test_playbook.yaml"
playbook_content = """
tasks:
- name: test-task
action: run
nodes: "router1"
commands: ["show ip interface brief"]
output: stdout
"""
playbook_path.write_text(playbook_content)
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "ok"}})
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
app.start(["run", str(playbook_path), "--analyze"])
mock_run.assert_called_once()
mock_analyze.assert_called_once_with(
{"router1": {"status": 0, "output": "ok"}},
query=f"Playbook: {str(playbook_path)}",
chunk_callback=ANY
)
def test_ai_generate_wizard_save(app, tmp_path):
"""Test that ai_generate wizard runs interactive chat loop, asks for validation and saves YAML."""
dest_yaml = tmp_path / "playbook.yaml"
mock_chat = MagicMock(return_value={
"response": "Here is your playbook.",
"chat_history": [],
"playbook_yaml": "tasks:\n - name: mytask"
})
app.services.ai.build_playbook_chat = mock_chat
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]):
app.start(["run", "--generate-ai", str(dest_yaml)])
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
assert os.path.exists(dest_yaml)
with open(dest_yaml) as f:
content = f.read()
assert "tasks:" in content
def test_ai_generate_wizard_run(app, tmp_path):
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
dest_yaml = tmp_path / "playbook_run.yaml"
mock_chat = MagicMock(return_value={
"response": "Here is your playbook.",
"chat_history": [],
"playbook_yaml": "tasks:\n - name: mytask\n action: run\n nodes: '*'\n commands: ['show version']\n output: stdout"
})
app.services.ai.build_playbook_chat = mock_chat
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]):
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
app.start(["run", "--generate-ai", str(dest_yaml)])
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
assert os.path.exists(dest_yaml)
with open(dest_yaml) as f:
content = f.read()
assert "tasks:" in content
mock_yaml_run.assert_called_once()
args = mock_yaml_run.call_args[0][0]
assert args.data == [str(dest_yaml)]
+67
View File
@@ -0,0 +1,67 @@
import pytest
from unittest.mock import MagicMock, patch
from connpy.cli.sso_handler import SSOHandler
def test_sso_handler_add_provider_with_allowed_domains():
# 1. Setup mock app structure
app_mock = MagicMock()
app_mock.services.mode = "local"
app_mock.config.config = {"sso": {"providers": {}}}
handler = SSOHandler(app_mock)
# Mock inquirer prompts
mock_answers = {
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
"secret": "my-secret-key",
"username_claim": "email",
"algorithms": "RS256, HS256",
"allowed_domains": "yyy.com, company.org"
}
args_mock = MagicMock()
args_mock.provider = "google"
with patch("inquirer.prompt", return_value=mock_answers):
handler.add_provider(args_mock)
# Verify update_setting was called with the correct data structure
app_mock.services.config_svc.update_setting.assert_called_once()
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
assert saved_key == "sso"
assert "providers" in saved_sso_config
assert "google" in saved_sso_config["providers"]
google_config = saved_sso_config["providers"]["google"]
assert google_config["jwks_url"] == "https://accounts.google.com/.well-known/jwks.json"
assert google_config["secret"] == "my-secret-key"
assert google_config["username_claim"] == "email"
assert google_config["algorithms"] == ["RS256", "HS256"]
assert google_config["allowed_domains"] == ["yyy.com", "company.org"]
def test_sso_handler_add_provider_allowed_domains_empty():
app_mock = MagicMock()
app_mock.services.mode = "local"
app_mock.config.config = {"sso": {"providers": {}}}
handler = SSOHandler(app_mock)
mock_answers = {
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
"secret": "",
"username_claim": "sub",
"algorithms": "RS256",
"allowed_domains": " " # empty input
}
args_mock = MagicMock()
args_mock.provider = "google"
with patch("inquirer.prompt", return_value=mock_answers):
handler.add_provider(args_mock)
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
google_config = saved_sso_config["providers"]["google"]
assert "allowed_domains" not in google_config
+43
View File
@@ -199,4 +199,47 @@ class TestUserCompletions:
assert "--help" in logout_completions
class TestSsoCompletions:
def test_sso_command_options(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test options at the "sso" level
sso_completions = resolve_completion(["sso", ""], tree)
assert "--add" in sso_completions
assert "--del" in sso_completions
assert "--rm" in sso_completions
assert "--show" in sso_completions
assert "--list" in sso_completions
assert "--ls" in sso_completions
def test_sso_action_completed_providers(self, tmp_path):
from connpy.completion import _build_tree, resolve_completion
import yaml
# Create mock config.yaml with SSO providers
config_file = tmp_path / "config.yaml"
config_data = {
"config": {
"sso": {
"providers": {
"google": {"username_claim": "email"},
"authelia": {"username_claim": "sub"}
}
}
}
}
with open(config_file, "w") as f:
yaml.dump(config_data, f)
tree = _build_tree([], [], [], {}, str(tmp_path))
# Resolve after --del, --rm, --show, --add
for action in ["--del", "--rm", "--show", "--add"]:
completions = resolve_completion(["sso", action, ""], tree)
assert "google" in completions
assert "authelia" in completions
+52
View File
@@ -338,6 +338,58 @@ class TestNodeTest:
assert isinstance(result, dict)
assert result.get("1.1.1.1") == False
def test_test_expected_regex(self, mock_pexpect):
"""Regex in expected matches correctly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12.5")
with patch.object(n, '_logclean', return_value="Debian version 12.5"):
result = n.test(["cat /etc/debian_version"], "version \\d+\\.\\d+")
assert isinstance(result, dict)
assert result.get("version \\d+\\.\\d+") == True
def test_test_expected_invalid_regex(self, mock_pexpect):
"""Malformed regex defaults to literal matching safely."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
# (invalid is a malformed regex (missing closing paren), but matches literally
n.mylog = io.BytesIO(b"some (invalid text")
with patch.object(n, '_logclean', return_value="some (invalid text"):
result = n.test(["echo"], "(invalid")
assert isinstance(result, dict)
assert result.get("(invalid") == True
def test_test_expected_with_vars(self, mock_pexpect):
"""Expected output formats variables properly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12")
with patch.object(n, '_logclean', return_value="Debian version 12"):
result = n.test(["echo"], "version {version_num}", vars={"version_num": "12"})
assert isinstance(result, dict)
assert result.get("version 12") == True
# =========================================================================
# nodes (parallel) tests
+229
View File
@@ -129,3 +129,232 @@ class TestGRPCAuthentication:
# 4. Logging in with new password must succeed
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
assert login_res_new.token is not None
def test_sso_login_success_and_auto_provision(self, channel, registry):
"""Tests that a valid SSO token successfully logs the user in and auto-provisions their account."""
import jwt
# 1. Setup SSO configuration in the registry's shared config
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "preferred_username",
"algorithms": ["HS256"]
}
}
}
# 2. Check that the user 'ssoalice' does not exist yet
assert not any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 3. Generate a valid SSO token signed with Authelia's secret
sso_token = jwt.encode(
{"preferred_username": "ssoalice"},
"sso-shared-secret",
algorithm="HS256"
)
# 4. Call login_sso
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="ssoalice",
id_token=sso_token,
provider="authelia"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "ssoalice"
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 5. Verify user 'ssoalice' was auto-created/provisioned
assert any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 6. Make an authenticated call to NodeService list_nodes with the returned token
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_sso_login_invalid_signature(self, channel, registry):
"""Verifies that an SSO token with an invalid signature fails with UNAUTHENTICATED."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
# Token signed with a WRONG key
wrong_token = jwt.encode({"sub": "bob"}, "wrong-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="bob",
id_token=wrong_token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO Token validation failed" in exc.value.details()
def test_sso_login_mismatched_username(self, channel, registry):
"""Verifies that if the requested username doesn't match the token claim, it fails."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "charlie"}, "sso-shared-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="different_user",
id_token=token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Mismatched username" in exc.value.details()
def test_sso_login_allowed_domains_success(self, channel, registry):
"""Verifies that SSO login succeeds if email matches allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com", "other.org"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_sso_login_allowed_domains_failed(self, channel, registry):
"""Verifies that SSO login fails if email does not match allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@attacker.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO user domain 'attacker.com' not allowed" in exc.value.details()
def test_sso_login_allowed_domains_fallback_to_username(self, channel, registry):
"""Verifies allowed_domains validation falls back to username claim if email is not present."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_login_and_login_sso_expiration_time(self, channel, registry):
"""Verifies expires_at is set to 12 hours in both login and login_sso."""
import jwt
import datetime
# 1. Test standard login expiration
registry.user_service.create_user("exp_user", "password123")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_res = auth_stub.login(connpy_pb2.LoginRequest(username="exp_user", password="password123"))
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
expected_expires_12h = now + 12 * 3600
# Allow a 10s buffer for execution lag
assert abs(login_res.expires_at - expected_expires_12h) < 10
# 2. Test SSO login expiration
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "sso_exp_user"}, "sso-secret", algorithm="HS256")
login_sso_res = auth_stub.login_sso(connpy_pb2.LoginSSORequest(
username="sso_exp_user",
id_token=token,
provider="authelia"
))
assert abs(login_sso_res.expires_at - expected_expires_12h) < 10
+296
View File
@@ -0,0 +1,296 @@
import pytest
import json
from unittest.mock import patch, MagicMock
from connpy.ai import PlaybookBuilderAgent
from connpy.services.ai_service import AIService
# =========================================================================
# PlaybookBuilderAgent validation tests
# =========================================================================
def test_validate_playbook_valid(ai_config):
"""Verifies that a valid canonical tasks[] playbook passes validation."""
agent = PlaybookBuilderAgent(ai_config)
valid_yaml = """
tasks:
- name: "Apply standard config"
action: "run"
nodes: "router1"
commands:
- "conf t"
- "end"
output: "stdout"
- name: "Verify connectivity"
action: "test"
nodes: "router1"
commands:
- "ping 10.0.0.1"
expected: "!"
output: "stdout"
"""
res = agent.validate_playbook(valid_yaml)
assert res["valid"] is True
assert "valid" in res["message"].lower()
def test_validate_playbook_invalid_yaml(ai_config):
"""Verifies that syntax errors in YAML are caught and reported."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Broken task"
action: "run
nodes: "router1"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "syntax error" in res["error"].lower()
def test_validate_playbook_missing_tasks_key(ai_config):
"""Verifies that a playbook without tasks root key is invalid."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
not_tasks:
- name: "Apply standard config"
action: "run"
nodes: "router1"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing mandatory root 'tasks' key" in res["error"].lower()
def test_validate_playbook_missing_mandatory_fields(ai_config):
"""Verifies that missing name, action, nodes, commands, or output triggers a validation failure."""
agent = PlaybookBuilderAgent(ai_config)
# Missing nodes
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "run"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing mandatory fields" in res["error"].lower()
assert "nodes" in res["error"]
def test_validate_playbook_invalid_action(ai_config):
"""Verifies that an unsupported action type is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "delete_everything"
nodes: "router1"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "invalid action" in res["error"].lower()
def test_validate_playbook_missing_expected_in_test(ai_config):
"""Verifies that action 'test' requires the expected field."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "test"
nodes: "router1"
commands:
- "ping 10.0.0.1"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing the mandatory 'expected' key" in res["error"].lower()
def test_validate_playbook_invalid_nodes_type(ai_config):
"""Verifies that nodes of invalid type (e.g. integer) is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply config"
action: "run"
nodes: 12345
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "nodes' must be a string (regex) or a list of strings (regexes)" in res["error"]
def test_validate_playbook_invalid_nodes_list_item(ai_config):
"""Verifies that nodes list containing non-string items is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply config"
action: "run"
nodes:
- "router1"
- 9999
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "list contains a non-string value" in res["error"]
# =========================================================================
# AIService new methods delegation tests
# =========================================================================
def test_build_playbook_chat_delegation(ai_config):
"""Verifies that build_playbook_chat instantiates PlaybookBuilderAgent and delegates ask."""
service = AIService(ai_config)
with patch("connpy.ai.PlaybookBuilderAgent") as MockAgentClass:
mock_agent = MockAgentClass.return_value
mock_agent.ask.return_value = {"response": "Mock response", "chat_history": []}
history = [{"role": "user", "content": "build playbook"}]
res = service.build_playbook_chat("help me", chat_history=history)
MockAgentClass.assert_called_once_with(ai_config)
mock_agent.ask.assert_called_once_with("help me", chat_history=history, status=None, chunk_callback=None)
assert res["response"] == "Mock response"
def test_analyze_execution_results_delegation(ai_config):
"""Verifies that analyze_execution_results formats prompt with @architect and delegates to self.ask."""
service = AIService(ai_config)
service.ask = MagicMock()
results = {"router1": {"output": "success", "status": 0}}
service.analyze_execution_results(results, query="diagnose border")
service.ask.assert_called_once()
args, kwargs = service.ask.call_args
prompt = args[0]
assert prompt.startswith("@architect:")
assert "diagnose border" in prompt
assert "Results Data:" in prompt
assert "router1" in prompt
assert kwargs.get("one_shot") is True
def test_predict_execution_results_delegation(ai_config):
"""Verifies that predict_execution_results formats prompt with @engineer and delegates to self.ask."""
service = AIService(ai_config)
service.ask = MagicMock()
nodes = ["router1", "router2"]
commands = ["conf t", "interface lo0"]
service.predict_execution_results(nodes, commands)
service.ask.assert_called_once()
args, kwargs = service.ask.call_args
prompt = args[0]
assert prompt.startswith("@engineer:")
assert "Preflight Simulation Agent" in prompt
assert "router1, router2" in prompt
assert "conf t" in prompt
assert "interface lo0" in prompt
# =========================================================================
# gRPC Integration Tests for AIService
# =========================================================================
import grpc
from concurrent import futures
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
class TestGRPCAIIntegration:
@pytest.fixture
def grpc_server(self, populated_config):
"""Starts a local gRPC server for IA integration testing."""
srv = grpc.server(futures.ThreadPoolExecutor(max_workers=5))
connpy_pb2_grpc.add_AIServiceServicer_to_server(server.ServerServicer(populated_config).ai if hasattr(server, 'ServerServicer') else server.AIServicer(populated_config), srv)
port = srv.add_insecure_port('127.0.0.1:0')
srv.start()
yield f"127.0.0.1:{port}"
srv.stop(0)
@pytest.fixture
def channel(self, grpc_server):
with grpc.insecure_channel(grpc_server) as channel:
yield channel
@pytest.fixture
def ai_stub(self, channel):
return stubs.AIStub(channel, "localhost")
def test_build_playbook_chat_grpc(self, ai_stub, populated_config):
"""Verifies that build_playbook_chat gRPC stream functions correctly."""
# Mock PlaybookBuilderAgent.ask to simulate agent response stream
def mock_ask(user_input, chat_history=None, status=None, debug=False, chunk_callback=None):
if chunk_callback:
chunk_callback("Generated Tasks:\n- name: config")
return {"response": "Done", "playbook_yaml": "tasks:\n- name: config"}
with patch("connpy.ai.PlaybookBuilderAgent.ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.build_playbook_chat("make playbook", chunk_callback=callback)
assert "tasks:" in res["playbook_yaml"]
assert len(chunks) > 0
assert "Generated Tasks:" in chunks[0]
def test_analyze_execution_results_grpc(self, ai_stub, populated_config):
"""Verifies that analyze_execution_results gRPC stream functions correctly."""
# Mock AIService.ask to simulate response stream
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
if chunk_callback:
chunk_callback("Results are optimal.")
return {"response": "Done"}
with patch.object(AIService, "ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.analyze_execution_results({"r1": "ok"}, query="test query", chunk_callback=callback)
assert res is not None
assert len(chunks) > 0
assert "optimal" in chunks[0]
def test_predict_execution_results_grpc(self, ai_stub, populated_config):
"""Verifies that predict_execution_results gRPC stream functions correctly."""
# Mock AIService.ask to simulate response stream
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
if chunk_callback:
chunk_callback("Commands are safe.")
return {"response": "Done"}
with patch.object(AIService, "ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.predict_execution_results(["r1"], ["show version"], chunk_callback=callback)
assert res is not None
assert len(chunks) > 0
assert "safe" in chunks[0]
+12 -12
View File
@@ -90,7 +90,7 @@ el.replaceWith(d);
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
if args.resume:
sessions, _ = self.app.services.ai.list_sessions()
@@ -100,8 +100,8 @@ el.replaceWith(d);
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -129,7 +129,7 @@ el.replaceWith(d);
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# El resto de la interacción el CLI la maneja con el agente subyacente
# The rest of the interaction is handled by the CLI with the underlying agent
self.app.myai = self.app.services.ai
self.ai_overrides = arguments
@@ -140,7 +140,7 @@ el.replaceWith(d);
def single_question(self, args, session_id):
query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) as status:
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
@@ -177,7 +177,7 @@ el.replaceWith(d);
if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
@@ -502,7 +502,7 @@ el.replaceWith(d);
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
if args.resume:
sessions, _ = self.app.services.ai.list_sessions()
@@ -512,8 +512,8 @@ el.replaceWith(d);
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -541,7 +541,7 @@ el.replaceWith(d);
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# El resto de la interacción el CLI la maneja con el agente subyacente
# The rest of the interaction is handled by the CLI with the underlying agent
self.app.myai = self.app.services.ai
self.ai_overrides = arguments
@@ -583,7 +583,7 @@ el.replaceWith(d);
if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
@@ -618,7 +618,7 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def single_question(self, args, session_id):
query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) as status:
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
+5
View File
@@ -92,6 +92,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></dt>
<dd>
<div class="desc"></div>
@@ -142,6 +146,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
<li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li>
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
<li><code><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></li>
+586 -7
View File
@@ -63,7 +63,12 @@ el.replaceWith(d);
def dispatch(self, args):
if len(args.data) &gt; 1:
args.action = &#34;noderun&#34;
actions = {&#34;noderun&#34;: self.node_run, &#34;generate&#34;: self.yaml_generate, &#34;run&#34;: self.yaml_run}
actions = {
&#34;noderun&#34;: self.node_run,
&#34;generate&#34;: self.yaml_generate,
&#34;generate_ai&#34;: self.ai_generate,
&#34;run&#34;: self.yaml_run
}
return actions.get(args.action)(args)
def node_run(self, args):
@@ -81,6 +86,41 @@ el.replaceWith(d);
commands = [&#34; &#34;.join(args.data[1:])]
# Check for Preflight AI simulation
if getattr(args, &#34;preflight_ai&#34;, False):
matched_node_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in matched_nodes]
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
matched_node_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed: {e}&#34;)
sys.exit(1)
sys.exit(0)
try:
header_printed = False
@@ -118,6 +158,40 @@ el.replaceWith(d);
)
printer.run_summary(results)
# Analyze execution results if requested
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else &#34; &#34;.join(args.data[1:])
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
@@ -138,8 +212,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
# Check preflight first before any task runs
if getattr(args, &#34;preflight_ai&#34;, False):
preflight_failed = False
for task in playbook.get(&#34;tasks&#34;, []):
name = task.get(&#34;name&#34;, &#34;Task&#34;)
nodelist = task.get(&#34;nodes&#34;, [])
commands = task.get(&#34;commands&#34;, [])
# Resolve nodes to names
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
resolved_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in resolved_nodes]
printer.console.print(f&#34;\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)&#34;)
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
resolved_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed for task {name}: {e}&#34;)
preflight_failed = True
if preflight_failed:
sys.exit(1)
sys.exit(0)
# Standard run
results_all = {}
for task in playbook.get(&#34;tasks&#34;, []):
self.cli_run(task)
task_res = self.cli_run(task)
if task_res:
results_all.update(task_res)
# If analyze is enabled, run analysis on accumulated results
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing playbook execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f&#34;Playbook: {path}&#34;
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results_all,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except Exception as e:
printer.error(f&#34;Failed to run playbook {path}: {e}&#34;)
@@ -184,6 +355,7 @@ el.replaceWith(d);
nodelist = resolved_nodes
results = {}
try:
header_printed = False
if action == &#34;run&#34;:
@@ -243,13 +415,244 @@ el.replaceWith(d);
)
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
except ConnpyError as e:
printer.error(str(e))</code></pre>
printer.error(str(e))
return {}
def ai_generate(self, args):
from rich.prompt import Prompt
from rich.rule import Rule
from rich.panel import Panel
from rich.syntax import Syntax
dest_file = args.data[0]
if os.path.exists(dest_file):
printer.error(f&#34;File &#39;{dest_file}&#39; already exists.&#34;)
sys.exit(14)
chat_history = []
# Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;))
while True:
try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;)
break
if user_prompt.strip().lower() in [&#34;exit&#34;, &#34;quit&#34;]:
printer.info(&#34;Exiting AI Assistant.&#34;)
break
if not user_prompt.strip():
continue
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try:
status_context.stop()
except:
pass
printer.console.print(Rule(title=&#34;[engineer][bold]Playbook Builder AI[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
res = self.app.services.ai.build_playbook_chat(
user_prompt,
chat_history=chat_history,
chunk_callback=callback
)
if first_chunk:
try:
status_context.stop()
except:
pass
renderer.flush()
if not first_chunk:
printer.console.print(Rule(style=&#34;engineer&#34;))
# Update history
if res and &#34;chat_history&#34; in res:
chat_history = res[&#34;chat_history&#34;]
# Check if the agent returned a validated playbook YAML
if res and &#34;playbook_yaml&#34; in res and res[&#34;playbook_yaml&#34;]:
yaml_content = res[&#34;playbook_yaml&#34;]
printer.console.print()
printer.success(&#34;Playbook YAML successfully generated and validated.&#34;)
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
syntax = Syntax(yaml_content, &#34;yaml&#34;, theme=&#34;ansi_dark&#34;, word_wrap=True, background_color=&#34;default&#34;)
panel = Panel(syntax, title=&#34;[engineer][bold]Resulting Playbook[/bold][/engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
printer.console.print(panel)
# Ask if the user wants to save it
try:
save_confirm = Prompt.ask(
f&#34;\nDo you want to save this playbook to &#39;{dest_file}&#39;?&#34;,
choices=[&#34;y&#34;, &#34;n&#34;, &#34;run&#34;],
default=&#34;y&#34;
)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Saving skipped.&#34;)
break
choice = save_confirm.strip().lower()
if choice in [&#34;y&#34;, &#34;yes&#34;, &#34;run&#34;]:
with open(dest_file, &#34;w&#34;) as f:
f.write(yaml_content)
printer.success(f&#34;Playbook saved successfully to &#39;{dest_file}&#39;&#34;)
if choice == &#34;run&#34;:
printer.console.print()
printer.info(&#34;Executing the saved playbook...&#34;)
self.yaml_run(args)
break
else:
printer.warning(&#34;Playbook not saved. You can continue describing changes or exit.&#34;)
except Exception as e:
printer.error(f&#34;Error in AI chat: {e}&#34;)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.run_handler.RunHandler.ai_generate"><code class="name flex">
<span>def <span class="ident">ai_generate</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def ai_generate(self, args):
from rich.prompt import Prompt
from rich.rule import Rule
from rich.panel import Panel
from rich.syntax import Syntax
dest_file = args.data[0]
if os.path.exists(dest_file):
printer.error(f&#34;File &#39;{dest_file}&#39; already exists.&#34;)
sys.exit(14)
chat_history = []
# Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;))
while True:
try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;)
break
if user_prompt.strip().lower() in [&#34;exit&#34;, &#34;quit&#34;]:
printer.info(&#34;Exiting AI Assistant.&#34;)
break
if not user_prompt.strip():
continue
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try:
status_context.stop()
except:
pass
printer.console.print(Rule(title=&#34;[engineer][bold]Playbook Builder AI[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
res = self.app.services.ai.build_playbook_chat(
user_prompt,
chat_history=chat_history,
chunk_callback=callback
)
if first_chunk:
try:
status_context.stop()
except:
pass
renderer.flush()
if not first_chunk:
printer.console.print(Rule(style=&#34;engineer&#34;))
# Update history
if res and &#34;chat_history&#34; in res:
chat_history = res[&#34;chat_history&#34;]
# Check if the agent returned a validated playbook YAML
if res and &#34;playbook_yaml&#34; in res and res[&#34;playbook_yaml&#34;]:
yaml_content = res[&#34;playbook_yaml&#34;]
printer.console.print()
printer.success(&#34;Playbook YAML successfully generated and validated.&#34;)
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
syntax = Syntax(yaml_content, &#34;yaml&#34;, theme=&#34;ansi_dark&#34;, word_wrap=True, background_color=&#34;default&#34;)
panel = Panel(syntax, title=&#34;[engineer][bold]Resulting Playbook[/bold][/engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
printer.console.print(panel)
# Ask if the user wants to save it
try:
save_confirm = Prompt.ask(
f&#34;\nDo you want to save this playbook to &#39;{dest_file}&#39;?&#34;,
choices=[&#34;y&#34;, &#34;n&#34;, &#34;run&#34;],
default=&#34;y&#34;
)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Saving skipped.&#34;)
break
choice = save_confirm.strip().lower()
if choice in [&#34;y&#34;, &#34;yes&#34;, &#34;run&#34;]:
with open(dest_file, &#34;w&#34;) as f:
f.write(yaml_content)
printer.success(f&#34;Playbook saved successfully to &#39;{dest_file}&#39;&#34;)
if choice == &#34;run&#34;:
printer.console.print()
printer.info(&#34;Executing the saved playbook...&#34;)
self.yaml_run(args)
break
else:
printer.warning(&#34;Playbook not saved. You can continue describing changes or exit.&#34;)
except Exception as e:
printer.error(f&#34;Error in AI chat: {e}&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.run_handler.RunHandler.cli_run"><code class="name flex">
<span>def <span class="ident">cli_run</span></span>(<span>self, script)</span>
</code></dt>
@@ -297,6 +700,7 @@ el.replaceWith(d);
nodelist = resolved_nodes
results = {}
try:
header_printed = False
if action == &#34;run&#34;:
@@ -356,9 +760,12 @@ el.replaceWith(d);
)
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
except ConnpyError as e:
printer.error(str(e))</code></pre>
printer.error(str(e))
return {}</code></pre>
</details>
<div class="desc"></div>
</dd>
@@ -373,7 +780,12 @@ el.replaceWith(d);
<pre><code class="python">def dispatch(self, args):
if len(args.data) &gt; 1:
args.action = &#34;noderun&#34;
actions = {&#34;noderun&#34;: self.node_run, &#34;generate&#34;: self.yaml_generate, &#34;run&#34;: self.yaml_run}
actions = {
&#34;noderun&#34;: self.node_run,
&#34;generate&#34;: self.yaml_generate,
&#34;generate_ai&#34;: self.ai_generate,
&#34;run&#34;: self.yaml_run
}
return actions.get(args.action)(args)</code></pre>
</details>
<div class="desc"></div>
@@ -401,6 +813,41 @@ el.replaceWith(d);
commands = [&#34; &#34;.join(args.data[1:])]
# Check for Preflight AI simulation
if getattr(args, &#34;preflight_ai&#34;, False):
matched_node_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in matched_nodes]
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
matched_node_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed: {e}&#34;)
sys.exit(1)
sys.exit(0)
try:
header_printed = False
@@ -438,6 +885,40 @@ el.replaceWith(d);
)
printer.run_summary(results)
# Analyze execution results if requested
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else &#34; &#34;.join(args.data[1:])
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)</code></pre>
@@ -478,8 +959,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
# Check preflight first before any task runs
if getattr(args, &#34;preflight_ai&#34;, False):
preflight_failed = False
for task in playbook.get(&#34;tasks&#34;, []):
name = task.get(&#34;name&#34;, &#34;Task&#34;)
nodelist = task.get(&#34;nodes&#34;, [])
commands = task.get(&#34;commands&#34;, [])
# Resolve nodes to names
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
resolved_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in resolved_nodes]
printer.console.print(f&#34;\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)&#34;)
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
first_chunk = False
renderer.feed(chunk)
try:
status_context.start()
self.app.services.ai.predict_execution_results(
resolved_names,
commands,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed for task {name}: {e}&#34;)
preflight_failed = True
if preflight_failed:
sys.exit(1)
sys.exit(0)
# Standard run
results_all = {}
for task in playbook.get(&#34;tasks&#34;, []):
self.cli_run(task)
task_res = self.cli_run(task)
if task_res:
results_all.update(task_res)
# If analyze is enabled, run analysis on accumulated results
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing playbook execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f&#34;Playbook: {path}&#34;
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results_all,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except Exception as e:
printer.error(f&#34;Failed to run playbook {path}: {e}&#34;)
@@ -506,7 +1084,8 @@ el.replaceWith(d);
<ul>
<li>
<h4><code><a title="connpy.cli.run_handler.RunHandler" href="#connpy.cli.run_handler.RunHandler">RunHandler</a></code></h4>
<ul class="">
<ul class="two-column">
<li><code><a title="connpy.cli.run_handler.RunHandler.ai_generate" href="#connpy.cli.run_handler.RunHandler.ai_generate">ai_generate</a></code></li>
<li><code><a title="connpy.cli.run_handler.RunHandler.cli_run" href="#connpy.cli.run_handler.RunHandler.cli_run">cli_run</a></code></li>
<li><code><a title="connpy.cli.run_handler.RunHandler.dispatch" href="#connpy.cli.run_handler.RunHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.run_handler.RunHandler.node_run" href="#connpy.cli.run_handler.RunHandler.node_run">node_run</a></code></li>
+459
View File
@@ -0,0 +1,459 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.sso_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.sso_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler"><code class="flex name class">
<span>class <span class="ident">SSOHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class SSOHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
return
# Print list in YAML format
providers_list = list(providers.keys())
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
printer.data(&#34;Configured SSO Providers&#34;, yaml_str)
def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler.add_provider"><code class="name flex">
<span>def <span class="ident">add_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.delete_provider"><code class="name flex">
<span>def <span class="ident">delete_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.list_providers"><code class="name flex">
<span>def <span class="ident">list_providers</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
return
# Print list in YAML format
providers_list = list(providers.keys())
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
printer.data(&#34;Configured SSO Providers&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.show_provider"><code class="name flex">
<span>def <span class="ident">show_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.sso_handler.SSOHandler" href="#connpy.cli.sso_handler.SSOHandler">SSOHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.sso_handler.SSOHandler.add_provider" href="#connpy.cli.sso_handler.SSOHandler.add_provider">add_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.delete_provider" href="#connpy.cli.sso_handler.SSOHandler.delete_provider">delete_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.dispatch" href="#connpy.cli.sso_handler.SSOHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.list_providers" href="#connpy.cli.sso_handler.SSOHandler.list_providers">list_providers</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.show_provider" href="#connpy.cli.sso_handler.SSOHandler.show_provider">show_provider</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer>
</body>
</html>
+22 -22
View File
@@ -121,14 +121,14 @@ el.replaceWith(d);
}
# 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34;
))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot
self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;)
@@ -195,7 +195,7 @@ el.replaceWith(d);
if app and app.current_buffer:
text = app.current_buffer.text
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
# Only show command help if typing the first command and there are no spaces
if text.startswith(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())]
@@ -210,19 +210,19 @@ el.replaceWith(d);
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
# Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
# If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
if len(range_blocks) &gt; 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
# Clean and truncate very long commands so they don&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
@@ -300,8 +300,8 @@ el.replaceWith(d);
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -333,12 +333,12 @@ el.replaceWith(d);
except: pass
asyncio.create_task(delayed_refresh())
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
# Move the cursor up and clean the line so the new prompt replaces the previous one
sys.stdout.write(&#39;\x1b[1A\x1b[2K&#39;)
sys.stdout.flush()
continue
else:
# Limpiar el mensaje de la barra cuando se hace una pregunta real
# Clean the toolbar message when a real question is asked
state[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -575,14 +575,14 @@ el.replaceWith(d);
}
# 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34;
))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot
self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;)
@@ -649,7 +649,7 @@ el.replaceWith(d);
if app and app.current_buffer:
text = app.current_buffer.text
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
# Only show command help if typing the first command and there are no spaces
if text.startswith(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())]
@@ -664,19 +664,19 @@ el.replaceWith(d);
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
# Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
# If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
if len(range_blocks) &gt; 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
# Clean and truncate very long commands so they don&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
@@ -754,8 +754,8 @@ el.replaceWith(d);
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -787,12 +787,12 @@ el.replaceWith(d);
except: pass
asyncio.create_task(delayed_refresh())
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
# Move the cursor up and clean the line so the new prompt replaces the previous one
sys.stdout.write(&#39;\x1b[1A\x1b[2K&#39;)
sys.stdout.flush()
continue
else:
# Limpiar el mensaje de la barra cuando se hace una pregunta real
# Clean the toolbar message when a real question is asked
state[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
+492 -100
View File
@@ -100,6 +100,21 @@ el.replaceWith(d);
request_deserializer=connpy__pb2.StringRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
&#39;build_playbook_chat&#39;: grpc.stream_stream_rpc_method_handler(
servicer.build_playbook_chat,
request_deserializer=connpy__pb2.AskRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
&#39;analyze_execution_results&#39;: grpc.unary_stream_rpc_method_handler(
servicer.analyze_execution_results,
request_deserializer=connpy__pb2.AnalyzeRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
&#39;predict_execution_results&#39;: grpc.unary_stream_rpc_method_handler(
servicer.predict_execution_results,
request_deserializer=connpy__pb2.PreflightRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
&#39;connpy.AIService&#39;, rpc_method_handlers)
@@ -123,11 +138,21 @@ el.replaceWith(d);
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
&#39;login_sso&#39;: grpc.unary_unary_rpc_method_handler(
servicer.login_sso,
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
&#39;change_password&#39;: grpc.unary_unary_rpc_method_handler(
servicer.change_password,
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
&#39;get_sso_providers&#39;: grpc.unary_unary_rpc_method_handler(
servicer.get_sso_providers,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
&#39;connpy.AuthService&#39;, rpc_method_handlers)
@@ -209,11 +234,6 @@ el.replaceWith(d);
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
&#39;run_yaml_playbook&#39;: grpc.unary_unary_rpc_method_handler(
servicer.run_yaml_playbook,
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
&#39;connpy.ExecutionService&#39;, rpc_method_handlers)
@@ -739,11 +759,129 @@ el.replaceWith(d);
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def build_playbook_chat(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
&#39;/connpy.AIService/build_playbook_chat&#39;,
connpy__pb2.AskRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def analyze_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
&#39;/connpy.AIService/analyze_execution_results&#39;,
connpy__pb2.AnalyzeRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def predict_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
&#39;/connpy.AIService/predict_execution_results&#39;,
connpy__pb2.PreflightRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
<h3>Static methods</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results"><code class="name flex">
<span>def <span class="ident">analyze_execution_results</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def analyze_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
&#39;/connpy.AIService/analyze_execution_results&#39;,
connpy__pb2.AnalyzeRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>request_iterator,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
@@ -818,6 +956,43 @@ def ask_copilot(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat"><code class="name flex">
<span>def <span class="ident">build_playbook_chat</span></span>(<span>request_iterator,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def build_playbook_chat(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
&#39;/connpy.AIService/build_playbook_chat&#39;,
connpy__pb2.AskRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp"><code class="name flex">
<span>def <span class="ident">configure_mcp</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
@@ -1077,6 +1252,43 @@ def load_session_data(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results"><code class="name flex">
<span>def <span class="ident">predict_execution_results</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def predict_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
&#39;/connpy.AIService/predict_execution_results&#39;,
connpy__pb2.PreflightRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer"><code class="flex name class">
@@ -1139,6 +1351,24 @@ def load_session_data(request,
raise NotImplementedError(&#39;Method not implemented!&#39;)
def load_session_data(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def build_playbook_chat(self, request_iterator, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def analyze_execution_results(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def predict_execution_results(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
@@ -1151,6 +1381,22 @@ def load_session_data(request,
</ul>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results"><code class="name flex">
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def analyze_execution_results(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>self, request_iterator, context)</span>
</code></dt>
@@ -1183,6 +1429,22 @@ def load_session_data(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat"><code class="name flex">
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, request_iterator, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def build_playbook_chat(self, request_iterator, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp"><code class="name flex">
<span>def <span class="ident">configure_mcp</span></span>(<span>self, request, context)</span>
</code></dt>
@@ -1295,6 +1557,22 @@ def load_session_data(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results"><code class="name flex">
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def predict_execution_results(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub"><code class="flex name class">
@@ -1359,6 +1637,21 @@ def load_session_data(request,
&#39;/connpy.AIService/load_session_data&#39;,
request_serializer=connpy__pb2.StringRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.build_playbook_chat = channel.stream_stream(
&#39;/connpy.AIService/build_playbook_chat&#39;,
request_serializer=connpy__pb2.AskRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.analyze_execution_results = channel.unary_stream(
&#39;/connpy.AIService/analyze_execution_results&#39;,
request_serializer=connpy__pb2.AnalyzeRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.predict_execution_results = channel.unary_stream(
&#39;/connpy.AIService/predict_execution_results&#39;,
request_serializer=connpy__pb2.PreflightRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
@@ -1407,6 +1700,33 @@ def load_session_data(request,
metadata,
_registered_method=True)
@staticmethod
def login_sso(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/login_sso&#39;,
connpy__pb2.LoginSSORequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def change_password(request,
target,
@@ -1432,6 +1752,33 @@ def load_session_data(request,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def get_sso_providers(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/get_sso_providers&#39;,
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.SSOProvidersResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -1474,6 +1821,43 @@ def change_password(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def get_sso_providers(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/get_sso_providers&#39;,
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.SSOProvidersResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
@@ -1511,6 +1895,43 @@ def login(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso"><code class="name flex">
<span>def <span class="ident">login_sso</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def login_sso(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/login_sso&#39;,
connpy__pb2.LoginSSORequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
@@ -1530,7 +1951,19 @@ def login(request,
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def login_sso(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def change_password(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def get_sso_providers(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
@@ -1559,6 +1992,22 @@ def login(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_sso_providers(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
</code></dt>
@@ -1575,6 +2024,22 @@ def login(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso"><code class="name flex">
<span>def <span class="ident">login_sso</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def login_sso(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
@@ -1600,10 +2065,20 @@ def login(request,
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.login_sso = channel.unary_unary(
&#39;/connpy.AuthService/login_sso&#39;,
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
&#39;/connpy.AuthService/change_password&#39;,
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
self.get_sso_providers = channel.unary_unary(
&#39;/connpy.AuthService/get_sso_providers&#39;,
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
@@ -2313,33 +2788,6 @@ def update_setting(request,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def run_yaml_playbook(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.ExecutionService/run_yaml_playbook&#39;,
connpy__pb2.ScriptRequest.SerializeToString,
connpy__pb2.StructResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -2419,43 +2867,6 @@ def run_commands(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook"><code class="name flex">
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def run_yaml_playbook(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.ExecutionService/run_yaml_playbook&#39;,
connpy__pb2.ScriptRequest.SerializeToString,
connpy__pb2.StructResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands"><code class="name flex">
<span>def <span class="ident">test_commands</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
@@ -2519,12 +2930,6 @@ def test_commands(request,
raise NotImplementedError(&#39;Method not implemented!&#39;)
def run_cli_script(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def run_yaml_playbook(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
@@ -2569,22 +2974,6 @@ def test_commands(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook"><code class="name flex">
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def run_yaml_playbook(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands"><code class="name flex">
<span>def <span class="ident">test_commands</span></span>(<span>self, request, context)</span>
</code></dt>
@@ -2635,11 +3024,6 @@ def test_commands(request,
&#39;/connpy.ExecutionService/run_cli_script&#39;,
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.run_yaml_playbook = channel.unary_unary(
&#39;/connpy.ExecutionService/run_yaml_playbook&#39;,
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
@@ -6089,9 +6473,11 @@ def stop_api(request,
<ul>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService">AIService</a></code></h4>
<ul class="two-column">
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask">ask</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot">ask_copilot</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm">confirm</a></code></li>
@@ -6099,13 +6485,16 @@ def stop_api(request,
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results">predict_execution_results</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></code></h4>
<ul class="two-column">
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
@@ -6113,6 +6502,7 @@ def stop_api(request,
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results">predict_execution_results</a></code></li>
</ul>
</li>
<li>
@@ -6122,14 +6512,18 @@ def stop_api(request,
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
</ul>
</li>
<li>
@@ -6165,7 +6559,6 @@ def stop_api(request,
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands">test_commands</a></code></li>
</ul>
</li>
@@ -6174,7 +6567,6 @@ def stop_api(request,
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
</ul>
</li>
+249 -31
View File
@@ -111,6 +111,15 @@ el.replaceWith(d);
fallback_provider = ServiceProvider(config, mode=&#34;local&#34;)
registry = UserRegistry(config.defaultdir)
# Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env
import os
if os.getenv(&#34;CONN_SSO_GATEWAY_SECRET&#34;) and registry._shared_config:
sso_config = registry._shared_config.config.get(&#34;sso&#34;, {})
providers = sso_config.get(&#34;providers&#34;, {})
if &#34;trusted_gateway&#34; not in providers:
from connpy import printer
printer.warning(&#34;CONN_SSO_GATEWAY_SECRET is defined in environment, but &#39;trusted_gateway&#39; is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.&#34;)
interceptors = []
if debug:
interceptors.append(LoggingInterceptor())
@@ -174,12 +183,11 @@ el.replaceWith(d);
def service(self):
return self._get_provider().ai
@handle_errors
def ask(self, request_iterator, context):
def _handle_chat_stream(self, request_iterator, context, service_method):
import queue
import threading
import contextvars
ai_service = self.service
chunk_queue = queue.Queue()
request_queue = queue.Queue()
bridge = None
@@ -197,21 +205,29 @@ el.replaceWith(d);
nonlocal history, bridge, agent_instance
try:
# Run the AI interaction (this blocks this specific thread)
res = ai_service.ask(
input_text,
chat_history=history if history else None,
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
**overrides
)
if getattr(service_method, &#34;__name__&#34;, None) == &#34;build_playbook_chat&#34;:
res = service_method(
input_text,
chat_history=history if history else None,
status=bridge,
chunk_callback=callback
)
else:
res = service_method(
input_text,
chat_history=history if history else None,
session_id=session_id,
debug=debug,
status=bridge,
console=bridge,
confirm_handler=bridge.confirm,
chunk_callback=callback,
trust=trust,
**overrides
)
# Update history for next message
if &#34;chat_history&#34; in res:
if res and &#34;chat_history&#34; in res:
history = res[&#34;chat_history&#34;]
# Send final chunk marker
@@ -265,10 +281,10 @@ el.replaceWith(d);
if req.HasField(&#34;engineer_auth&#34;): overrides[&#34;engineer_auth&#34;] = from_struct(req.engineer_auth)
if req.HasField(&#34;architect_auth&#34;): overrides[&#34;architect_auth&#34;] = from_struct(req.architect_auth)
# Start AI in its own thread so we can keep listening for interrupts
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
ctx_ai = contextvars.copy_context()
ai_thread = threading.Thread(
target=run_ai_task,
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
daemon=True
)
ai_thread.start()
@@ -280,8 +296,9 @@ el.replaceWith(d);
# When client closes stream, send sentinel
chunk_queue.put((None, None))
# Start listening for client requests/signals
threading.Thread(target=request_listener, daemon=True).start()
# Start listening for client requests/signals with a copied context
ctx_listener = contextvars.copy_context()
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
# Main response loop (yields to gRPC)
while True:
@@ -305,6 +322,73 @@ el.replaceWith(d);
elif msg_type == &#34;final_mark&#34;:
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
def _handle_unary_stream(self, service_method, *args, **kwargs):
import queue
import threading
chunk_queue = queue.Queue()
bridge = StatusBridge(chunk_queue, is_web=False)
def callback(chunk):
chunk_queue.put((&#34;text&#34;, chunk))
def _worker():
try:
res = service_method(*args, chunk_callback=callback, status=bridge, **kwargs)
chunk_queue.put((&#34;final_mark&#34;, res))
except Exception as e:
import traceback
print(f&#34;gRPC Unary Stream error: {e}&#34;)
traceback.print_exc()
chunk_queue.put((&#34;status&#34;, f&#34;Error: {str(e)}&#34;))
chunk_queue.put((&#34;final_mark&#34;, {&#34;response&#34;: f&#34;Error: {str(e)}&#34;, &#34;error&#34;: True}))
finally:
chunk_queue.put((None, None))
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = chunk_queue.get()
if item == (None, None):
break
msg_type, val = item
if msg_type == &#34;text&#34;:
yield connpy_pb2.AIResponse(text_chunk=val, is_final=False)
elif msg_type == &#34;status&#34;:
clean_val = val.replace(&#34;[ai_status]&#34;, &#34;&#34;).replace(&#34;[/ai_status]&#34;, &#34;&#34;)
yield connpy_pb2.AIResponse(status_update=clean_val, is_final=False)
elif msg_type == &#34;debug&#34;:
yield connpy_pb2.AIResponse(debug_message=val, is_final=False)
elif msg_type == &#34;important&#34;:
yield connpy_pb2.AIResponse(important_message=val, is_final=False)
elif msg_type == &#34;confirm&#34;:
yield connpy_pb2.AIResponse(status_update=val, requires_confirmation=True, is_final=False)
elif msg_type == &#34;final_mark&#34;:
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
@handle_errors
def ask(self, request_iterator, context):
yield from self._handle_chat_stream(request_iterator, context, self.service.ask)
@handle_errors
def build_playbook_chat(self, request_iterator, context):
yield from self._handle_chat_stream(request_iterator, context, self.service.build_playbook_chat)
@handle_errors
def analyze_execution_results(self, request, context):
results = from_struct(request.results)
query = request.query if request.query else None
yield from self._handle_unary_stream(self.service.analyze_execution_results, results, query=query)
@handle_errors
def predict_execution_results(self, request, context):
target_nodes = list(request.target_nodes)
commands = list(request.commands)
yield from self._handle_unary_stream(self.service.predict_execution_results, target_nodes, commands)
@handle_errors
def confirm(self, request, context):
res = self.service.confirm(request.value)
@@ -386,8 +470,10 @@ def service(self):
<ul class="hlist">
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></b></code>:
<ul class="hlist">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
@@ -395,6 +481,7 @@ def service(self):
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results">predict_execution_results</a></code></li>
</ul>
</li>
</ul>
@@ -409,7 +496,7 @@ def service(self):
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = [&#34;/connpy.AuthService/login&#34;]
OPEN_METHODS = [&#34;/connpy.AuthService/login&#34;, &#34;/connpy.AuthService/login_sso&#34;, &#34;/connpy.AuthService/get_sso_providers&#34;]
def __init__(self, registry):
self.registry = registry
@@ -596,7 +683,7 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Invalid username or password&#34;)
token = self.registry.user_service.generate_jwt(username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
@@ -604,6 +691,137 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
expires_at=expires_at
)
@handle_errors
def login_sso(self, request, context):
username = request.username
id_token = request.id_token
provider = request.provider
if not id_token or not provider:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, &#34;id_token and provider are required&#34;)
# Load SSO configuration
sso_config = {}
if self.registry:
shared_config = self.registry.get_shared_config()
if shared_config:
sso_config = shared_config.config.get(&#34;sso&#34;, {})
providers = sso_config.get(&#34;providers&#34;, {})
if provider not in providers:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f&#34;SSO Provider &#39;{provider}&#39; not configured in config.yaml&#34;)
p_config = providers[provider]
jwks_url = p_config.get(&#34;jwks_url&#34;)
secret = p_config.get(&#34;secret&#34;)
if secret and secret.startswith(&#34;$&#34;):
import os
secret = os.getenv(secret[1:])
if not jwks_url and not secret:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f&#34;Provider &#39;{provider}&#39; has no jwks_url or secret configured&#34;)
# Validate token
import jwt
try:
algorithms = p_config.get(&#34;algorithms&#34;, [&#34;RS256&#34;] if jwks_url else [&#34;HS256&#34;])
verify_aud = &#34;audience&#34; in p_config
audience = p_config.get(&#34;audience&#34;)
verify_iss = &#34;issuer&#34; in p_config
issuer = p_config.get(&#34;issuer&#34;)
options = {
&#34;verify_signature&#34;: True,
&#34;verify_exp&#34;: True,
&#34;verify_aud&#34;: verify_aud,
&#34;verify_iss&#34;: verify_iss
}
decode_kwargs = {
&#34;algorithms&#34;: algorithms,
&#34;options&#34;: options
}
if verify_aud:
decode_kwargs[&#34;audience&#34;] = audience
if verify_iss:
decode_kwargs[&#34;issuer&#34;] = issuer
if jwks_url:
from jwt import PyJWKClient
jwks_client = PyJWKClient(jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
payload = jwt.decode(id_token, signing_key.key, **decode_kwargs)
else:
payload = jwt.decode(id_token, secret, **decode_kwargs)
except Exception as e:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;SSO Token validation failed: {str(e)}&#34;)
# Extract username from claim
username_claim = p_config.get(&#34;username_claim&#34;, &#34;sub&#34;)
claim_username = payload.get(username_claim)
if not claim_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Username claim &#39;{username_claim}&#39; not found in SSO Token&#34;)
# Check domain restrictions (allowed_domains)
allowed_domains = p_config.get(&#34;allowed_domains&#34;, [])
if allowed_domains:
email = payload.get(&#34;email&#34;)
if not email and claim_username and &#34;@&#34; in claim_username:
email = claim_username
if not email:
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Domain restriction enabled but no email claim found in SSO Token&#34;)
try:
user_domain = email.split(&#34;@&#34;)[-1].strip().lower()
except Exception:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Invalid email format in SSO Token: &#39;{email}&#39;&#34;)
allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d]
if user_domain not in allowed_domains_lower:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;SSO user domain &#39;{user_domain}&#39; not allowed&#34;)
# Normalize username to alphanumeric/dashes/underscores to match connpy&#39;s username regex
import re
normalized_username = re.sub(r&#39;[^a-zA-Z0-9_-]&#39;, &#39;_&#39;, claim_username.split(&#39;@&#39;)[0])
# If a requested username was sent, verify it matches
if username and username != normalized_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Mismatched username. Expected &#39;{normalized_username}&#39;, got &#39;{username}&#39;&#34;)
# Check if user exists in connpy registry, otherwise auto-provision
try:
user_exists = any(u[&#34;username&#34;] == normalized_username for u in self.registry.user_service.list_users())
if not user_exists:
import secrets
# Provision new user with random password (never used directly)
self.registry.user_service.create_user(normalized_username, secrets.token_hex(32))
except Exception as e:
context.abort(grpc.StatusCode.INTERNAL, f&#34;Failed to auto-provision user: {str(e)}&#34;)
# Generate native connpy JWT token
token = self.registry.user_service.generate_jwt(normalized_username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
username=normalized_username,
expires_at=expires_at
)
@handle_errors
def get_sso_providers(self, request, context):
sso_config = {}
if self.registry:
shared_config = self.registry.get_shared_config()
if shared_config:
sso_config = shared_config.config.get(&#34;sso&#34;, {})
providers = list(sso_config.get(&#34;providers&#34;, {}).keys())
external_providers = [p for p in providers if p != &#34;trusted_gateway&#34;]
return connpy_pb2.SSOProvidersResponse(providers=external_providers)
@handle_errors
def change_password(self, request, context):
username = _current_user.get()
@@ -628,7 +846,9 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
<ul class="hlist">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
</ul>
</li>
</ul>
@@ -785,7 +1005,9 @@ def service(self):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -834,7 +1056,9 @@ def service(self):
finally:
q.put(None)
threading.Thread(target=_worker, daemon=True).start()
import contextvars
ctx = contextvars.copy_context()
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
while True:
item = q.get()
@@ -855,11 +1079,6 @@ def service(self):
@handle_errors
def run_cli_script(self, request, context):
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
return connpy_pb2.StructResponse(data=to_struct(res))
@handle_errors
def run_yaml_playbook(self, request, context):
res = self.service.run_yaml_playbook(request.param1, request.parallel)
return connpy_pb2.StructResponse(data=to_struct(res))</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -888,7 +1107,6 @@ def service(self):
<ul class="hlist">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
</ul>
</li>
+175 -230
View File
@@ -99,8 +99,7 @@ el.replaceWith(d);
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
def _ai_chat_stream(self, stub_method, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, chunk_callback=None, **overrides):
import queue
from rich.prompt import Prompt
from rich.text import Text
@@ -135,7 +134,7 @@ el.replaceWith(d);
if req is None: break
yield req
responses = self.stub.ask(request_generator())
responses = stub_method(request_generator())
full_content = &#34;&#34;
header_printed = False
@@ -234,26 +233,32 @@ el.replaceWith(d);
try: status.stop()
except: pass
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk
alias = &#34;architect&#34; if current_responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if current_responder == &#34;architect&#34; else &#34;Network Engineer&#34;
stable_console.print(Rule(f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;, style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk
alias = &#34;architect&#34; if current_responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if current_responder == &#34;architect&#34; else &#34;Network Engineer&#34;
stable_console.print(Rule(f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;, style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
md_parser.feed(response.text_chunk)
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if header_printed:
if not chunk_callback and header_printed:
from rich.rule import Rule
md_parser.flush()
@@ -262,12 +267,8 @@ el.replaceWith(d);
except: pass
final_result = from_struct(response.full_result)
responder = final_result.get(&#34;responder&#34;, &#34;engineer&#34;)
alias = &#34;architect&#34; if responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
if header_printed:
if not chunk_callback and header_printed:
from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
@@ -286,6 +287,104 @@ el.replaceWith(d);
return final_result
@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)
@handle_errors
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
def _process_unary_stream(self, responses, status=None, chunk_callback=None):
full_content = &#34;&#34;
header_printed = False
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
md_parser = None
try:
for response in responses:
if response.status_update:
if status:
status.update(response.status_update)
continue
if response.important_message:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if status:
try: status.start()
except: pass
continue
if not response.is_final:
if response.text_chunk:
if not header_printed:
if status:
try: status.stop()
except: pass
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print default header
stable_console.print(Rule(&#34;[bold engineer]AI Analysis[/bold engineer]&#34;, style=&#34;engineer&#34;))
header_printed = True
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if md_parser:
md_parser.flush()
if status:
try: status.stop()
except: pass
final_result = from_struct(response.full_result)
if md_parser:
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=&#34;engineer&#34;))
break
except Exception as e:
if isinstance(e, grpc.RpcError):
raise
printer.warning(f&#34;Stream interrupted: {e}&#34;)
if full_content:
final_result[&#34;streamed&#34;] = True
return final_result
@handle_errors
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
req = connpy_pb2.AnalyzeRequest(query=query or &#34;&#34;)
req.results.CopyFrom(to_struct(results))
responses = self.stub.analyze_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)
@handle_errors
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
responses = self.stub.predict_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)
@handle_errors
def confirm(self, input_text, console=None):
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
@@ -333,6 +432,23 @@ el.replaceWith(d);
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.stubs.AIStub.analyze_execution_results"><code class="name flex">
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results, query=None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
req = connpy_pb2.AnalyzeRequest(query=query or &#34;&#34;)
req.results.CopyFrom(to_struct(results))
responses = self.stub.analyze_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AIStub.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>session_id=None,<br>debug=False,<br>status=None,<br>**overrides)</span>
</code></dt>
@@ -343,190 +459,21 @@ el.replaceWith(d);
</summary>
<pre><code class="python">@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
import queue
from rich.prompt import Prompt
from rich.text import Text
from rich.panel import Panel
from rich.markdown import Markdown
req_queue = queue.Queue()
initial_req = connpy_pb2.AskRequest(
input_text=input_text,
dryrun=dryrun,
session_id=session_id or &#34;&#34;,
debug=debug,
engineer_model=overrides.get(&#34;engineer_model&#34;, &#34;&#34;),
engineer_api_key=overrides.get(&#34;engineer_api_key&#34;, &#34;&#34;),
architect_model=overrides.get(&#34;architect_model&#34;, &#34;&#34;),
architect_api_key=overrides.get(&#34;architect_api_key&#34;, &#34;&#34;),
trust=overrides.get(&#34;trust&#34;, False)
)
if chat_history is not None:
initial_req.chat_history.CopyFrom(to_value(chat_history))
if &#34;engineer_auth&#34; in overrides and overrides[&#34;engineer_auth&#34;]:
initial_req.engineer_auth.CopyFrom(to_struct(overrides[&#34;engineer_auth&#34;]))
if &#34;architect_auth&#34; in overrides and overrides[&#34;architect_auth&#34;]:
initial_req.architect_auth.CopyFrom(to_struct(overrides[&#34;architect_auth&#34;]))
req_queue.put(initial_req)
def request_generator():
while True:
req = req_queue.get()
if req is None: break
yield req
responses = self.stub.ask(request_generator())
full_content = &#34;&#34;
header_printed = False
current_responder = &#34;engineer&#34;
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
# Background thread to pull responses from gRPC into a local queue
# This prevents KeyboardInterrupt from corrupting the gRPC iterator state
response_queue = queue.Queue()
def pull_responses():
try:
for response in responses:
response_queue.put((&#34;data&#34;, response))
except Exception as e:
response_queue.put((&#34;error&#34;, e))
finally:
response_queue.put((None, None))
threading.Thread(target=pull_responses, daemon=True).start()
try:
while True:
try:
# BLOCKING GET from local queue (interruptible by signal)
msg_type, response = response_queue.get()
except KeyboardInterrupt:
# Signal interruption to the server
if status:
status.update(&#34;[error]Interrupted! Closing pending tasks...&#34;)
# Send the interrupt signal to the server
req_queue.put(connpy_pb2.AskRequest(interrupt=True))
# CONTINUE the loop to receive remaining data and summary from the queue
continue
if msg_type is None: # Sentinel
break
if msg_type == &#34;error&#34;:
# Re-raise or handle gRPC error from background thread
if isinstance(response, grpc.RpcError):
raise response
printer.warning(f&#34;Stream interrupted: {response}&#34;)
break
if response.status_update:
if response.status_update.startswith(&#34;__RESPONDER__:&#34;):
current_responder = response.status_update.split(&#34;:&#34;)[1].lower()
continue
if response.requires_confirmation:
if status: status.stop()
# Show prompt and wait for answer
prompt_text = Text.from_ansi(response.status_update)
ans = Prompt.ask(prompt_text)
if status:
status.update(&#34;[ai_status]Agent: Resuming...&#34;)
status.start()
req_queue.put(connpy_pb2.AskRequest(confirmation_answer=ans))
continue
if status:
status.update(response.status_update)
continue
if response.debug_message:
if debug:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.debug_message))
if status:
try: status.start()
except: pass
continue
if response.important_message:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if status:
try: status.start()
except: pass
continue
if not response.is_final:
if response.text_chunk:
if not header_printed:
if status:
try: status.stop()
except: pass
from rich.console import Console as RichConsole
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk
alias = &#34;architect&#34; if current_responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if current_responder == &#34;architect&#34; else &#34;Network Engineer&#34;
stable_console.print(Rule(f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;, style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if header_printed:
from rich.rule import Rule
md_parser.flush()
if status:
try: status.stop()
except: pass
final_result = from_struct(response.full_result)
responder = final_result.get(&#34;responder&#34;, &#34;engineer&#34;)
alias = &#34;architect&#34; if responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
if header_printed:
from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias))
break
except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch
if isinstance(e, grpc.RpcError):
raise
printer.warning(f&#34;Stream interrupted: {e}&#34;)
finally:
req_queue.put(None)
if full_content:
final_result[&#34;streamed&#34;] = True
return final_result</code></pre>
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AIStub.build_playbook_chat"><code class="name flex">
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input, chat_history=None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"></div>
</dd>
@@ -644,6 +591,22 @@ def load_session_data(self, session_id):
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AIStub.predict_execution_results"><code class="name flex">
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes, commands, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
responses = self.stub.predict_execution_results(req)
return self._process_unary_stream(responses, status, chunk_callback)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor"><code class="flex name class">
@@ -1124,12 +1087,7 @@ def update_setting(self, key, value):
@handle_errors
def run_cli_script(self, nodes_filter, script_path, parallel=10):
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
return from_struct(self.stub.run_cli_script(req).data)
@handle_errors
def run_yaml_playbook(self, playbook_path, parallel=10):
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
return from_struct(self.stub.run_yaml_playbook(req).data)</code></pre>
return from_struct(self.stub.run_cli_script(req).data)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
@@ -1187,21 +1145,6 @@ def run_commands(self, nodes_filter, commands, variables=None, parallel=10, time
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook"><code class="name flex">
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_path, parallel=10)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def run_yaml_playbook(self, playbook_path, parallel=10):
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
return from_struct(self.stub.run_yaml_playbook(req).data)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.ExecutionStub.test_commands"><code class="name flex">
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter,<br>commands,<br>expected,<br>variables=None,<br>parallel=10,<br>timeout=10,<br>prompt=None,<br>**kwargs)</span>
</code></dt>
@@ -2815,8 +2758,10 @@ def stop_api(self):
<ul>
<li>
<h4><code><a title="connpy.grpc_layer.stubs.AIStub" href="#connpy.grpc_layer.stubs.AIStub">AIStub</a></code></h4>
<ul class="two-column">
<ul class="">
<li><code><a title="connpy.grpc_layer.stubs.AIStub.analyze_execution_results" href="#connpy.grpc_layer.stubs.AIStub.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.ask" href="#connpy.grpc_layer.stubs.AIStub.ask">ask</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.build_playbook_chat" href="#connpy.grpc_layer.stubs.AIStub.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_mcp" href="#connpy.grpc_layer.stubs.AIStub.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_provider" href="#connpy.grpc_layer.stubs.AIStub.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.confirm" href="#connpy.grpc_layer.stubs.AIStub.confirm">confirm</a></code></li>
@@ -2824,6 +2769,7 @@ def stop_api(self):
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_mcp_servers" href="#connpy.grpc_layer.stubs.AIStub.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_sessions" href="#connpy.grpc_layer.stubs.AIStub.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.load_session_data" href="#connpy.grpc_layer.stubs.AIStub.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.predict_execution_results" href="#connpy.grpc_layer.stubs.AIStub.predict_execution_results">predict_execution_results</a></code></li>
</ul>
</li>
<li>
@@ -2857,7 +2803,6 @@ def stop_api(self):
<ul class="">
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_cli_script" href="#connpy.grpc_layer.stubs.ExecutionStub.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook" href="#connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.test_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.test_commands">test_commands</a></code></li>
</ul>
</li>
+23
View File
@@ -143,6 +143,12 @@ el.replaceWith(d);
def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())
def get_shared_config(self):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
with self._lock:
self._refresh_shared()
return self._shared_config
def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
@@ -244,6 +250,22 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config"><code class="name flex">
<span>def <span class="ident">get_shared_config</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_shared_config(self):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
with self._lock:
self._refresh_shared()
return self._shared_config</code></pre>
</details>
<div class="desc"><p>Thread-safe access to the hot-reloaded shared configuration.</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
<span>def <span class="ident">has_users</span></span>(<span>self) > bool</span>
</code></dt>
@@ -280,6 +302,7 @@ el.replaceWith(d);
<ul class="">
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.evict" href="#connpy.grpc_layer.user_registry.UserRegistry.evict">evict</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_provider" href="#connpy.grpc_layer.user_registry.UserRegistry.get_provider">get_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config" href="#connpy.grpc_layer.user_registry.UserRegistry.get_shared_config">get_shared_config</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.has_users" href="#connpy.grpc_layer.user_registry.UserRegistry.has_users">has_users</a></code></li>
</ul>
</li>
+193 -105
View File
@@ -125,6 +125,24 @@ conn ai
# Run a command on all nodes in a folder
conn run @office &quot;uptime&quot;
</code></pre>
<h3 id="sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</h3>
<p>In remote mode, <code><a title="connpy" href="#connpy">connpy</a></code> supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the <code>conn sso</code> command suite:</p>
<ul>
<li><strong>List configured providers</strong>:
<code>bash
conn sso --list</code></li>
<li><strong>Show provider details</strong> (sensitive credentials like secrets are masked):
<code>bash
conn sso --show &lt;provider_name&gt;</code></li>
<li><strong>Add or update a provider</strong> (opens an interactive configuration wizard):
<code>bash
conn sso --add &lt;provider_name&gt;</code></li>
<li><strong>Delete a provider</strong>:
<code>bash
conn sso --del &lt;provider_name&gt;</code></li>
</ul>
<h4 id="security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</h4>
<p>To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a <code>$</code> instead of the literal secret during the <code>conn sso --add</code> prompts (e.g., <code>$CONN_SSO_MYPROVIDER_SECRET</code>). The backend gRPC server will dynamically resolve the value from its environment variables at runtime.</p>
<hr>
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
@@ -185,6 +203,8 @@ response = myai.ask(&quot;What is the status of the BGP neighbors in the office?
</code></pre>
<hr>
<p><em>For detailed developer notes and plugin hooks documentation, see the <a href="https://fluzzi.github.io/connpy/">Documentation</a>.</em></p>
<h2 id="license">📜 License</h2>
<p><a href="LICENSE">PolyForm Noncommercial 1.0.0</a></p>
</section>
<section>
<h2 class="section-title" id="header-submodules">Sub-modules</h2>
@@ -644,9 +664,10 @@ class ai:
self.confirm_handler = confirm_handler or self._local_confirm_handler
self.trusted_session = trust # Trust mode for the entire session
self.interrupted = False
self.one_shot = kwargs.get(&#34;one_shot&#34;, False)
# 1. Cargar configuración genérica con herencia/merge global
# 1. Load generic configuration with global inheritance/merge
if hasattr(self.config, &#34;get_effective_setting&#34;):
aiconfig = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
@@ -689,7 +710,7 @@ class ai:
custom_trusted = [c.strip() for c in custom_trusted.split(&#34;,&#34;) if c.strip()]
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
# Límites
# Limits
self.max_history = 30
self.max_truncate = 50000
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
@@ -726,7 +747,7 @@ class ai:
self.session_id = getattr(self.config, &#34;session_id&#34;, None)
self.session_path = os.path.join(self.sessions_dir, f&#34;{self.session_id}.json&#34;) if self.session_id else None
# Prompts base agnósticos
# Agnostic base prompts
architect_instructions = &#34;&#34;
if self.has_architect:
architect_instructions = &#34;&#34;&#34;
@@ -815,10 +836,13 @@ class ai:
@property
def architect_system_prompt(self):
&#34;&#34;&#34;Build architect system prompt with plugin extensions.&#34;&#34;&#34;
prompt = self._architect_base_prompt
if getattr(self, &#34;one_shot&#34;, False):
prompt += &#34;\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer.&#34;
if self.architect_prompt_extensions:
extensions = &#34;\n&#34;.join(self.architect_prompt_extensions)
return self._architect_base_prompt + f&#34;\n\nPlugin Capabilities:\n{extensions}&#34;
return self._architect_base_prompt
return prompt + f&#34;\n\nPlugin Capabilities:\n{extensions}&#34;
return prompt
def register_ai_tool(self, tool_definition, handler, target=&#34;engineer&#34;, engineer_prompt=None, architect_prompt=None, status_formatter=None):
&#34;&#34;&#34;Register an external tool for the AI system.
@@ -1263,7 +1287,7 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
&#34;&#34;&#34;Internal loop where the Engineer executes technical tasks for the Architect.&#34;&#34;&#34;
# Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
# Cache optimization for the Engineer (Only for direct Anthropic, Vertex has different rules)
if &#34;claude&#34; in self.engineer_model.lower() and &#34;vertex&#34; not in self.engineer_model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: self.engineer_system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
@@ -1295,13 +1319,11 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f&#34;[warning]⚠ Engineer has performed {iteration} steps. This is taking longer than expected.[/warning]&#34;)
self.console.print(f&#34;[warning] You can press Ctrl+C to interrupt and get a summary.[/warning]&#34;)
soft_limit_warned = True
if status and not chat_history: status.update(f&#34;[ai_status]Engineer: Analyzing mission... (step {iteration})&#34;)
if status and not chat_history:
status_text = f&#34;[ai_status]Engineer: Analyzing mission... (step {iteration})&#34;
if iteration &gt;= self.soft_limit_iterations:
status_text += &#34; [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]&#34;
status.update(status_text)
try:
safe_messages = self._sanitize_messages(messages)
@@ -1324,19 +1346,25 @@ class ai:
for tc in resp_msg.tool_calls:
fn, args = tc.function.name, json.loads(tc.function.arguments)
# Notificación en tiempo real de la tarea técnica (Only if not in Architect loop)
# Real-time notification of the technical task (Only if not in Architect loop)
if status and not chat_history:
if fn == &#34;list_nodes&#34;: status.update(f&#34;[ai_status]Engineer: [SEARCH] {args.get(&#39;filter_pattern&#39;,&#39;.*&#39;)}&#34;)
s_text = &#34;&#34;
if fn == &#34;list_nodes&#34;: s_text = f&#34;[ai_status]Engineer: [SEARCH] {args.get(&#39;filter_pattern&#39;,&#39;.*&#39;)}&#34;
elif fn == &#34;run_commands&#34;:
cmds = args.get(&#39;commands&#39;, [])
cmd_str = cmds[0] if cmds else &#34;&#34;
status.update(f&#34;[ai_status]Engineer: [CMD] {cmd_str}&#34;)
elif fn == &#34;get_node_info&#34;: status.update(f&#34;[ai_status]Engineer: [INSPECT] {args.get(&#39;node_name&#39;,&#39;&#39;)}&#34;)
s_text = f&#34;[ai_status]Engineer: [CMD] {cmd_str}&#34;
elif fn == &#34;get_node_info&#34;: s_text = f&#34;[ai_status]Engineer: [INSPECT] {args.get(&#39;node_name&#39;,&#39;&#39;)}&#34;
elif fn.startswith(&#34;mcp_&#34;):
server = fn.split(&#34;__&#34;)[0].replace(&#34;mcp_&#34;, &#34;&#34;)
tool = fn.split(&#34;__&#34;)[1] if &#34;__&#34; in fn else fn
status.update(f&#34;[ai_status]Engineer: [MCP:{server}] {tool}&#34;)
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
s_text = f&#34;[ai_status]Engineer: [MCP:{server}] {tool}&#34;
elif fn in self.tool_status_formatters: s_text = self.tool_status_formatters[fn](args)
if s_text:
if iteration &gt;= self.soft_limit_iterations:
s_text += &#34; [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]&#34;
status.update(s_text)
if debug:
self._print_debug_observation(f&#34;Decision: {fn}&#34;, args, status=status)
@@ -1406,6 +1434,8 @@ class ai:
{&#34;type&#34;: &#34;function&#34;, &#34;function&#34;: {&#34;name&#34;: &#34;return_to_engineer&#34;, &#34;description&#34;: &#34;Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.&#34;, &#34;parameters&#34;: {&#34;type&#34;: &#34;object&#34;, &#34;properties&#34;: {&#34;summary&#34;: {&#34;type&#34;: &#34;string&#34;, &#34;description&#34;: &#34;Brief summary of your analysis to hand over to the Engineer.&#34;}}, &#34;required&#34;: [&#34;summary&#34;]}}},
{&#34;type&#34;: &#34;function&#34;, &#34;function&#34;: {&#34;name&#34;: &#34;manage_memory_tool&#34;, &#34;description&#34;: &#34;Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.&#34;, &#34;parameters&#34;: {&#34;type&#34;: &#34;object&#34;, &#34;properties&#34;: {&#34;content&#34;: {&#34;type&#34;: &#34;string&#34;}, &#34;action&#34;: {&#34;type&#34;: &#34;string&#34;, &#34;enum&#34;: [&#34;append&#34;, &#34;replace&#34;]}}, &#34;required&#34;: [&#34;content&#34;]}}}
]
if getattr(self, &#34;one_shot&#34;, False):
base_tools = [t for t in base_tools if t[&#34;function&#34;][&#34;name&#34;] not in (&#34;delegate_to_engineer&#34;, &#34;return_to_engineer&#34;)]
all_tools = base_tools + self.external_architect_tools
seen_names = set()
@@ -1541,11 +1571,18 @@ class ai:
@MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
def update_status(text):
if not status:
return
if iteration &gt;= self.soft_limit_iterations:
warning_suffix = &#34; [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]&#34;
if warning_suffix not in text:
text += warning_suffix
status.update(text)
if chat_history is None: chat_history = []
@@ -1564,7 +1601,7 @@ class ai:
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain)
# 1. Initial Role Selector (Sticky Brain)
explicit_architect = re.match(r&#39;^(architect|arquitecto|@architect)[:\s]&#39;, user_input, re.I)
explicit_engineer = re.match(r&#39;^(engineer|ingeniero|@engineer)[:\s]&#39;, user_input, re.I)
@@ -1573,7 +1610,7 @@ class ai:
elif explicit_engineer:
current_brain = &#34;engineer&#34;
else:
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
# Sticky Brain: Detect if the Architect was in control in recent history
is_architect_active = False
for msg in reversed(chat_history[-5:]):
tcs = msg.get(&#39;tool_calls&#39;) if isinstance(msg, dict) else getattr(msg, &#39;tool_calls&#39;, None)
@@ -1587,7 +1624,7 @@ class ai:
if is_architect_active: break
current_brain = &#34;architect&#34; if is_architect_active else &#34;engineer&#34;
# 2. Preparación de mensajes y limpieza
# 2. Message preparation and cleaning
clean_input = re.sub(r&#39;^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+&#39;, &#39;&#39;, user_input, flags=re.IGNORECASE).strip()
system_prompt = self.architect_system_prompt if current_brain == &#34;architect&#34; else self.engineer_system_prompt
@@ -1596,13 +1633,13 @@ class ai:
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
current_auth = self.architect_auth if current_brain == &#34;architect&#34; else self.engineer_auth
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
if &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial
# History interleaving
last_role = &#34;system&#34;
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -1622,7 +1659,7 @@ class ai:
if last_role == &#39;user&#39;: messages[-1][&#39;content&#39;] += &#34;\n&#34; + clean_input
else: messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -1636,18 +1673,14 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f&#34;[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]&#34;)
self.console.print(f&#34;[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]&#34;)
soft_limit_warned = True
# Soft limit warning - handled inline within update_status
label = &#34;[architect][bold]Architect[/bold][/architect]&#34; if current_brain == &#34;architect&#34; else &#34;[engineer][bold]Engineer[/bold][/engineer]&#34;
if status:
# Notify responder identity for web/remote clients
if getattr(status, &#34;is_web&#34;, False) or getattr(status, &#34;is_remote&#34;, False):
status.update(f&#34;__RESPONDER__:{current_brain}&#34;)
status.update(f&#34;{label} is thinking... (step {iteration})&#34;)
update_status(f&#34;{label} is thinking... (step {iteration})&#34;)
streamed_response = False
try:
@@ -1662,7 +1695,7 @@ class ai:
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
except Exception as e:
if current_brain == &#34;architect&#34;:
if status: status.update(&#34;[unavailable]Architect unavailable! Falling back to Engineer...&#34;)
if status: update_status(&#34;[unavailable]Architect unavailable! Falling back to Engineer...&#34;)
# Preserve context when falling back - use clean_input directly
current_brain = &#34;engineer&#34;
model = self.engineer_model
@@ -1719,8 +1752,8 @@ class ai:
continue
if status:
if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[architect]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[architect]Architect: [UPDATING MEMORY]&#34;)
if fn == &#34;delegate_to_engineer&#34;: update_status(f&#34;[architect]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: update_status(f&#34;[architect]Architect: [UPDATING MEMORY]&#34;)
if debug:
self._print_debug_observation(f&#34;Decision: {fn}&#34;, args, status=status)
@@ -1729,7 +1762,7 @@ class ai:
obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1])
usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;]
elif fn == &#34;consult_architect&#34;:
if status: status.update(&#34;[architect]Engineer consulting Architect...&#34;)
if status: update_status(&#34;[architect]Engineer consulting Architect...&#34;)
try:
# Consultation only - Engineer stays in control
claude_resp = completion(
@@ -1751,11 +1784,11 @@ class ai:
try: status.start()
except: pass
except Exception as e:
if status: status.update(&#34;[unavailable]Architect unavailable! Engineer continuing alone...&#34;)
if status: update_status(&#34;[unavailable]Architect unavailable! Engineer continuing alone...&#34;)
obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34;
elif fn == &#34;escalate_to_architect&#34;:
if status: status.update(&#34;[architect]Transferring control to Architect...&#34;)
if status: update_status(&#34;[architect]Transferring control to Architect...&#34;)
# Full escalation - Architect takes over
current_brain = &#34;architect&#34;
model = self.architect_model
@@ -1777,7 +1810,7 @@ class ai:
except: pass
elif fn == &#34;return_to_engineer&#34;:
if status: status.update(&#34;[engineer]Transferring control back to Engineer...&#34;)
if status: update_status(&#34;[engineer]Transferring control back to Engineer...&#34;)
# Architect returns control to Engineer
current_brain = &#34;engineer&#34;
model = self.engineer_model
@@ -1830,7 +1863,7 @@ class ai:
messages.append(resp_msg.model_dump(exclude_none=True))
except Exception as e:
if status:
status.update(f&#34;[error]Error fetching summary: {e}[/error]&#34;)
update_status(f&#34;[error]Error fetching summary: {e}[/error]&#34;)
printer.warning(f&#34;Failed to fetch final summary from LLM: {e}&#34;)
except KeyboardInterrupt:
if status: status.update(&#34;[error]Interrupted! Closing pending tasks...&#34;)
@@ -2167,10 +2200,13 @@ Node: {node_name}&#34;&#34;&#34;
<pre><code class="python">@property
def architect_system_prompt(self):
&#34;&#34;&#34;Build architect system prompt with plugin extensions.&#34;&#34;&#34;
prompt = self._architect_base_prompt
if getattr(self, &#34;one_shot&#34;, False):
prompt += &#34;\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer.&#34;
if self.architect_prompt_extensions:
extensions = &#34;\n&#34;.join(self.architect_prompt_extensions)
return self._architect_base_prompt + f&#34;\n\nPlugin Capabilities:\n{extensions}&#34;
return self._architect_base_prompt</code></pre>
return prompt + f&#34;\n\nPlugin Capabilities:\n{extensions}&#34;
return prompt</code></pre>
</details>
<div class="desc"><p>Build architect system prompt with plugin extensions.</p></div>
</dd>
@@ -2488,11 +2524,18 @@ Node: {node_name}&#34;&#34;&#34;
</summary>
<pre><code class="python">@MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
def update_status(text):
if not status:
return
if iteration &gt;= self.soft_limit_iterations:
warning_suffix = &#34; [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]&#34;
if warning_suffix not in text:
text += warning_suffix
status.update(text)
if chat_history is None: chat_history = []
@@ -2511,7 +2554,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain)
# 1. Initial Role Selector (Sticky Brain)
explicit_architect = re.match(r&#39;^(architect|arquitecto|@architect)[:\s]&#39;, user_input, re.I)
explicit_engineer = re.match(r&#39;^(engineer|ingeniero|@engineer)[:\s]&#39;, user_input, re.I)
@@ -2520,7 +2563,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
elif explicit_engineer:
current_brain = &#34;engineer&#34;
else:
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
# Sticky Brain: Detect if the Architect was in control in recent history
is_architect_active = False
for msg in reversed(chat_history[-5:]):
tcs = msg.get(&#39;tool_calls&#39;) if isinstance(msg, dict) else getattr(msg, &#39;tool_calls&#39;, None)
@@ -2534,7 +2577,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
if is_architect_active: break
current_brain = &#34;architect&#34; if is_architect_active else &#34;engineer&#34;
# 2. Preparación de mensajes y limpieza
# 2. Message preparation and cleaning
clean_input = re.sub(r&#39;^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+&#39;, &#39;&#39;, user_input, flags=re.IGNORECASE).strip()
system_prompt = self.architect_system_prompt if current_brain == &#34;architect&#34; else self.engineer_system_prompt
@@ -2543,13 +2586,13 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
current_auth = self.architect_auth if current_brain == &#34;architect&#34; else self.engineer_auth
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
if &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial
# History interleaving
last_role = &#34;system&#34;
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -2569,7 +2612,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
if last_role == &#39;user&#39;: messages[-1][&#39;content&#39;] += &#34;\n&#34; + clean_input
else: messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -2583,18 +2626,14 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f&#34;[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]&#34;)
self.console.print(f&#34;[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]&#34;)
soft_limit_warned = True
# Soft limit warning - handled inline within update_status
label = &#34;[architect][bold]Architect[/bold][/architect]&#34; if current_brain == &#34;architect&#34; else &#34;[engineer][bold]Engineer[/bold][/engineer]&#34;
if status:
# Notify responder identity for web/remote clients
if getattr(status, &#34;is_web&#34;, False) or getattr(status, &#34;is_remote&#34;, False):
status.update(f&#34;__RESPONDER__:{current_brain}&#34;)
status.update(f&#34;{label} is thinking... (step {iteration})&#34;)
update_status(f&#34;{label} is thinking... (step {iteration})&#34;)
streamed_response = False
try:
@@ -2609,7 +2648,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
except Exception as e:
if current_brain == &#34;architect&#34;:
if status: status.update(&#34;[unavailable]Architect unavailable! Falling back to Engineer...&#34;)
if status: update_status(&#34;[unavailable]Architect unavailable! Falling back to Engineer...&#34;)
# Preserve context when falling back - use clean_input directly
current_brain = &#34;engineer&#34;
model = self.engineer_model
@@ -2666,8 +2705,8 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
continue
if status:
if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[architect]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[architect]Architect: [UPDATING MEMORY]&#34;)
if fn == &#34;delegate_to_engineer&#34;: update_status(f&#34;[architect]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: update_status(f&#34;[architect]Architect: [UPDATING MEMORY]&#34;)
if debug:
self._print_debug_observation(f&#34;Decision: {fn}&#34;, args, status=status)
@@ -2676,7 +2715,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1])
usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;]
elif fn == &#34;consult_architect&#34;:
if status: status.update(&#34;[architect]Engineer consulting Architect...&#34;)
if status: update_status(&#34;[architect]Engineer consulting Architect...&#34;)
try:
# Consultation only - Engineer stays in control
claude_resp = completion(
@@ -2698,11 +2737,11 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
try: status.start()
except: pass
except Exception as e:
if status: status.update(&#34;[unavailable]Architect unavailable! Engineer continuing alone...&#34;)
if status: update_status(&#34;[unavailable]Architect unavailable! Engineer continuing alone...&#34;)
obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34;
elif fn == &#34;escalate_to_architect&#34;:
if status: status.update(&#34;[architect]Transferring control to Architect...&#34;)
if status: update_status(&#34;[architect]Transferring control to Architect...&#34;)
# Full escalation - Architect takes over
current_brain = &#34;architect&#34;
model = self.architect_model
@@ -2724,7 +2763,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
except: pass
elif fn == &#34;return_to_engineer&#34;:
if status: status.update(&#34;[engineer]Transferring control back to Engineer...&#34;)
if status: update_status(&#34;[engineer]Transferring control back to Engineer...&#34;)
# Architect returns control to Engineer
current_brain = &#34;engineer&#34;
model = self.engineer_model
@@ -2777,7 +2816,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
messages.append(resp_msg.model_dump(exclude_none=True))
except Exception as e:
if status:
status.update(f&#34;[error]Error fetching summary: {e}[/error]&#34;)
update_status(f&#34;[error]Error fetching summary: {e}[/error]&#34;)
printer.warning(f&#34;Failed to fetch final summary from LLM: {e}&#34;)
except KeyboardInterrupt:
if status: status.update(&#34;[error]Interrupted! Closing pending tasks...&#34;)
@@ -4757,12 +4796,12 @@ class node:
# Get raw bytes from BytesIO
raw_bytes = self.mylog.getvalue()
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
# tenga control exclusivo del stdin sin interferencias de LocalStream.
# Stop terminal reading so prompt_toolkit (in run_session)
# has exclusive control of stdin without LocalStream interference.
if hasattr(stream, &#39;stop_reading&#39;):
stream.stop_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
# Fallback si no tiene el método (en LocalStream)
# Fallback if the method is missing (in LocalStream)
stream._loop.remove_reader(stream.stdin_fd)
try:
@@ -4779,7 +4818,7 @@ class node:
break
finally:
print(&#34;\033[2m Returning to session...\033[0m&#34;, flush=True)
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
# Restart terminal reading to return to interactive SSH/Telnet mode
if hasattr(stream, &#39;start_reading&#39;):
stream.start_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
@@ -4847,14 +4886,6 @@ class node:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -4875,6 +4906,20 @@ class node:
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
# Only set terminal size on devices without a
# screen_length_command (e.g. Linux/bash servers).
# Routers already disable pagination via that command.
# After setwinsize, consume any SIGWINCH re-render
# prompt (~40ms on bash) with a short timeout.
if c == commands[0] and &#34;screen_length_command&#34; not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -4957,14 +5002,6 @@ class node:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -4986,6 +5023,15 @@ class node:
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -5011,13 +5057,28 @@ class node:
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = output
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2:
@@ -5425,14 +5486,6 @@ def run(self, commands, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -5453,6 +5506,20 @@ def run(self, commands, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
# Only set terminal size on devices without a
# screen_length_command (e.g. Linux/bash servers).
# Routers already disable pagination via that command.
# After setwinsize, consume any SIGWINCH re-render
# prompt (~40ms on bash) with a short timeout.
if c == commands[0] and &#34;screen_length_command&#34; not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -5576,14 +5643,6 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Attempt to set the terminal size
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -5605,6 +5664,15 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; not in self.tags:
try:
self.child.setwinsize(65535, 65535)
except Exception:
try:
self.child.setwinsize(10000, 10000)
except Exception:
pass
self.child.expect(expects, timeout = 1)
self.child.sendline(c)
if result == 2:
break
@@ -5630,13 +5698,28 @@ def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt =
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = output
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
else:
self.result[e]= False
try:
if re.search(e, cleaned_output):
self.result[e] = True
else:
self.result[e] = False
except re.error:
self.result[e] = False
self.status = 0
return self.result
if result == 2:
@@ -6368,6 +6451,10 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
</li>
<li><a href="#usage">Usage</a><ul>
<li><a href="#basic-examples">Basic Examples:</a></li>
<li><a href="#sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</a><ul>
<li><a href="#security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
@@ -6384,6 +6471,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
<li><a href="#ai-programmatic-use">AI Programmatic Use</a></li>
</ul>
</li>
<li><a href="#license">📜 License</a></li>
</ul>
</li>
</ul>
+102 -1
View File
@@ -369,7 +369,41 @@ el.replaceWith(d);
&#34;&#34;&#34;Load a session&#39;s raw data by ID.&#34;&#34;&#34;
from connpy.ai import ai
agent = ai(self.config)
return agent.load_session_data(session_id)</code></pre>
return agent.load_session_data(session_id)
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
from connpy.ai import PlaybookBuilderAgent
agent = PlaybookBuilderAgent(self.config)
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; prefix, forcing 1-shot architect brain.
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact.&#34;
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
<p>Initialize the service.</p>
@@ -402,6 +436,31 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.analyze_execution_results"><code class="name flex">
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results: dict, query: str = None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; prefix, forcing 1-shot architect brain.
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)</code></pre>
</details>
<div class="desc"><p>Analyze actual command execution results using Network Architect 1-shot.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
</code></dt>
@@ -559,6 +618,22 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.build_playbook_chat"><code class="name flex">
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input: str, chat_history: list = None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
from connpy.ai import PlaybookBuilderAgent
agent = PlaybookBuilderAgent(self.config)
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Interact with the specialized Playbook Builder Agent.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.configure_mcp"><code class="name flex">
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
</code></dt>
@@ -715,6 +790,29 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Load a session's raw data by ID.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.predict_execution_results"><code class="name flex">
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes: list, commands: list, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact.&#34;
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.process_copilot_input"><code class="name flex">
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) > dict</span>
</code></dt>
@@ -813,9 +911,11 @@ el.replaceWith(d);
<h4><code><a title="connpy.services.ai_service.AIService" href="#connpy.services.ai_service.AIService">AIService</a></code></h4>
<ul class="">
<li><code><a title="connpy.services.ai_service.AIService.aask_copilot" href="#connpy.services.ai_service.AIService.aask_copilot">aask_copilot</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.analyze_execution_results" href="#connpy.services.ai_service.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.ask" href="#connpy.services.ai_service.AIService.ask">ask</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.ask_copilot" href="#connpy.services.ai_service.AIService.ask_copilot">ask_copilot</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.build_context_blocks" href="#connpy.services.ai_service.AIService.build_context_blocks">build_context_blocks</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.build_playbook_chat" href="#connpy.services.ai_service.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.configure_mcp" href="#connpy.services.ai_service.AIService.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.configure_provider" href="#connpy.services.ai_service.AIService.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.confirm" href="#connpy.services.ai_service.AIService.confirm">confirm</a></code></li>
@@ -823,6 +923,7 @@ el.replaceWith(d);
<li><code><a title="connpy.services.ai_service.AIService.list_mcp_servers" href="#connpy.services.ai_service.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.list_sessions" href="#connpy.services.ai_service.AIService.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.load_session_data" href="#connpy.services.ai_service.AIService.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.predict_execution_results" href="#connpy.services.ai_service.AIService.predict_execution_results">predict_execution_results</a></code></li>
<li><code><a title="connpy.services.ai_service.AIService.process_copilot_input" href="#connpy.services.ai_service.AIService.process_copilot_input">process_copilot_input</a></code></li>
</ul>
</li>
+1 -110
View File
@@ -156,56 +156,7 @@ el.replaceWith(d);
except Exception as e:
raise ConnpyError(f&#34;Failed to read script {script_path}: {e}&#34;)
return self.run_commands(nodes_filter, commands, parallel=parallel)
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</code></pre>
return self.run_commands(nodes_filter, commands, parallel=parallel)</code></pre>
</details>
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
<p>Initialize the service.</p>
@@ -300,65 +251,6 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
</dd>
<dt id="connpy.services.execution_service.ExecutionService.run_yaml_playbook"><code class="name flex">
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_data: str, parallel: int = 10) > Dict[str, Any]</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</code></pre>
</details>
<div class="desc"><p>Run a structured Connpy YAML automation playbook (from path or content).</p></div>
</dd>
<dt id="connpy.services.execution_service.ExecutionService.test_commands"><code class="name flex">
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) > Dict[str, Dict[str, bool]]</span>
</code></dt>
@@ -439,7 +331,6 @@ el.replaceWith(d);
<ul class="">
<li><code><a title="connpy.services.execution_service.ExecutionService.run_cli_script" href="#connpy.services.execution_service.ExecutionService.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.services.execution_service.ExecutionService.run_commands" href="#connpy.services.execution_service.ExecutionService.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.services.execution_service.ExecutionService.run_yaml_playbook" href="#connpy.services.execution_service.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.services.execution_service.ExecutionService.test_commands" href="#connpy.services.execution_service.ExecutionService.test_commands">test_commands</a></code></li>
</ul>
</li>
+103 -111
View File
@@ -428,7 +428,41 @@ el.replaceWith(d);
&#34;&#34;&#34;Load a session&#39;s raw data by ID.&#34;&#34;&#34;
from connpy.ai import ai
agent = ai(self.config)
return agent.load_session_data(session_id)</code></pre>
return agent.load_session_data(session_id)
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
from connpy.ai import PlaybookBuilderAgent
agent = PlaybookBuilderAgent(self.config)
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; prefix, forcing 1-shot architect brain.
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact.&#34;
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
<p>Initialize the service.</p>
@@ -461,6 +495,31 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
</dd>
<dt id="connpy.services.AIService.analyze_execution_results"><code class="name flex">
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results: dict, query: str = None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; prefix, forcing 1-shot architect brain.
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)</code></pre>
</details>
<div class="desc"><p>Analyze actual command execution results using Network Architect 1-shot.</p></div>
</dd>
<dt id="connpy.services.AIService.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
</code></dt>
@@ -618,6 +677,22 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
</dd>
<dt id="connpy.services.AIService.build_playbook_chat"><code class="name flex">
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input: str, chat_history: list = None, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
from connpy.ai import PlaybookBuilderAgent
agent = PlaybookBuilderAgent(self.config)
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Interact with the specialized Playbook Builder Agent.</p></div>
</dd>
<dt id="connpy.services.AIService.configure_mcp"><code class="name flex">
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
</code></dt>
@@ -774,6 +849,29 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Load a session's raw data by ID.</p></div>
</dd>
<dt id="connpy.services.AIService.predict_execution_results"><code class="name flex">
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes: list, commands: list, status=None, chunk_callback=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact.&#34;
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
</details>
<div class="desc"><p>Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).</p></div>
</dd>
<dt id="connpy.services.AIService.process_copilot_input"><code class="name flex">
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) > dict</span>
</code></dt>
@@ -1255,56 +1353,7 @@ el.replaceWith(d);
except Exception as e:
raise ConnpyError(f&#34;Failed to read script {script_path}: {e}&#34;)
return self.run_commands(nodes_filter, commands, parallel=parallel)
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</code></pre>
return self.run_commands(nodes_filter, commands, parallel=parallel)</code></pre>
</details>
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
<p>Initialize the service.</p>
@@ -1399,65 +1448,6 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
</dd>
<dt id="connpy.services.ExecutionService.run_yaml_playbook"><code class="name flex">
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_data: str, parallel: int = 10) > Dict[str, Any]</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</code></pre>
</details>
<div class="desc"><p>Run a structured Connpy YAML automation playbook (from path or content).</p></div>
</dd>
<dt id="connpy.services.ExecutionService.test_commands"><code class="name flex">
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) > Dict[str, Dict[str, bool]]</span>
</code></dt>
@@ -4006,9 +3996,11 @@ el.replaceWith(d);
<h4><code><a title="connpy.services.AIService" href="#connpy.services.AIService">AIService</a></code></h4>
<ul class="">
<li><code><a title="connpy.services.AIService.aask_copilot" href="#connpy.services.AIService.aask_copilot">aask_copilot</a></code></li>
<li><code><a title="connpy.services.AIService.analyze_execution_results" href="#connpy.services.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
<li><code><a title="connpy.services.AIService.ask" href="#connpy.services.AIService.ask">ask</a></code></li>
<li><code><a title="connpy.services.AIService.ask_copilot" href="#connpy.services.AIService.ask_copilot">ask_copilot</a></code></li>
<li><code><a title="connpy.services.AIService.build_context_blocks" href="#connpy.services.AIService.build_context_blocks">build_context_blocks</a></code></li>
<li><code><a title="connpy.services.AIService.build_playbook_chat" href="#connpy.services.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
<li><code><a title="connpy.services.AIService.configure_mcp" href="#connpy.services.AIService.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.services.AIService.configure_provider" href="#connpy.services.AIService.configure_provider">configure_provider</a></code></li>
<li><code><a title="connpy.services.AIService.confirm" href="#connpy.services.AIService.confirm">confirm</a></code></li>
@@ -4016,6 +4008,7 @@ el.replaceWith(d);
<li><code><a title="connpy.services.AIService.list_mcp_servers" href="#connpy.services.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
<li><code><a title="connpy.services.AIService.list_sessions" href="#connpy.services.AIService.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.services.AIService.load_session_data" href="#connpy.services.AIService.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.services.AIService.predict_execution_results" href="#connpy.services.AIService.predict_execution_results">predict_execution_results</a></code></li>
<li><code><a title="connpy.services.AIService.process_copilot_input" href="#connpy.services.AIService.process_copilot_input">process_copilot_input</a></code></li>
</ul>
</li>
@@ -4041,7 +4034,6 @@ el.replaceWith(d);
<ul class="">
<li><code><a title="connpy.services.ExecutionService.run_cli_script" href="#connpy.services.ExecutionService.run_cli_script">run_cli_script</a></code></li>
<li><code><a title="connpy.services.ExecutionService.run_commands" href="#connpy.services.ExecutionService.run_commands">run_commands</a></code></li>
<li><code><a title="connpy.services.ExecutionService.run_yaml_playbook" href="#connpy.services.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
<li><code><a title="connpy.services.ExecutionService.test_commands" href="#connpy.services.ExecutionService.test_commands">test_commands</a></code></li>
</ul>
</li>
+13 -9
View File
@@ -256,18 +256,19 @@ el.replaceWith(d);
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))
def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#34;&#34;&#34;
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
@@ -277,7 +278,8 @@ el.replaceWith(d);
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
@@ -468,24 +470,25 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
<span>Expand source code</span>
</summary>
<pre><code class="python">def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#34;&#34;&#34;
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token</code></pre>
</details>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 8 hours.</p></div>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 12 hours.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
<span>def <span class="ident">get_user</span></span>(<span>self, username) > dict</span>
@@ -545,7 +548,8 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
+1 -1
View File
@@ -8,7 +8,7 @@ keywords = networking, automation, docker, kubernetes, ssh, telnet, connection m
author = Federico Luzzi
author_email = fluzzi@gmail.com
url = https://github.com/fluzzi/connpy
license = Custom Software License
license = PolyForm Noncommercial License 1.0.0
license_files = LICENSE
project_urls =
Bug Tracker = https://github.com/fluzzi/connpy/issues