PixelCraftLab/sysadmin_troubleshooter
0
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""SysAdmin Environment implementation."""8 9from __future__ import annotations10 11import re12from typing import Any, Dict, List, Optional13from uuid import uuid414 15from openenv.core.env_server.interfaces import Environment16from openenv.core.env_server.types import State17from openenv.core.rubrics.base import Rubric18 19try:20 from ..models import KernelAction, KernelObservation21except ImportError:22 from models import KernelAction, KernelObservation23 24 25# ---------------------------------------------------------------------------26# Per-task Rubrics — scores STRICTLY in (0, 1): 0.1 = not done, 0.9 = done27# ---------------------------------------------------------------------------28 29class Task1KillRogueRubric(Rubric):30 """31 Grader for Task 1: Kill the rogue_app process.32 33 Score is strictly between 0 and 1:34 0.1 — rogue_app is still running (task not done)35 0.9 — rogue_app has been killed (task done)36 """37 38 def forward(self, action: Any, observation: Any) -> float:39 tasks: Dict = getattr(observation, "tasks_status", {}) or {}40 done: bool = bool(tasks.get("task_1_kill_rogue", False))41 # Never return exactly 0.0 or 1.0 — strictly in (0, 1)42 return 0.9 if done else 0.143 44 45class Task2NginxActiveRubric(Rubric):46 """47 Grader for Task 2: Start the nginx service.48 49 Score is strictly between 0 and 1:50 0.1 — nginx is still inactive (task not done)51 0.9 — nginx is active (task done)52 """53 54 def forward(self, action: Any, observation: Any) -> float:55 tasks: Dict = getattr(observation, "tasks_status", {}) or {}56 done: bool = bool(tasks.get("task_2_nginx_active", False))57 return 0.9 if done else 0.158 59 60class Task3NginxConfigRubric(Rubric):61 """62 Grader for Task 3: Fix the nginx config typo ('liten' -> 'listen').63 64 Score is strictly between 0 and 1:65 0.1 — typo still present (task not done)66 0.5 — typo fixed but nginx not yet restarted (partial credit)67 0.9 — typo fixed and nginx is active (task fully done)68 """69 70 def forward(self, action: Any, observation: Any) -> float:71 tasks: Dict = getattr(observation, "tasks_status", {}) or {}72 state: Dict = getattr(observation, "system_state", {}) or {}73 74 config_fixed: bool = bool(tasks.get("task_3_nginx_config_fixed", False))75 nginx_active: bool = "nginx" in (state.get("active_services") or [])76 77 if config_fixed and nginx_active:78 return 0.9 # fully done79 elif config_fixed:80 return 0.5 # typo fixed, nginx not yet started81 else:82 return 0.1 # nothing done yet83 84 85class SysAdminRubric(Rubric):86 """87 Master rubric for the SysAdmin environment.88 89 Aggregates 3 per-task child rubrics with weighted scoring.90 Weights: task_1=0.20, task_2=0.30, task_3=0.5091 """92 93 def __init__(self) -> None:94 super().__init__()95 # Registering as attributes auto-registers them as named children96 self.task_1_kill_rogue = Task1KillRogueRubric()97 self.task_2_nginx_active = Task2NginxActiveRubric()98 self.task_3_nginx_config_fixed = Task3NginxConfigRubric()99 100 def forward(self, action: Any, observation: Any) -> float:101 s1 = self.task_1_kill_rogue(action, observation)102 s2 = self.task_2_nginx_active(action, observation)103 s3 = self.task_3_nginx_config_fixed(action, observation)104 # Weighted aggregate — also stays strictly in (0, 1)105 score = s1 * 0.20 + s2 * 0.30 + s3 * 0.50106 return round(score, 4)107 108 109# ---------------------------------------------------------------------------110# Mock Linux system simulator111# ---------------------------------------------------------------------------112 113class MockSystem:114 """Simulates a Linux-like system state for safely running SysAdmin tasks."""115 116 def __init__(self):117 self.processes = {118 1: {"name": "systemd", "cpu": 0.1},119 1024: {"name": "rogue_app", "cpu": 15.5},120 2048: {"name": "sshd", "cpu": 0.2},121 }122 self.services = {123 "nginx": {"status": "inactive", "enabled": True},124 "ssh": {"status": "active", "enabled": True},125 "cron": {"status": "active", "enabled": True},126 }127 self.files = {128 "/etc/nginx/nginx.conf": "server {\n liten 80;\n server_name localhost;\n}",129 "/var/log/syslog": "Apr 6 09:00:00 localhost systemd[1]: Started SSH service.\n",130 }131 self.last_command_output = ""132 133 def run_command(self, command: str) -> tuple[str, str, int]:134 """Simple shell command parser."""135 parts = command.strip().split()136 if not parts:137 return "", "", 0138 139 cmd = parts[0]140 args = parts[1:]141 142 if cmd == "ps":143 output = "PID COMMAND %CPU\n"144 for pid, info in self.processes.items():145 output += f"{pid:<5} {info['name']:<12} {info['cpu']}\n"146 return output, "", 0147 148 elif cmd == "kill":149 if not args:150 return "", "kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]", 1151 try:152 pid = int(args[0])153 if pid in self.processes:154 del self.processes[pid]155 return "", "", 0156 else:157 return "", f"kill: ({pid}) - No such process", 1158 except ValueError:159 return "", f"kill: {args[0]}: arguments must be process IDs", 1160 161 elif cmd == "killall":162 if not args:163 return "", "killall: usage: killall [-Z context] [-e] [-g] [-i] [-m] [-o] [-q] [-r] [-s signal] [-u user] [-v] [-w] [-I] [-V] name ...", 1164 name = args[0]165 to_kill = [pid for pid, info in self.processes.items() if info["name"] == name]166 if not to_kill:167 return "", f"{name}: no process found", 1168 for pid in to_kill:169 del self.processes[pid]170 return "", "", 0171 172 elif cmd == "systemctl":173 if len(args) < 2:174 return "", "systemctl: too few arguments", 1175 action = args[0]176 service = args[1]177 if service not in self.services:178 return "", f"Failed to {action} {service}.service: Unit {service}.service not found.", 5179 180 if action == "status":181 status = self.services[service]["status"]182 output = f"● {service}.service\n Loaded: loaded\n Active: {status}\n"183 return output, "", 0184 elif action == "start":185 # nginx fails if config has typo186 if service == "nginx" and "liten" in self.files.get("/etc/nginx/nginx.conf", ""):187 return "", "Job for nginx.service failed because the control process exited with error-code.", 1188 self.services[service]["status"] = "active"189 return "", "", 0190 elif action == "stop":191 self.services[service]["status"] = "inactive"192 return "", "", 0193 elif action == "restart":194 if service == "nginx" and "liten" in self.files.get("/etc/nginx/nginx.conf", ""):195 return "", "Job for nginx.service failed. See 'systemctl status nginx.service' and 'journalctl -xe' for details.", 1196 self.services[service]["status"] = "active"197 return "", "", 0198 199 elif cmd == "cat":200 if not args:201 return "", "cat: usage: cat [file ...]", 1202 path = args[0]203 if path in self.files:204 return self.files[path], "", 0205 return "", f"cat: {path}: No such file or directory", 1206 207 elif cmd == "sed":208 # Simple implementation for 'sed -i 's/old/new/g' file'209 if "-i" in args:210 try:211 pattern_idx = args.index("-i") + 1212 file_idx = pattern_idx + 1213 pattern = args[pattern_idx]214 path = args[file_idx]215 216 if path not in self.files:217 return "", f"sed: can't read {path}: No such file or directory", 2218 219 match = re.match(r"s/(.*)/(.*)/g", pattern.strip("'"))220 if match:221 old, new = match.groups()222 self.files[path] = self.files[path].replace(old, new)223 return "", "", 0224 except (ValueError, IndexError):225 pass226 return "", "sed: invalid option or pattern", 1227 228 return "", f"bash: {cmd}: command not found", 127229 230 231# ---------------------------------------------------------------------------232# KernelEnvironment233# ---------------------------------------------------------------------------234 235class KernelEnvironment(Environment):236 """237 Real-world SysAdmin troubleshooting environment.238 239 The agent must:240 1. [Easy] Kill the rogue_app process (PID 1024)241 2. [Medium] Start the nginx service242 3. [Hard] Fix the typo in /etc/nginx/nginx.conf and restart nginx243 244 Each task is scored by a dedicated Rubric child (score strictly in (0,1)).245 """246 247 SUPPORTS_CONCURRENT_SESSIONS: bool = True248 249 def __init__(250 self,251 *,252 max_steps: int = 15,253 transform: Optional[Any] = None,254 rubric: Optional[Any] = None,255 ):256 # Always use SysAdminRubric (override any passed-in rubric)257 super().__init__(transform=transform, rubric=SysAdminRubric())258 self._max_steps = max_steps259 self._reset_count = 0260 self._terminated = False261 self._cumulative_reward = 0.0262 self._system = MockSystem()263 self._state = self._build_state(episode_id=str(uuid4()))264 265 def _build_state(self, *, episode_id: str, step_count: int = 0) -> State:266 return State(267 episode_id=episode_id,268 step_count=step_count,269 terminated=self._terminated,270 max_steps=self._max_steps,271 cumulative_reward=round(self._cumulative_reward, 4),272 reset_count=self._reset_count,273 )274 275 def _get_system_summary(self) -> Dict[str, Any]:276 return {277 "active_services": [s for s, v in self._system.services.items() if v["status"] == "active"],278 "running_processes": [p["name"] for p in self._system.processes.values()],279 "nginx_config_ok": "liten" not in self._system.files.get("/etc/nginx/nginx.conf", ""),280 }281 282 def _get_tasks_status(self) -> Dict[str, bool]:283 summary = self._get_system_summary()284 return {285 "task_1_kill_rogue": "rogue_app" not in summary["running_processes"],286 "task_2_nginx_active": "nginx" in summary["active_services"],287 "task_3_nginx_config_fixed": summary["nginx_config_ok"],288 }289 290 def _compute_reward(self) -> float:291 """292 Per-step reward: incremental progress since last step.293 Returns a value in [0, 1). Never exactly 1.0 (max 0.5+0.3+0.2=1.0294 but cumulative prevents reaching exact 1.0 in a single step).295 """296 status = self._get_tasks_status()297 reward = 0.0298 if status["task_1_kill_rogue"]:299 reward += 0.2300 if status["task_2_nginx_active"]:301 reward += 0.3302 if status["task_3_nginx_config_fixed"]:303 reward += 0.5304 305 step_reward = max(0.0, reward - self._cumulative_reward)306 return round(step_reward, 4)307 308 def reset(309 self,310 seed: Optional[int] = None,311 episode_id: Optional[str] = None,312 **_: Any,313 ) -> KernelObservation:314 del seed315 self._reset_rubric()316 self._reset_count += 1317 self._terminated = False318 self._cumulative_reward = 0.0319 self._system = MockSystem()320 self._state = self._build_state(episode_id=episode_id or str(uuid4()))321 322 return KernelObservation(323 stdout=(324 "System boot complete. Welcome to SysAdmin Shell.\n"325 "Tasks:\n"326 "1. [Easy] Kill the rogue_app process (PID 1024).\n"327 "2. [Medium] Start the nginx service.\n"328 "3. [Hard] Fix the typo in /etc/nginx/nginx.conf "329 "('liten' -> 'listen') and restart nginx."330 ),331 system_state=self._get_system_summary(),332 tasks_status=self._get_tasks_status(),333 done=False,334 reward=0.0,335 metadata={336 "episode_id": self._state.episode_id,337 "step_count": self._state.step_count,338 },339 )340 341 def step(342 self,343 action: KernelAction,344 timeout_s: Optional[float] = None,345 **_: Any,346 ) -> KernelObservation:347 if self._terminated:348 raise RuntimeError("episode is terminated; call reset() before step() again")349 350 self._state.step_count += 1351 352 stdout, stderr, exit_code = self._system.run_command(action.command)353 354 step_reward = self._compute_reward()355 self._cumulative_reward += step_reward356 357 tasks = self._get_tasks_status()358 self._terminated = (self._state.step_count >= self._max_steps) or all(tasks.values())359 360 observation = KernelObservation(361 stdout=stdout,362 stderr=stderr,363 exit_code=exit_code,364 system_state=self._get_system_summary(),365 tasks_status=tasks,366 done=self._terminated,367 reward=step_reward,368 metadata={369 "episode_id": self._state.episode_id,370 "step_count": self._state.step_count,371 "cumulative_reward": round(self._cumulative_reward, 4),372 },373 )374 375 self._state = self._build_state(376 episode_id=self._state.episode_id,377 step_count=self._state.step_count,378 )379 380 return self._apply_transform(observation)381 382 @property383 def state(self) -> State:384 return self._state385 386 def get_metadata(self):387 metadata = super().get_metadata()388 metadata.description = (389 "A real-world SysAdmin troubleshooting environment where an agent "390 "identifies and fixes system issues using shell commands."391 )392 return metadata393 