This commit is contained in:
Víctor Mayoral Vilches 2025-05-26 10:58:52 +02:00
commit 5ab7f64562
6 changed files with 548 additions and 222 deletions

View File

@ -159,13 +159,12 @@ from cai.repl.commands.parallel import PARALLEL_CONFIGS, ParallelConfig
from cai import is_pentestperf_available
ctf_global = None
messages_ctf = ""
ctf_init=1
previous_ctf_name = os.getenv('CTF_NAME', None)
if is_pentestperf_available() and os.getenv('CTF_NAME', None):
ctf, messages_ctf = setup_ctf()
ctf_global = ctf
if os.getenv('CTF_INSIDE', 'True').lower() == 'false':
container_id = ""
os.environ['CAI_ACTIVE_CONTAINER'] = container_id
ctf_init=0
# NOTE: This is needed when using LiteLLM Proxy Server
#
@ -248,12 +247,12 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
return "Agent"
# Prevent the model from using its own rich streaming to avoid conflicts
# and suppress final output message to avoid duplicates
# but allow final output message to ensure all agent responses are shown
if hasattr(agent, 'model'):
if hasattr(agent.model, 'disable_rich_streaming'):
agent.model.disable_rich_streaming = False # Now True as the model handles streaming
if hasattr(agent.model, 'suppress_final_output'):
agent.model.suppress_final_output = True
agent.model.suppress_final_output = False # Changed to False to show all agent messages
# Set the agent name in the model for proper display in streaming panel
if hasattr(agent.model, 'set_agent_name'):
@ -267,6 +266,7 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
global previous_ctf_name
global ctf_global
global messages_ctf
global ctf_init
if previous_ctf_name != os.getenv('CTF_NAME', None):
if is_pentestperf_available():
if ctf_global:
@ -274,7 +274,7 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
ctf, messages_ctf = setup_ctf()
ctf_global = ctf
previous_ctf_name = os.getenv('CTF_NAME', None)
ctf_init=0
# Check if CAI_MAX_TURNS has been updated via /config
current_max_turns = os.getenv('CAI_MAX_TURNS', 'inf')
if current_max_turns != str(prev_max_turns):
@ -320,7 +320,7 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
if hasattr(agent.model, 'disable_rich_streaming'):
agent.model.disable_rich_streaming = False # Now False to let model handle streaming
if hasattr(agent.model, 'suppress_final_output'):
agent.model.suppress_final_output = True
agent.model.suppress_final_output = False # Changed to False to show all agent messages
# Apply current model to the new agent
if hasattr(agent.model, 'model'):
@ -332,7 +332,7 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
except Exception as e:
console.print(f"[red]Error switching agent: {str(e)}[/red]")
if not force_until_flag:
if not force_until_flag and ctf_init!=0:
# Get user input with command completion and history
user_input = get_user_input(
command_completer,
@ -340,11 +340,11 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
history_file,
get_toolbar_with_refresh,
current_text
) + messages_ctf
)
else:
user_input = messages_ctf
if os.getenv('CTF_INSIDE', 'True').lower() == 'false':
user_input += ctf_global.get_ip()
ctf_init=1
idle_time += time.time() - idle_start_time
# Stop measuring user idle time and start measuring active time
@ -651,7 +651,7 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf'),
history_context.append({"role": "user", "content": user_input})
conversation_input = history_context
else:
conversation_input = user_input
conversation_input = messages_ctf + user_input
# Process the conversation with the agent - with parallel execution if enabled
if parallel_count > 1:
@ -873,9 +873,9 @@ def main():
# Disable rich streaming in the model to avoid conflicts
if hasattr(agent.model, 'disable_rich_streaming'):
agent.model.disable_rich_streaming = True
# Suppress final output to avoid duplicates
# Allow final output to ensure all agent messages are shown
if hasattr(agent.model, 'suppress_final_output'):
agent.model.suppress_final_output = True
agent.model.suppress_final_output = False # Changed to False to show all agent messages
# Run the CLI with the selected agent
run_cai_cli(agent)

View File

