Add SecureConsole v3.0 with password authentication

- SecureConsole with Argon2id password hashing
- Password stored in ~/.xml-pipeline/console.key
- Protected commands require password re-entry
- Attach/detach model with idle timeout
- Fallback to simple input for incompatible terminals
- @listener message injection into pump
- Boot handler no longer sends to old console
- Response handler prints to stdout with prompt refresh

Dependencies: argon2-cffi, prompt_toolkit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dullfig 2026-01-10 18:24:24 -08:00
parent 379f5a0258
commit f75aa3eee6
8 changed files with 809 additions and 80 deletions

View file

@ -0,0 +1,10 @@
"""
console Secure console interface for organism operators.
Provides password-protected access to privileged operations
via local keyboard input only (no network exposure).
"""
from agentserver.console.secure_console import SecureConsole, PasswordManager
__all__ = ["SecureConsole", "PasswordManager"]

View file

@ -0,0 +1,730 @@
"""
secure_console.py Password-protected console for privileged operations.
The console is the sole privileged interface to the organism. Privileged
operations are only accessible via local keyboard input, never over the network.
Features:
- Password protection with Argon2id hashing
- Protected commands require password re-entry
- Attach/detach model with idle timeout
- Integration with context buffer for /monitor
Security model:
- Keyboard = Local = Trusted
- No network port for privileged operations
- Password hash stored in ~/.xml-pipeline/console.key (mode 600)
"""
from __future__ import annotations
import asyncio
import getpass
import os
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Callable, Awaitable
import yaml
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# prompt_toolkit may not work in all terminals (e.g., Git Bash on Windows)
# We provide a fallback to simple input()
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
if TYPE_CHECKING:
from agentserver.message_bus.stream_pump import StreamPump
# ============================================================================
# Constants
# ============================================================================
CONFIG_DIR = Path.home() / ".xml-pipeline"
KEY_FILE = CONFIG_DIR / "console.key"
HISTORY_FILE = CONFIG_DIR / "history"
# Commands that require password re-entry
PROTECTED_COMMANDS = {"restart", "kill", "pause", "resume"}
# Idle timeout before auto-detach (seconds, 0 = disabled)
DEFAULT_IDLE_TIMEOUT = 30 * 60 # 30 minutes
# ============================================================================
# ANSI Colors
# ============================================================================
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def cprint(text: str, color: str = Colors.RESET):
"""Print with ANSI color."""
try:
print(f"{color}{text}{Colors.RESET}")
except UnicodeEncodeError:
print(text)
# ============================================================================
# Password Management
# ============================================================================
class PasswordManager:
"""Manages password hashing and verification."""
def __init__(self, key_path: Path = KEY_FILE):
self.key_path = key_path
self.hasher = PasswordHasher()
self._hash: Optional[str] = None
def ensure_config_dir(self):
"""Create config directory if needed."""
self.key_path.parent.mkdir(parents=True, exist_ok=True)
def has_password(self) -> bool:
"""Check if password has been set."""
return self.key_path.exists()
def load_hash(self) -> Optional[str]:
"""Load password hash from file."""
if not self.key_path.exists():
return None
try:
with open(self.key_path) as f:
data = yaml.safe_load(f)
self._hash = data.get("hash")
return self._hash
except Exception:
return None
def save_hash(self, password: str) -> None:
"""Hash password and save to file."""
self.ensure_config_dir()
hash_value = self.hasher.hash(password)
data = {
"algorithm": "argon2id",
"hash": hash_value,
"created": datetime.now(timezone.utc).isoformat(),
}
with open(self.key_path, "w") as f:
yaml.dump(data, f)
# Set file permissions to 600 (owner read/write only)
if sys.platform != "win32":
os.chmod(self.key_path, stat.S_IRUSR | stat.S_IWUSR)
self._hash = hash_value
def verify(self, password: str) -> bool:
"""Verify password against stored hash."""
if self._hash is None:
self.load_hash()
if self._hash is None:
return False
try:
self.hasher.verify(self._hash, password)
return True
except VerifyMismatchError:
return False
# ============================================================================
# Secure Console
# ============================================================================
class SecureConsole:
"""
Password-protected console with privileged command support.
The console can be in one of two states:
- Attached: Full access, can send messages and run commands
- Detached: Limited access, only /commands work, @messages rejected
"""
def __init__(
self,
pump: StreamPump,
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
):
self.pump = pump
self.idle_timeout = idle_timeout
self.password_mgr = PasswordManager()
# State
self.authenticated = False
self.attached = True # Start attached
self.running = False
# prompt_toolkit session (may be None if fallback mode)
self.session: Optional[PromptSession] = None
self.use_simple_input = False # Fallback mode flag
# ------------------------------------------------------------------
# Startup
# ------------------------------------------------------------------
def _init_prompt_session(self) -> None:
"""Initialize prompt session (with fallback)."""
if self.session is not None:
return # Already initialized
self.password_mgr.ensure_config_dir()
if PROMPT_TOOLKIT_AVAILABLE:
try:
self.session = PromptSession(
history=FileHistory(str(HISTORY_FILE))
)
except Exception as e:
cprint(f"Note: Using simple input mode ({type(e).__name__})", Colors.DIM)
self.use_simple_input = True
else:
self.use_simple_input = True
async def authenticate(self) -> bool:
"""
Authenticate user (call before starting pump).
Returns True if authenticated, False otherwise.
"""
self._init_prompt_session()
# Ensure password is set up
if not await self._ensure_password():
return False
# Authenticate
if not await self._authenticate():
cprint("Authentication failed.", Colors.RED)
return False
self.authenticated = True
return True
async def run_command_loop(self) -> None:
"""
Run the command loop (call after authentication).
This shows the banner and enters the main input loop.
"""
if not self.authenticated:
cprint("Not authenticated. Call authenticate() first.", Colors.RED)
return
self.running = True
self._print_banner()
await self._main_loop()
async def run(self) -> None:
"""Main console loop (combines authenticate + run_command_loop)."""
if await self.authenticate():
await self.run_command_loop()
async def _ensure_password(self) -> bool:
"""Ensure password is set up (first run setup)."""
if self.password_mgr.has_password():
return True
cprint("\n" + "=" * 50, Colors.CYAN)
cprint(" First-time setup: Create console password", Colors.CYAN)
cprint("=" * 50 + "\n", Colors.CYAN)
cprint("This password protects privileged operations.", Colors.DIM)
cprint("It will be required at startup and for protected commands.\n", Colors.DIM)
# Get password with confirmation
while True:
password = await self._prompt_password("New password: ")
if not password:
cprint("Password cannot be empty.", Colors.RED)
continue
if len(password) < 4:
cprint("Password must be at least 4 characters.", Colors.RED)
continue
confirm = await self._prompt_password("Confirm password: ")
if password != confirm:
cprint("Passwords do not match.", Colors.RED)
continue
break
self.password_mgr.save_hash(password)
cprint("\nPassword set successfully.\n", Colors.GREEN)
return True
async def _authenticate(self) -> bool:
"""Authenticate user at startup."""
self.password_mgr.load_hash()
for attempt in range(3):
password = await self._prompt_password("Password: ")
if self.password_mgr.verify(password):
self.authenticated = True
return True
cprint("Incorrect password.", Colors.RED)
return False
async def _prompt_password(self, prompt: str) -> str:
"""Prompt for password (hidden input when possible)."""
if self.use_simple_input:
# Simple input mode: use visible input (getpass unreliable in some terminals)
cprint("(password will be visible)", Colors.DIM)
print(prompt, end="", flush=True)
loop = asyncio.get_event_loop()
try:
line = await loop.run_in_executor(None, sys.stdin.readline)
return line.strip() if line else ""
except (EOFError, KeyboardInterrupt):
return ""
else:
# Use prompt_toolkit for password input (hidden)
try:
session = PromptSession()
return await session.prompt_async(prompt, is_password=True)
except (EOFError, KeyboardInterrupt):
return ""
except Exception:
# Fallback if prompt_toolkit fails mid-session
self.use_simple_input = True
return await self._prompt_password(prompt)
# ------------------------------------------------------------------
# Main Loop
# ------------------------------------------------------------------
async def _main_loop(self) -> None:
"""Main input loop."""
while self.running:
try:
# Determine prompt based on attach state
prompt_str = "> " if self.attached else "# "
# Read input
line = await self._read_input(prompt_str)
await self._handle_input(line.strip())
except EOFError:
cprint("\nEOF received. Shutting down.", Colors.YELLOW)
break
except KeyboardInterrupt:
continue
async def _read_input(self, prompt: str) -> str:
"""Read a line of input (with fallback for non-TTY terminals)."""
if self.use_simple_input:
# Fallback: simple blocking input
loop = asyncio.get_event_loop()
print(prompt, end="", flush=True)
try:
line = await loop.run_in_executor(None, sys.stdin.readline)
if not line:
raise EOFError()
return line.strip()
except (EOFError, KeyboardInterrupt):
raise
else:
# Use prompt_toolkit with optional timeout
try:
with patch_stdout():
if self.idle_timeout > 0:
try:
return await asyncio.wait_for(
self.session.prompt_async(prompt),
timeout=self.idle_timeout
)
except asyncio.TimeoutError:
cprint("\nIdle timeout. Detaching console.", Colors.YELLOW)
self.attached = False
return ""
else:
return await self.session.prompt_async(prompt)
except Exception:
# Fall back to simple input if prompt_toolkit fails
self.use_simple_input = True
return await self._read_input(prompt)
async def _handle_input(self, line: str) -> None:
"""Route input to appropriate handler."""
if not line:
return
if line.startswith("/"):
await self._handle_command(line)
elif line.startswith("@"):
await self._handle_message(line)
else:
cprint("Use @listener message or /command", Colors.DIM)
# ------------------------------------------------------------------
# Command Handling
# ------------------------------------------------------------------
async def _handle_command(self, line: str) -> None:
"""Handle /command."""
parts = line[1:].split(None, 1)
cmd = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""
# Check if protected command
if cmd in PROTECTED_COMMANDS:
if not await self._verify_password():
cprint("Password required for this command.", Colors.RED)
return
# Dispatch to handler
handler = getattr(self, f"_cmd_{cmd}", None)
if handler:
await handler(args)
else:
cprint(f"Unknown command: /{cmd}", Colors.RED)
cprint("Type /help for available commands.", Colors.DIM)
async def _verify_password(self) -> bool:
"""Verify password for protected commands."""
password = await self._prompt_password("Password: ")
return self.password_mgr.verify(password)
# ------------------------------------------------------------------
# Message Handling
# ------------------------------------------------------------------
async def _handle_message(self, line: str) -> None:
"""Handle @listener message."""
if not self.attached:
cprint("Console detached. Use /attach first.", Colors.RED)
return
parts = line[1:].split(None, 1)
if not parts:
cprint("Usage: @listener message", Colors.DIM)
return
target = parts[0].lower()
message = parts[1] if len(parts) > 1 else ""
# Check if listener exists
if target not in self.pump.listeners:
cprint(f"Unknown listener: {target}", Colors.RED)
cprint("Use /listeners to see available listeners.", Colors.DIM)
return
cprint(f"[sending to {target}]", Colors.DIM)
# Create payload based on target listener
listener = self.pump.listeners[target]
payload = self._create_payload(listener, message)
if payload is None:
cprint(f"Cannot create payload for {target}", Colors.RED)
return
# Create thread and inject message
import uuid
thread_id = str(uuid.uuid4())
envelope = self.pump._wrap_in_envelope(
payload=payload,
from_id="console",
to_id=target,
thread_id=thread_id,
)
await self.pump.inject(envelope, thread_id=thread_id, from_id="console")
def _create_payload(self, listener, message: str):
"""Create payload instance for a listener from message text."""
payload_class = listener.payload_class
# Try to create payload with common field patterns
# Most payloads have a single text field like 'name', 'message', 'text', etc.
if hasattr(payload_class, '__dataclass_fields__'):
fields = payload_class.__dataclass_fields__
field_names = list(fields.keys())
if len(field_names) == 1:
# Single field - use the message as its value
return payload_class(**{field_names[0]: message})
elif 'name' in field_names:
return payload_class(name=message)
elif 'message' in field_names:
return payload_class(message=message)
elif 'text' in field_names:
return payload_class(text=message)
# Fallback: try with no args
try:
return payload_class()
except Exception:
return None
# ------------------------------------------------------------------
# Commands: Informational
# ------------------------------------------------------------------
async def _cmd_help(self, args: str) -> None:
"""Show available commands."""
cprint("\nCommands:", Colors.CYAN)
cprint(" /help Show this help", Colors.DIM)
cprint(" /status Show organism status", Colors.DIM)
cprint(" /listeners List registered listeners", Colors.DIM)
cprint(" /threads List active threads", Colors.DIM)
cprint(" /buffer <thread> Inspect thread's context buffer", Colors.DIM)
cprint(" /config View current configuration", Colors.DIM)
cprint("")
cprint("Protected (require password):", Colors.YELLOW)
cprint(" /restart Restart the pipeline", Colors.DIM)
cprint(" /kill <thread> Terminate a thread", Colors.DIM)
cprint(" /pause Pause message processing", Colors.DIM)
cprint(" /resume Resume message processing", Colors.DIM)
cprint("")
cprint("Session:", Colors.CYAN)
cprint(" /attach Attach console (enable @messages)", Colors.DIM)
cprint(" /detach Detach console (organism keeps running)", Colors.DIM)
cprint(" /passwd Change console password", Colors.DIM)
cprint(" /quit Graceful shutdown", Colors.DIM)
cprint("")
async def _cmd_status(self, args: str) -> None:
"""Show organism status."""
from agentserver.memory import get_context_buffer
from agentserver.message_bus.thread_registry import get_registry
buffer = get_context_buffer()
registry = get_registry()
stats = buffer.get_stats()
cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN)
cprint(f"Status: {'attached' if self.attached else 'detached'}",
Colors.GREEN if self.attached else Colors.YELLOW)
cprint(f"Listeners: {len(self.pump.listeners)}", Colors.DIM)
cprint(f"Threads: {stats['thread_count']} active", Colors.DIM)
cprint(f"Buffer: {stats['total_slots']} slots across threads", Colors.DIM)
cprint("")
async def _cmd_listeners(self, args: str) -> None:
"""List registered listeners."""
cprint("\nRegistered listeners:", Colors.CYAN)
for name, listener in self.pump.listeners.items():
agent_tag = "[agent] " if listener.is_agent else ""
cprint(f" {name:20} {agent_tag}{listener.description}", Colors.DIM)
cprint("")
async def _cmd_threads(self, args: str) -> None:
"""List active threads."""
from agentserver.memory import get_context_buffer
buffer = get_context_buffer()
stats = buffer.get_stats()
if stats["thread_count"] == 0:
cprint("\nNo active threads.", Colors.DIM)
return
cprint(f"\nActive threads ({stats['thread_count']}):", Colors.CYAN)
# Access internal threads dict (not ideal but works for now)
for thread_id, ctx in buffer._threads.items():
slot_count = len(ctx)
age = datetime.now(timezone.utc) - ctx._created_at
age_str = str(age).split(".")[0] # Remove microseconds
# Get last sender/receiver
if slot_count > 0:
last = ctx[-1]
flow = f"{last.from_id} -> {last.to_id}"
else:
flow = "(empty)"
cprint(f" {thread_id[:12]}... slots={slot_count:3} age={age_str} {flow}", Colors.DIM)
cprint("")
async def _cmd_buffer(self, args: str) -> None:
"""Inspect a thread's context buffer."""
if not args:
cprint("Usage: /buffer <thread-id>", Colors.DIM)
return
from agentserver.memory import get_context_buffer
buffer = get_context_buffer()
# Find thread by prefix
thread_id = None
for tid in buffer._threads.keys():
if tid.startswith(args):
thread_id = tid
break
if not thread_id:
cprint(f"Thread not found: {args}", Colors.RED)
return
ctx = buffer.get_thread(thread_id)
cprint(f"\nThread: {thread_id}", Colors.CYAN)
cprint(f"Slots: {len(ctx)}", Colors.DIM)
cprint("-" * 60, Colors.DIM)
for slot in ctx:
payload_type = type(slot.payload).__name__
cprint(f"[{slot.index}] {slot.from_id} -> {slot.to_id}: {payload_type}", Colors.DIM)
# Show first 100 chars of payload repr
payload_repr = repr(slot.payload)[:100]
cprint(f" {payload_repr}", Colors.DIM)
cprint("")
async def _cmd_config(self, args: str) -> None:
"""View current configuration (read-only)."""
cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN)
cprint(f"Port: {self.pump.config.port}", Colors.DIM)
cprint(f"Thread scheduling: {self.pump.config.thread_scheduling}", Colors.DIM)
cprint(f"Max concurrent pipelines: {self.pump.config.max_concurrent_pipelines}", Colors.DIM)
cprint(f"Max concurrent handlers: {self.pump.config.max_concurrent_handlers}", Colors.DIM)
cprint(f"Max concurrent per agent: {self.pump.config.max_concurrent_per_agent}", Colors.DIM)
cprint("\nTo modify: /quit -> edit organism.yaml -> restart", Colors.DIM)
cprint("")
# ------------------------------------------------------------------
# Commands: Protected
# ------------------------------------------------------------------
async def _cmd_restart(self, args: str) -> None:
"""Restart the pipeline."""
cprint("Restarting pipeline...", Colors.YELLOW)
await self.pump.shutdown()
# Re-bootstrap
from agentserver.message_bus.stream_pump import bootstrap
self.pump = await bootstrap()
# Start pump in background
asyncio.create_task(self.pump.run())
cprint("Pipeline restarted.", Colors.GREEN)
async def _cmd_kill(self, args: str) -> None:
"""Terminate a thread."""
if not args:
cprint("Usage: /kill <thread-id>", Colors.DIM)
return
from agentserver.memory import get_context_buffer
buffer = get_context_buffer()
# Find thread by prefix
thread_id = None
for tid in buffer._threads.keys():
if tid.startswith(args):
thread_id = tid
break
if not thread_id:
cprint(f"Thread not found: {args}", Colors.RED)
return
buffer.delete_thread(thread_id)
cprint(f"Thread {thread_id[:12]}... terminated.", Colors.YELLOW)
async def _cmd_pause(self, args: str) -> None:
"""Pause message processing."""
cprint("Pause not yet implemented.", Colors.YELLOW)
# TODO: Implement pump pause
async def _cmd_resume(self, args: str) -> None:
"""Resume message processing."""
cprint("Resume not yet implemented.", Colors.YELLOW)
# TODO: Implement pump resume
# ------------------------------------------------------------------
# Commands: Session
# ------------------------------------------------------------------
async def _cmd_attach(self, args: str) -> None:
"""Attach console."""
if self.attached:
cprint("Already attached.", Colors.DIM)
return
if not await self._verify_password():
cprint("Password required to attach.", Colors.RED)
return
self.attached = True
cprint("Console attached.", Colors.GREEN)
async def _cmd_detach(self, args: str) -> None:
"""Detach console."""
if not self.attached:
cprint("Already detached.", Colors.DIM)
return
self.attached = False
cprint("Console detached. Organism continues running.", Colors.YELLOW)
cprint("Use /attach to re-attach.", Colors.DIM)
async def _cmd_passwd(self, args: str) -> None:
"""Change console password."""
# Verify current password
current = await self._prompt_password("Current password: ")
if not self.password_mgr.verify(current):
cprint("Incorrect password.", Colors.RED)
return
# Get new password
while True:
new_pass = await self._prompt_password("New password: ")
if not new_pass or len(new_pass) < 4:
cprint("Password must be at least 4 characters.", Colors.RED)
continue
confirm = await self._prompt_password("Confirm new password: ")
if new_pass != confirm:
cprint("Passwords do not match.", Colors.RED)
continue
break
self.password_mgr.save_hash(new_pass)
cprint("Password changed successfully.", Colors.GREEN)
async def _cmd_quit(self, args: str) -> None:
"""Graceful shutdown."""
cprint("Shutting down...", Colors.YELLOW)
self.running = False
await self.pump.shutdown()
# ------------------------------------------------------------------
# UI Helpers
# ------------------------------------------------------------------
def _print_banner(self) -> None:
"""Print startup banner."""
print()
cprint("+" + "=" * 44 + "+", Colors.CYAN)
cprint("|" + " " * 8 + "xml-pipeline console v3.0" + " " * 9 + "|", Colors.CYAN)
cprint("+" + "=" * 44 + "+", Colors.CYAN)
print()
cprint(f"Organism '{self.pump.config.name}' ready.", Colors.GREEN)
cprint(f"{len(self.pump.listeners)} listeners registered.", Colors.DIM)
cprint("Type /help for commands.", Colors.DIM)
print()

View file

@ -7,10 +7,12 @@ It establishes the root thread from which all other threads descend.
The boot handler: The boot handler:
1. Logs organism startup 1. Logs organism startup
2. Initializes any system-level state 2. Initializes any system-level state
3. Sends initial ConsolePrompt to start the console REPL
All external messages that arrive without a known thread parent All external messages that arrive without a known thread parent
will be registered as children of the boot thread. will be registered as children of the boot thread.
Note: The SecureConsole (v3.0) handles the console directly, so the boot
handler no longer sends to a console listener.
""" """
from dataclasses import dataclass from dataclasses import dataclass
@ -35,25 +37,11 @@ class Boot:
listener_count: int = 0 listener_count: int = 0
@xmlify async def handle_boot(payload: Boot, metadata: HandlerMetadata) -> None:
@dataclass
class ConsolePrompt:
"""
Prompt message to the console.
Duplicated here to avoid circular import with handlers.console.
The pump will route based on payload class name.
"""
output: str = ""
source: str = ""
show_banner: bool = False
async def handle_boot(payload: Boot, metadata: HandlerMetadata) -> HandlerResponse:
""" """
Handle the system boot message. Handle the system boot message.
Logs the boot event and sends initial ConsolePrompt to start the REPL. Logs the boot event. The SecureConsole handles user interaction directly.
""" """
logger.info( logger.info(
f"Organism '{payload.organism_name}' booted at {payload.timestamp} " f"Organism '{payload.organism_name}' booted at {payload.timestamp} "
@ -66,12 +54,5 @@ async def handle_boot(payload: Boot, metadata: HandlerMetadata) -> HandlerRespon
# - Load cached schemas # - Load cached schemas
# - Pre-populate routing caches # - Pre-populate routing caches
# Send initial prompt to console to start the REPL # No response needed - SecureConsole handles user interaction
return HandlerResponse( return None
payload=ConsolePrompt(
output=f"Organism '{payload.organism_name}' ready.\n{payload.listener_count} listeners registered.",
source="system",
show_banner=True,
),
to="console",
)

View file

@ -1,17 +1,13 @@
# organism.yaml — Multi-agent organism with console interface # organism.yaml — Multi-agent organism with secure console (v3.0)
# #
# Message flow: # The SecureConsole is the sole privileged interface.
# boot # It handles authentication and commands, then injects messages into the flow.
# -> system.boot (logs, sends ConsolePrompt) #
# -> console (displays, awaits input, returns ConsoleInput) # Message flow for @greeter hello:
# -> console-router (ConsoleInput -> Greeting) # SecureConsole.inject(Greeting)
# -> greeter (Greeting -> GreetingResponse) # -> greeter (Greeting -> GreetingResponse)
# -> shouter (GreetingResponse -> ShoutedResponse) # -> shouter (GreetingResponse -> ShoutedResponse)
# -> response-handler (ShoutedResponse -> ConsolePrompt) # -> (response captured by SecureConsole via context buffer)
# -> console (displays, awaits input, ...)
#
# The console is a regular handler in the message flow.
# Returns None on EOF/quit to disconnect.
organism: organism:
name: hello-world name: hello-world
@ -35,33 +31,11 @@ llm:
rate_limit_tpm: 100000 rate_limit_tpm: 100000
listeners: listeners:
# Console: receives ConsolePrompt, displays output, awaits input
# Returns ConsoleInput to console-router, or None to disconnect
- name: console
payload_class: handlers.console.ConsolePrompt
handler: handlers.console.handle_console_prompt
description: Interactive console - displays output, awaits input
agent: false
# Console router: receives ConsoleInput, translates to target payload
- name: console-router
payload_class: handlers.console.ConsoleInput
handler: handlers.console.handle_console_input
description: Routes console input to appropriate listeners
agent: false
# Response handler: receives ShoutedResponse, creates ConsolePrompt
- name: response-handler
payload_class: handlers.console.ShoutedResponse
handler: handlers.console.handle_shouted_response
description: Forwards responses back to console
agent: false
# Greeter: receives Greeting, sends GreetingResponse to shouter # Greeter: receives Greeting, sends GreetingResponse to shouter
- name: greeter - name: greeter
payload_class: handlers.hello.Greeting payload_class: handlers.hello.Greeting
handler: handlers.hello.handle_greeting handler: handlers.hello.handle_greeting
description: Receives greeting, forwards to shouter description: Greeting agent - forwards to shouter
agent: true agent: true
peers: [shouter] peers: [shouter]
@ -71,3 +45,10 @@ listeners:
handler: handlers.hello.handle_shout handler: handlers.hello.handle_shout
description: Shouts the greeting in ALL CAPS description: Shouts the greeting in ALL CAPS
agent: false agent: false
# Response handler: prints responses to stdout
- name: response-handler
payload_class: handlers.hello.ShoutedResponse
handler: handlers.hello.handle_response_print
description: Prints responses to console
agent: false

View file

@ -126,3 +126,15 @@ async def handle_shout(payload: GreetingResponse, metadata: HandlerMetadata) ->
payload=ShoutedResponse(message=payload.message.upper()), payload=ShoutedResponse(message=payload.message.upper()),
to=payload.original_sender, to=payload.original_sender,
) )
async def handle_response_print(payload: ShoutedResponse, metadata: HandlerMetadata) -> None:
"""
Print the final response to stdout.
This is a simple terminal handler for the SecureConsole flow.
"""
# Print on fresh line with color formatting, then reprint prompt
print(f"\n\033[36m[response] {payload.message}\033[0m")
print("> ", end="", flush=True) # Reprint prompt
return None