@ -506,6 +506,7 @@ class OpenAIChatCompletionsModel(Model):
if _debug.DONT_LOG_MODEL_DATA:
logger.debug("Received model response")
else:
import json
logger.debug(
f"LLM resp:\n{json.dumps(response.choices[0].message.model_dump(), indent=2)}\n"
)
@ -552,16 +553,85 @@ class OpenAIChatCompletionsModel(Model):
for tool_call in response.choices[0].message.tool_calls:
call_id = tool_call.id
# If we're using direct tool output display with cli_print_tool_output,
# and we've already displayed this tool call output, we can skip displaying
# the assistant message to avoid duplication
if (hasattr(_Converter, 'tool_outputs') and call_id in _Converter.tool_outputs and
hasattr(_Converter, 'recent_tool_calls') and call_id in _Converter.recent_tool_calls):
# We've already displayed this tool and its output directly
should_display_message = False
# Check if this tool call has already been displayed
if (hasattr(_Converter, 'tool_outputs') and call_id in _Converter.tool_outputs):
tool_output_content = _Converter.tool_outputs[call_id]
# Check if this is a command sent to an existing async session
is_async_session_input = False
has_auto_output = False
is_regular_command = False
try:
import json
args = json.loads(tool_call.function.arguments)
# Check if this is a regular command (not a session command)
if (isinstance(args, dict) and
args.get("command") and
not args.get("session_id") and
not args.get("async_mode")):
is_regular_command = True
# Only consider it an async session input if it has session_id AND it's not creating a new session
elif (isinstance(args, dict) and
args.get("session_id") and
not args.get("async_mode") and # Not creating a new session
not args.get("creating_session")): # Not marked as session creation
is_async_session_input = True
# Check if this has auto_output flag
has_auto_output = args.get("auto_output", False)
except:
pass
# For regular commands that were already shown via streaming, suppress the agent message
if is_regular_command and tool_call.function.name == "generic_linux_command":
# Check if this was executed very recently (likely shown via streaming)
if (hasattr(_Converter, 'recent_tool_calls') and
call_id in _Converter.recent_tool_calls):
tool_call_info = _Converter.recent_tool_calls[call_id]
if 'start_time' in tool_call_info:
import time
time_since_execution = time.time() - tool_call_info['start_time']
# If executed within last 2 seconds, it was likely shown via streaming
if time_since_execution < 2.0:
should_display_message = False
tool_output = None
elif is_async_session_input:
should_display_message = True
tool_output = None
# For async session inputs without auto_output, always show the agent message
elif is_async_session_input and not has_auto_output:
should_display_message = True
tool_output = None
# For session creation messages, also show them
elif ("Started async session" in tool_output_content or
"session" in tool_output_content.lower() and "async" in tool_output_content.lower()):
should_display_message = True
tool_output = None
else:
# For other tool calls, check if we should suppress based on timing
# Only suppress if this tool was JUST executed (within last 2 seconds)
if (hasattr(_Converter, 'recent_tool_calls') and
call_id in _Converter.recent_tool_calls):
tool_call_info = _Converter.recent_tool_calls[call_id]
if 'start_time' in tool_call_info:
import time
time_since_execution = time.time() - tool_call_info['start_time']
# Only suppress if this was executed very recently
if time_since_execution < 2.0:
should_display_message = False
else:
# For older tool calls, show the message
should_display_message = True
break
# Additional check: Always show messages that have text content
# This ensures agent explanations are not suppressed
if (hasattr(response.choices[0].message, 'content') and
response.choices[0].message.content and
str(response.choices[0].message.content).strip()):
# If the message has actual text content, always show it
should_display_message = True
# Only display the agent message if we haven't already shown the tool output
# Display the agent message (this will show the command for async sessions)
if should_display_message:
# Ensure we're in non-streaming mode for proper markdown parsing
previous_stream_setting = os.environ.get('CAI_STREAM', 'false')
@ -588,8 +658,8 @@ class OpenAIChatCompletionsModel(Model):
total_reasoning_tokens=getattr(self, 'total_reasoning_tokens', 0),
interaction_cost=None,
total_cost=None,
tool_output=None, # Don't pass tool output here, we're using direct display
suppress_empty=True # Suppress empty panels
tool_output=tool_output, # Pass tool_output only when needed
suppress_empty=True # Keep suppress_empty=True as requested
)
# Restore previous streaming setting
@ -3192,15 +3262,41 @@ class _Converter:
# Check if we're in streaming mode
is_streaming_enabled = os.environ.get('CAI_STREAM', 'false').lower() == 'true'
# Always display tool output regardless of streaming mode
cli_print_tool_output(
tool_name=tool_name,
args=tool_args,
output=output_content,
call_id=call_id,
execution_info=execution_info,
token_info=token_info
)
# Check if this output was already displayed during streaming
# For async sessions, we always display since they don't have real streaming
should_display = True
# If streaming is enabled, check if this was already shown
if is_streaming_enabled and hasattr(cls, 'recent_tool_calls') and call_id in cls.recent_tool_calls:
tool_call_info = cls.recent_tool_calls[call_id]
# Check if this tool was executed very recently (within last 5 seconds)
# This indicates it was likely shown during streaming
if 'start_time' in tool_call_info:
time_since_execution = time.time() - tool_call_info['start_time']
# For regular commands executed recently in streaming mode, skip display
# But always display for async session commands (they have session_id in args)
if time_since_execution < 5.0:
# Parse arguments to check if this is an async session command
try:
import json
args_dict = json.loads(tool_args) if isinstance(tool_args, str) else tool_args
# If it has session_id, it's an async command - always show
if not (isinstance(args_dict, dict) and args_dict.get("session_id")):
should_display = False
except:
# If we can't parse args, assume it's a regular command
should_display = False
# Only display if it hasn't been shown during streaming
if should_display:
cli_print_tool_output(
tool_name=tool_name,
args=tool_args,
output=output_content,
call_id=call_id,
execution_info=execution_info,
token_info=token_info
)
# Continue with normal processing
flush_assistant_message()