View file

@ -29,6 +29,8 @@ dependencies = [
"aiostream>=0.5", "aiostream>=0.5",
"pyhumps", "pyhumps",
"termcolor", "termcolor",
"argon2-cffi",
"prompt_toolkit>=3.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -1,16 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
run_organism.py Start the organism. run_organism.py Start the organism with secure console.
Usage: Usage:
python run_organism.py [config.yaml] python run_organism.py [config.yaml]
This boots the organism and runs the message pump. This boots the organism with a password-protected console.
The console is a regular handler in the message flow: The secure console handles privileged operations via local keyboard only.
boot -> system.boot -> console (await input) -> console-router -> ... -> console Flow:
1. Password authentication
The pump continues until the console returns None (EOF or /quit). 2. Pump starts processing messagest
3. Console handles commands and @messages
4. /quit shuts down gracefully
""" """
import asyncio import asyncio
@ -18,25 +20,36 @@ import sys
from pathlib import Path from pathlib import Path
from agentserver.message_bus import bootstrap from agentserver.message_bus import bootstrap
from agentserver.console import SecureConsole
async def run_organism(config_path: str = "config/organism.yaml"): async def run_organism(config_path: str = "config/organism.yaml"):
"""Boot organism and run the message pump.""" """Boot organism with secure console."""
# Bootstrap the pump (registers listeners, injects boot message) # Bootstrap the pump (registers listeners, but DON'T start yet)
pump = await bootstrap(config_path) pump = await bootstrap(config_path)
# Set pump reference for console introspection commands # Create secure console and authenticate FIRST
from handlers.console import set_pump_ref console = SecureConsole(pump)
set_pump_ref(pump)
# Authenticate before starting pump
if not await console.authenticate():
print("Authentication failed.")
return
# Now start the pump in background
pump_task = asyncio.create_task(pump.run())
# Run the pump - it will process boot -> console -> ... flow
# The pump runs until shutdown is called
try: try:
await pump.run() # Run console command loop (already authenticated)
except asyncio.CancelledError: await console.run_command_loop()
pass
finally: finally:
# Ensure pump is shut down
pump_task.cancel()
try:
await pump_task
except asyncio.CancelledError:
pass
await pump.shutdown() await pump.shutdown()
print("Goodbye!") print("Goodbye!")

View file

@ -52,13 +52,13 @@ class TestPumpBootstrap:
config = ConfigLoader.load('config/organism.yaml') config = ConfigLoader.load('config/organism.yaml')
assert config.name == "hello-world" assert config.name == "hello-world"
assert len(config.listeners) == 5 # console-router, response-handler, console, greeter, shouter assert len(config.listeners) == 3 # greeter, shouter, response-handler
# Find greeter and shouter by name # Find greeter and shouter by name
listener_names = [lc.name for lc in config.listeners] listener_names = [lc.name for lc in config.listeners]
assert "greeter" in listener_names assert "greeter" in listener_names
assert "shouter" in listener_names assert "shouter" in listener_names
assert "console-router" in listener_names assert "response-handler" in listener_names
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bootstrap_creates_pump(self): async def test_bootstrap_creates_pump(self):
@ -66,10 +66,10 @@ class TestPumpBootstrap:
pump = await bootstrap('config/organism.yaml') pump = await bootstrap('config/organism.yaml')
assert pump.config.name == "hello-world" assert pump.config.name == "hello-world"
assert len(pump.routing_table) == 8 # 5 user listeners + 3 system (boot, todo, todo-complete) assert len(pump.routing_table) == 6 # 3 user listeners + 3 system (boot, todo, todo-complete)
assert "greeter.greeting" in pump.routing_table assert "greeter.greeting" in pump.routing_table
assert "shouter.greetingresponse" in pump.routing_table assert "shouter.greetingresponse" in pump.routing_table
assert "console-router.consoleinput" in pump.routing_table assert "response-handler.shoutedresponse" in pump.routing_table
assert "system.boot.boot" in pump.routing_table # Boot listener assert "system.boot.boot" in pump.routing_table # Boot listener
@pytest.mark.asyncio @pytest.mark.asyncio