View File

@ -174,42 +174,67 @@ class ShellSession: # pylint: disable=too-many-instance-attributes
def _read_output(self):
"""Read output from the process"""
try:
# Buffer for incomplete lines
partial_line = ""
while self.is_running and self.master is not None:
try:
# Check if process has exited before reading
if self.process and self.process.poll() is not None:
self.is_running = False
break
# Read the output
output = os.read(self.master, 1024).decode()
# Read the output - increased buffer size to avoid cutting commands
output = os.read(self.master, 4096).decode('utf-8', errors='replace')
if output:
self.output_buffer.append(output)
# Combine with any partial line from previous read
full_output = partial_line + output
# Split into lines but keep the last partial line if it doesn't end with newline
lines = full_output.split('\n')
# If output doesn't end with newline, the last item is a partial line
if not output.endswith('\n'):
partial_line = lines[-1]
lines = lines[:-1]
else:
partial_line = ""
# Add complete lines to buffer
for line in lines:
if line: # Don't add empty lines
self.output_buffer.append(line)
self.last_activity = time.time()
else:
# os.read() returned empty. This does NOT necessarily mean
# the process itself has exited if self.process.poll() is None.
# It might be idle and waiting for input.
# The self.process.poll() check at the start of the loop iteration
# (or if self.is_process_running() is used in the sleep condition)
# will handle actual termination.
# Thus, we 'pass' here and let the loop continue with its sleep,
# rather than prematurely setting self.is_running = False.
if self.process and self.process.poll() is None:
# Process is alive but PTY read was empty (e.g., idle).
# Do nothing here to change is_running.
pass
else:
# Process is confirmed dead or no process to check,
# and read returned empty. Session is over.
if partial_line:
# Add any remaining partial line
self.output_buffer.append(partial_line)
self.is_running = False
break # Exit the while loop
break
except UnicodeDecodeError as e:
# Handle unicode decode errors gracefully
self.output_buffer.append(f"[Session {self.session_id}] Unicode decode error in output")
continue
except Exception as read_err:
self.output_buffer.append(f"Error reading output buffer: {str(read_err)}")
self.is_running = False
break
self.output_buffer.append(f"Error reading output buffer: {str(read_err)}")
self.is_running = False
break
# Add a small sleep to prevent busy-waiting if no output
if self.is_process_running():
time.sleep(0.05)
time.sleep(0.05)
except Exception as e:
self.output_buffer.append(f"Error in read_output loop: {str(e)}")
self.is_running = False
@ -261,6 +286,21 @@ class ShellSession: # pylint: disable=too-many-instance-attributes
if clear:
self.output_buffer = []
return output
def get_new_output(self, mark_position=True):
"""Get only new output since last marked position"""
if not hasattr(self, '_last_output_position'):
self._last_output_position = 0
# Get new output since last position
new_output_lines = self.output_buffer[self._last_output_position:]
new_output = "\n".join(new_output_lines)
# Update position marker if requested
if mark_position:
self._last_output_position = len(self.output_buffer)
return new_output
def terminate(self):
"""Terminate the session"""
@ -392,7 +432,7 @@ def terminate_session(session_id):
return result
def _run_ctf(ctf, command, stdout=False, timeout=100, workspace_dir=None):
def _run_ctf(ctf, command, stdout=False, timeout=100, workspace_dir=None, stream=False):
"""Runs command in CTF env, changing to workspace_dir first."""
target_dir = workspace_dir or _get_workspace_dir()
full_command = f"{command}"
@ -400,7 +440,9 @@ def _run_ctf(ctf, command, stdout=False, timeout=100, workspace_dir=None):
context_msg = f"(ctf:{target_dir})"
try:
output = ctf.get_shell(full_command, timeout=timeout)
if stdout:
# In streaming mode, don't print to stdout to avoid duplication
# The streaming system will handle the display
if stdout and not stream:
print(f"\033[32m{context_msg} $ {original_cmd_for_msg}\n{output}\033[0m") # noqa E501
return output
except Exception as e: # pylint: disable=broad-except
@ -408,7 +450,7 @@ def _run_ctf(ctf, command, stdout=False, timeout=100, workspace_dir=None):
print(color(error_msg, fg="red"))
return error_msg
def _run_ssh(command, stdout=False, timeout=100, workspace_dir=None):
def _run_ssh(command, stdout=False, timeout=100, workspace_dir=None, stream=False):
"""Runs command via SSH. Assumes SSH agent or passwordless setup unless sshpass is used externally.""" # noqa E501
ssh_user = os.environ.get('SSH_USER')
ssh_host = os.environ.get('SSH_HOST')
@ -434,14 +476,16 @@ def _run_ssh(command, stdout=False, timeout=100, workspace_dir=None):
timeout=timeout
)
output = result.stdout if result.stdout else result.stderr
if stdout:
# In streaming mode, don't print to stdout to avoid duplication
# The streaming system will handle the display
if stdout and not stream:
print(f"\033[32m{context_msg} $ {original_cmd_for_msg}\n{output}\033[0m") # noqa E501
# Return combined output, potentially including errors
return output.strip()
except subprocess.TimeoutExpired as e:
error_output = e.stdout if e.stdout else str(e)
timeout_msg = f"Timeout executing SSH command: {error_output}"
if stdout:
if stdout and not stream:
print(f"\033[33m{context_msg} $ {original_cmd_for_msg}\nTIMEOUT\n{error_output}\033[0m") # noqa E501
return timeout_msg
except FileNotFoundError:
@ -586,55 +630,9 @@ def _run_local(command, stdout=False, timeout=100, stream=False, call_id=None, t
)
output = result.stdout if result.stdout else result.stderr
# If this is the same command that was recently streamed, don't display it again
# We'll create a similar tool_args structure to what streaming would use
parts = command.strip().split(' ', 1)
cmd_var = parts[0] if parts else ""
args_param_val = parts[1] if len(parts) > 1 else ""
# Generate a standard tool name for consistency with streaming
standard_tool_name = tool_name or (f"{cmd_var}_command" if cmd_var else "command")
# Calculate a consistent command key - must match the format used in cli_print_tool_output
command_key = f"{standard_tool_name}:{args_param_val}"
if hasattr(cli_print_tool_output, '_displayed_commands') and command_key in cli_print_tool_output._displayed_commands:
# Skip stdout display if already shown through streaming
return output.strip()
if stdout:
# Create a tool_args dictionary for non-streaming display
# that matches the format used in streaming
tool_display_args = {
"command": cmd_var,
"args": args_param_val,
"full_command": command,
"workspace": os.path.basename(target_dir)
}
# If custom args were provided, merge them with the default args
if custom_args is not None and isinstance(custom_args, dict):
for key, value in custom_args.items():
tool_display_args[key] = value
# Calculate execution info
tool_execution_time = time.time() - process_start_time
exec_info = {
"status": "completed" if result.returncode == 0 else "error",
"return_code": result.returncode,
"environment": "Local",
"host": os.path.basename(target_dir),
"tool_time": tool_execution_time
}
# Display the command output with rich formatting
cli_print_tool_output(
tool_name=standard_tool_name,
args=tool_display_args,
output=output,
execution_info=exec_info
)
# In non-streaming mode, we should NOT display the output via cli_print_tool_output
# to avoid duplication. The output will be handled by the calling function.
# Only return the raw output for the calling function to handle.
return output.strip()
except subprocess.TimeoutExpired as e:
error_output = e.stdout if hasattr(e, 'stdout') and e.stdout else str(e)
@ -762,6 +760,36 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
session = ACTIVE_SESSIONS[session_id]
result = session.send_input(command) # Send the raw command string
# Wait for the command to execute and capture output
# This provides automatic output display for async sessions
wait_time = 3.0 # Wait 3 seconds for command to execute
# Mark the current position in the output buffer before sending input
session.get_new_output(mark_position=True) # Reset position marker
# Smart waiting: check for new output every 0.5 seconds, up to max wait time
max_wait = wait_time
check_interval = 0.5
elapsed = 0.0
new_output_detected = False
while elapsed < max_wait:
time.sleep(check_interval)
elapsed += check_interval
# Check if new output is available
current_new_output = session.get_new_output(mark_position=False)
# If we detect new output, wait a bit more for it to complete, then break
if current_new_output.strip():
if not new_output_detected:
new_output_detected = True
# Give it a bit more time to complete the output
time.sleep(0.5)
else:
# We already detected new output and waited, now break
break
# Always show the session output after sending input using the counter mechanism
# Generate unique counter for this session input command
counter_key = f"session_input_{session_id}"
@ -775,9 +803,21 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
"args": "",
"session_id": session_id,
"call_counter": SESSION_OUTPUT_COUNTER[counter_key], # This ensures uniqueness
"input_to_session": True # Flag to identify this as session input
"input_to_session": True, # Flag to identify this as session input
}
# Only add auto_output if not already present (prevents duplication)
if args and isinstance(args, dict):
# If args were passed and contain auto_output, use that value
if "auto_output" in args:
session_args["auto_output"] = args["auto_output"]
else:
# Otherwise, force it to True for session commands
session_args["auto_output"] = True
else:
# No args provided, force auto_output
session_args["auto_output"] = True
# Determine environment info for display
env_type = "Local"
if session.container_id:
@ -785,15 +825,17 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
elif session.ctf:
env_type = "CTF"
# Get the session output to display
output = get_session_output(session_id, clear=False, stdout=False)
# Get only the NEW output to display (not the entire buffer)
output = session.get_new_output(mark_position=True)
# Create execution info
execution_info = {
"status": "completed",
"environment": env_type,
"host": session.workspace_dir,
"session_id": session_id
"session_id": session_id,
"wait_time": elapsed,
"new_output_detected": new_output_detected
}
# Display the session input and its result using cli_print_tool_output
@ -813,7 +855,12 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
stop_active_timer()
start_idle_timer()
return result # Return the result of sending input ("Input sent..." or error)
# Return the actual output from the session
# The output has already been displayed via cli_print_tool_output
if output and output.strip():
return output
else:
return f"Command sent to session {session_id}. No output captured."
# 2. Determine Execution Environment (Container > CTF > SSH > Local)
active_container = os.getenv("CAI_ACTIVE_CONTAINER", "")
@ -835,11 +882,46 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
stop_active_timer()
start_idle_timer()
return new_session_id
if stdout:
# Wait a moment for initial output
time.sleep(0.2)
output = get_session_output(new_session_id, clear=False, stdout=False)
print(f"\033[32m(Started Session {new_session_id} in {context_msg})\n{output}\033[0m") # noqa E501
# Display the command that creates the async session
from cai.util import cli_print_tool_output
# Create args for display
session_creation_args = {
"command": command,
"args": "",
"session_id": new_session_id,
"async_mode": True
}
# Create execution info
execution_info = {
"status": "session_created",
"environment": f"Container({container_id[:12]})",
"host": container_workspace,
"session_id": new_session_id
}
# Get initial output if any
session = ACTIVE_SESSIONS.get(new_session_id)
initial_output = ""
if session:
time.sleep(0.2) # Wait a moment for initial output
initial_output = session.get_new_output(mark_position=True)
# Format the output message
output_msg = f"Started async session {new_session_id} in container {container_id[:12]}. Use this ID to interact."
if initial_output:
output_msg += f"\n\n{initial_output}"
# Display the session creation command and initial output
cli_print_tool_output(
tool_name="generic_linux_command",
args=session_creation_args,
output=output_msg,
execution_info=execution_info,
streaming=False
)
# For async sessions, switch back to idle mode after session creation
stop_active_timer()
@ -1038,7 +1120,9 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
output = result.stdout if result.stdout else result.stderr
output = output.strip() # Clean trailing newline
if stdout:
# In streaming mode, don't print to stdout to avoid duplication
# The streaming system will handle the display
if stdout and not stream:
print(f"\033[32m{context_msg} $ {command}\n{output}\033[0m") # noqa E501
# Check if command failed specifically because container isn't running
@ -1149,7 +1233,7 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
return error_msg
else:
# Standard non-streaming CTF execution
result = _run_ctf(ctf, command, stdout, timeout) # Pass None for workspace_dir
result = _run_ctf(ctf, command, stdout, timeout, _get_workspace_dir(), stream)
# Switch back to idle mode after CTF command completes
stop_active_timer()
@ -1278,7 +1362,7 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
return error_msg
else:
# Standard non-streaming SSH execution
result = _run_ssh(command, stdout, timeout) # Workspace dir less relevant here
result = _run_ssh(command, stdout, timeout, _get_workspace_dir(), stream)
# Switch back to idle mode after SSH command completes
stop_active_timer()
@ -1297,13 +1381,49 @@ def run_command(command, ctf=None, stdout=False, # pylint: disable=too-many-arg
stop_active_timer()
start_idle_timer()
return new_session_id
# Display the command that creates the async session
from cai.util import cli_print_tool_output
# Retrieve the actual workspace dir the session is using
session = ACTIVE_SESSIONS.get(new_session_id)
actual_workspace = session.workspace_dir if session else "unknown"
if stdout:
# Create args for display
session_creation_args = {
"command": command,
"args": "",
"session_id": new_session_id,
"async_mode": True
}
# Create execution info
execution_info = {
"status": "session_created",
"environment": "Local",
"host": os.path.basename(actual_workspace),
"session_id": new_session_id
}
# Get initial output if any
initial_output = ""
if session:
time.sleep(0.2) # Allow session buffer to populate
output = get_session_output(new_session_id, clear=False, stdout=False)
print(f"\033[32m(Started Session {new_session_id} in local:{actual_workspace})\n{output}\033[0m")
initial_output = session.get_new_output(mark_position=True)
# Format the output message
output_msg = f"Started async session {new_session_id} locally. Use this ID to interact."
if initial_output:
output_msg += f"\n\n{initial_output}"
# Display the session creation command and initial output
cli_print_tool_output(
tool_name="generic_linux_command",
args=session_creation_args,
output=output_msg,
execution_info=execution_info,
streaming=False
)
# For async, switch back to idle mode after session creation
stop_active_timer()

View File

@ -16,40 +16,53 @@ from wasabi import color # pylint: disable=import-error
@function_tool
def generic_linux_command(command: str = "",
args: str = "", ctf=None,
async_mode: bool = False,
interactive: bool = False,
session_id: str = None) -> str:
"""
Execute Linux commands with support for interactive sessions.
Execute commands with session management.
Two modes:
1. Regular command execution:
- For normal Linux commands
- Example: generic_linux_command("ls", "-la")
2. Session management:
- For interactive commands(ssh)
- Workflow:
- Start: generic_linux_command("ssh", "user@host")
Returns session ID
- List: generic_linux_command("session", "list")
- Get output: generic_linux_command("session", "output <id>")
- Send input: Use session_id parameter
- End: generic_linux_command("session", "kill <id>")
Use this tool to run any command. The system automatically detects and handles:
- Regular commands (ls, cat, grep, etc.)
- Interactive commands that need persistent sessions (ssh, nc, python, etc.)
- Session management and output capture
- CTF environments (automatically detected and used when available)
- Container environments (automatically detected and used when available)
- SSH environments (automatically detected and used when available)
Args:
command: Command name
args: Command arguments
ctf: CTF environment object
async_mode: Force async session
session_id: Existing session ID
command: The complete command to execute (e.g., "ls -la", "ssh user@host", "cat file.txt")
interactive: Set to True for commands that need persistent sessions (ssh, nc, python, ftp etc.)
Leave False for regular commands
session_id: Use existing session ID to send commands to running interactive sessions.
Get session IDs from previous interactive command outputs.
Examples:
- Regular command: generic_linux_command("ls -la")
- Interactive command: generic_linux_command("ssh user@host", interactive=True)
- Send to session: generic_linux_command("pwd", session_id="abc12345")
- List sessions: generic_linux_command("session list")
- Kill session: generic_linux_command("session kill abc12345")
- Environment info: generic_linux_command("env info")
Environment Detection:
The system automatically detects and uses the appropriate execution environment:
- CTF: Commands run in the CTF challenge environment when available
- Container: Commands run in Docker containers when CAI_ACTIVE_CONTAINER is set
- SSH: Commands run via SSH when SSH_USER and SSH_HOST are configured
- Local: Commands run on the local system as fallback
Returns:
Command output, session ID, or status message
Command output, session ID for interactive commands, or status message
"""
# Special commands for session management
if command == "session":
if args == "list":
# Handle special session management commands
if command.startswith("session "):
parts = command.split(" ", 2)
if len(parts) < 2:
return "Usage: session list|output|kill [session_id]"
action = parts[1]
if action == "list":
sessions = list_shell_sessions()
if not sessions:
return "No active sessions"
@ -61,66 +74,75 @@ def generic_linux_command(command: str = "",
f"Last activity: {session['last_activity']}\n")
return result
if args.startswith("output "):
session_id = args.split(" ")[1]
# Call get_session_output with stdout=True to display via cli_print_tool_output
# The function will handle the display and return a simple confirmation message
output = get_session_output(session_id, clear=False, stdout=True)
elif action == "output" and len(parts) >= 3:
target_session_id = parts[2]
output = get_session_output(target_session_id, clear=False, stdout=True)
return output
if args.startswith("kill "):
session_id = args.split(" ")[1]
return terminate_session(session_id)
elif action == "kill" and len(parts) >= 3:
target_session_id = parts[2]
return terminate_session(target_session_id)
return """Unknown session command.
Available: list, output <id>, kill <id>"""
return "Usage: session list|output <id>|kill <id>"
# BUGFIX: Detect if arguments are swapped due to framework issue
# Sometimes async_mode gets the session_id value and session_id gets boolean values
if (isinstance(async_mode, str) and len(async_mode) == 8 and
session_id in [None, True, False]):
# Arguments are swapped - fix them
actual_session_id = async_mode
actual_async_mode = False # Default to False when session_id is provided
session_id = actual_session_id
async_mode = actual_async_mode
# Handle environment information command
if command.strip() == "env info" or command.strip() == "environment info":
env_info = []
# Check CTF environment
try:
from cai.cli import ctf_global
if ctf_global and hasattr(ctf_global, 'get_shell'):
env_info.append("🎯 CTF Environment: Active")
else:
env_info.append("🎯 CTF Environment: Not available")
except:
env_info.append("🎯 CTF Environment: Not available")
# Check Container environment
active_container = os.getenv("CAI_ACTIVE_CONTAINER", "")
if active_container:
env_info.append(f"🐳 Container: {active_container[:12]}")
else:
env_info.append("🐳 Container: Not active")
# Check SSH environment
ssh_user = os.getenv('SSH_USER')
ssh_host = os.getenv('SSH_HOST')
if ssh_user and ssh_host:
env_info.append(f"🔗 SSH: {ssh_user}@{ssh_host}")
else:
env_info.append("🔗 SSH: Not configured")
# Check workspace
try:
from cai.tools.common import _get_workspace_dir
workspace = _get_workspace_dir()
env_info.append(f"📁 Workspace: {workspace}")
except:
env_info.append("📁 Workspace: Unknown")
return "Current Environment:\n" + "\n".join(env_info)
# Regular command execution
full_command = f'{command} {args}'.strip()
if not command.strip():
return "Error: No command provided"
# Detect if this should be an async command
# Only auto-detect async commands when no session_id is provided
if not async_mode and not session_id:
async_commands = ['ssh', 'python -m http.server', "ftp"]
async_mode = any(cmd in full_command for cmd in async_commands)
# For SSH sessions or async commands, use different timeout
# For SSH sessions or interactive commands, use different timeout
if session_id:
timeout = 10
else:
timeout = 100
# Check if streaming should be enabled
stream = os.getenv('CAI_STREAM', 'false').lower() == 'true'
# Command streaming should be independent of LLM streaming
stream = True # Always enable streaming for commands
# Generate a call_id for streaming if needed
# Only use call_id for streaming mode to prevent duplicate panels
# When CAI_STREAM=true, we want the Tool Execution panel but not the Tool Output panel
call_id = str(uuid.uuid4())[:8] if stream else None
# In streaming mode, prevent displaying both panels by forcing a call_id
# This tricks cli_print_tool_output into thinking it's being called for a streaming update
if stream and not call_id:
call_id = str(uuid.uuid4())[:8]
# Generate a call_id for streaming
call_id = str(uuid.uuid4())[:8]
# Run the command with the appropriate parameters - pass the actual tool name!
result = run_command(full_command, ctf=ctf,
async_mode=async_mode, session_id=session_id,
# Run the command with the appropriate parameters
result = run_command(command, ctf=None,
async_mode=interactive, session_id=session_id,
timeout=timeout, stream=stream, call_id=call_id,
tool_name="generic_linux_command")
# For better output formatting, if we're in streaming mode, we can modify the output
# to include any additional information needed while still preventing the duplicate panel
return result

View File

@ -1343,6 +1343,18 @@ def cli_print_agent_messages(agent_name, message, counter, model, debug, # pyli
parsed_message = parse_message_content(message)
tool_panels = []
# Special handling for async session messages
if tool_output and ("Started async session" in tool_output or "session" in tool_output.lower()):
# For async session creation, show the session message as the main content
if not parsed_message or parsed_message == "null" or parsed_message == "":
parsed_message = tool_output
else:
# If there's already content, append the session message
parsed_message = f"{parsed_message}\n\n{tool_output}"
# Clear tool_panels to avoid duplication since we're showing the session message as main content
tool_panels = []
# Skip empty panels - THIS IS THE KEY CHANGE
# If suppress_empty is True and there's no parsed message and no tool panels,
# don't create an empty panel to avoid cluttering during streaming
@ -1875,6 +1887,12 @@ def cli_print_tool_output(tool_name="", args="", output="", call_id=None, execut
if isinstance(args, dict):
# If args is a dictionary, extract the 'args' field.
effective_command_args_str = args.get("args", "")
# For session commands, also include the actual command to make it unique
if "command" in args and args.get("session_id"):
# For async session commands, include the full command to differentiate
effective_command_args_str = f"{args.get('command', '')}:{effective_command_args_str}"
# Also include session_id to make it unique per session
effective_command_args_str += f":session_{args.get('session_id', '')}"
elif isinstance(args, str):
# If args is a string, it might be a JSON representation or a plain string.
try:
@ -1882,6 +1900,11 @@ def cli_print_tool_output(tool_name="", args="", output="", call_id=None, execut
if isinstance(parsed_json_args, dict):
# Parsed as JSON dict, get the 'args' field.
effective_command_args_str = parsed_json_args.get("args", "")
# For session commands, also include the actual command
if "command" in parsed_json_args and parsed_json_args.get("session_id"):
effective_command_args_str = f"{parsed_json_args.get('command', '')}:{effective_command_args_str}"
# Also include session_id to make it unique per session
effective_command_args_str += f":session_{parsed_json_args.get('session_id', '')}"
else:
# Parsed as JSON, but not a dict (e.g., a JSON string literal).
effective_command_args_str = parsed_json_args if isinstance(parsed_json_args, str) else args
@ -1897,6 +1920,19 @@ def cli_print_tool_output(tool_name="", args="", output="", call_id=None, execut
call_counter = args["call_counter"]
command_key += f":counter_{call_counter}"
# For async session inputs, add timestamp to ensure uniqueness
# This prevents duplicate detection for different commands sent to the same session
if isinstance(args, dict) and args.get("session_id") and args.get("input_to_session"):
# Add a timestamp component to make each session input unique
import time
command_key += f":ts_{int(time.time() * 1000)}"
# Special handling for auto_output commands - they should always display
# even if a similar command was shown before
if isinstance(args, dict) and args.get("auto_output"):
# Add auto_output flag to the key to differentiate from manual commands
command_key += ":auto_output"
# --- End of Command Key Generation ---
# Check for duplicate display conditions

View File

@ -1,51 +1,103 @@
import os
import pytest
import json
import asyncio
from unittest.mock import MagicMock
# Set test environment variables to avoid OpenAI client initialization errors
os.environ["OPENAI_API_KEY"] = "test_key_for_ci_environment"
from cai.tools.reconnaissance.generic_linux_command import generic_linux_command
from cai.tools.reconnaissance.generic_linux_command import generic_linux_command
from cai.sdk.agents import RunContextWrapper
async def test_generic_linux_command_regular_commands():
"""Test the execution of a regular command using the generic Linux command tool."""
mock_ctx = MagicMock() # Create a mock context for the command execution
params = {
"command": "echo", # Command to be executed
"args": "'hello'" # Arguments for the command
}
# Invoke the tool with the specified parameters and await the result
result = await generic_linux_command.on_invoke_tool(mock_ctx, json.dumps(params))
@pytest.mark.asyncio
async def test_generic_linux_command_echo():
"""Test the execution of echo command using generic_linux_command."""
args = {"command": "echo 'hello'"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
assert result.strip() == 'hello'
# Assert that the result matches the expected output
assert result.replace("\n", "") == 'hello'
@pytest.mark.asyncio
async def test_generic_linux_command_ls():
"""Test the execution of the 'ls' command using the generic Linux command tool."""
mock_ctx = MagicMock() # Create a mock context for the command execution
params = {
"command": "ls", # Command to be executed
"args": "-l" # Arguments for the command
}
"""Test the execution of ls command using generic_linux_command."""
args = {"command": "ls -l"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
# Check that the output contains typical ls -l indicators
assert "total" in result or "drwx" in result or "-rw" in result
# Invoke the tool with the specified parameters and await the result
result = await generic_linux_command.on_invoke_tool(mock_ctx, json.dumps(params))
# Assert that the output contains 'total', which is typical for 'ls -l'
assert "total" in result
@pytest.mark.asyncio
async def test_generic_linux_command_invalid_command():
"""Test the handling of an invalid command using the generic Linux command tool."""
mock_ctx = MagicMock() # Create a mock context for the command execution
params = {
"command": "invalid_command", # Invalid command to be executed
"args": "" # No arguments for the command
}
"""Test handling of invalid command using generic_linux_command."""
args = {"command": "invalid_command_xyz123"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
# Check for common error indicators
assert ("not found" in result.lower() or
"command not found" in result.lower() or
"no such file" in result.lower())
# Invoke the tool with the specified parameters and await the result
result = await generic_linux_command.on_invoke_tool(mock_ctx, json.dumps(params))
# Assert that the result indicates the command was not found
assert "not found" in result
@pytest.mark.asyncio
async def test_generic_linux_command_empty_command():
"""Test handling of empty command using generic_linux_command."""
args = {"command": ""}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
assert "Error: No command provided" in result
@pytest.mark.asyncio
async def test_generic_linux_command_session_list():
"""Test session list functionality using generic_linux_command."""
args = {"command": "session list"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
assert "No active sessions" in result or "Active sessions:" in result
@pytest.mark.asyncio
async def test_generic_linux_command_env_info():
"""Test environment info functionality using generic_linux_command."""
args = {"command": "env info"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
assert "Current Environment:" in result
assert "CTF Environment:" in result
assert "Container:" in result
assert "SSH:" in result
assert "Workspace:" in result
@pytest.mark.asyncio
async def test_generic_linux_command_interactive_flag():
"""Test interactive flag functionality using generic_linux_command."""
# Test with interactive=True but a simple command
args = {"command": "echo 'test'", "interactive": True}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
# Should still work, just might have different session handling
assert "test" in result
@pytest.mark.asyncio
async def test_generic_linux_command_with_session_id():
"""Test session_id parameter using generic_linux_command."""
# Test with a non-existent session_id
args = {"command": "echo 'test'", "session_id": "nonexistent123"}
result = await generic_linux_command.on_invoke_tool(
RunContextWrapper(None), json.dumps(args)
)
# Should handle gracefully - either execute or give session error
assert isinstance(result, str)