"""Remote execution support for Obliteratus. Run abliteration pipelines on remote GPU nodes via SSH. The remote machine must have CUDA-capable GPUs and a Python environment. Obliteratus will be auto-installed if not present. Usage (CLI): obliteratus obliterate meta-llama/Llama-3.1-8B-Instruct \ --remote user@gpu-node \ --ssh-key ~/.ssh/id_rsa Usage (YAML config): remote: host: gpu-node user: root ssh_key: ~/.ssh/id_rsa remote_dir: /tmp/obliteratus_run """ from __future__ import annotations import os import queue import shlex import subprocess import tempfile import threading import time from dataclasses import dataclass from pathlib import Path from typing import Callable from rich.console import Console import yaml from obliteratus import __version__ from obliteratus.remote_contracts import ( parse_remote_target, remote_python_command, remote_scp_spec, validate_remote_settings, ) console = Console() @dataclass class RemoteConfig: """SSH connection and remote execution settings.""" host: str user: str = "root" port: int = 22 ssh_key: str | None = None known_hosts_file: str | None = None remote_dir: str = "/tmp/obliteratus_run" install_timeout: int = 600 # seconds python: str = "python3" # remote python binary sync_results: bool = True gpus: str | None = None # comma-separated GPU IDs or "all" install_source: str = "git+https://github.com/elder-plinius/OBLITERATUS.git" def __post_init__(self) -> None: self.gpus = validate_remote_settings( host=self.host, user=self.user, port=self.port, remote_dir=self.remote_dir, python=self.python, gpus=self.gpus, install_source=self.install_source, ) if ( isinstance(self.install_timeout, bool) or not isinstance(self.install_timeout, int) or self.install_timeout <= 0 ): raise ValueError("remote install timeout must be a positive integer") @property def ssh_target(self) -> str: return f"{self.user}@{self.host}" @classmethod def from_cli_args(cls, remote_str: str, **kwargs) -> RemoteConfig: """Parse 'user@host' or just 'host' from CLI --remote flag.""" user, host = parse_remote_target(remote_str) return cls(host=host, user=user, **kwargs) @classmethod def from_dict(cls, d: dict) -> RemoteConfig: return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) class RemoteRunner: """Execute Obliteratus commands on a remote machine via SSH.""" def __init__( self, config: RemoteConfig, on_log: Callable[[str], None] | None = None, ): self.config = config self.on_log = on_log or (lambda msg: console.print(f"[dim][remote][/] {msg}")) def _ssh_base_cmd(self) -> list[str]: """Build base SSH command with common options.""" cmd = [ "ssh", "-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes", "-o", "ConnectTimeout=30", "-p", str(self.config.port), ] if self.config.known_hosts_file: known_hosts = os.path.expanduser(self.config.known_hosts_file) cmd.extend(["-o", f"UserKnownHostsFile={known_hosts}"]) if self.config.ssh_key: key_path = os.path.expanduser(self.config.ssh_key) cmd.extend(["-i", key_path]) cmd.append(self.config.ssh_target) return cmd def _scp_base_cmd(self) -> list[str]: """Build base SCP command.""" cmd = [ "scp", "-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes", "-P", str(self.config.port), "-r", ] if self.config.known_hosts_file: known_hosts = os.path.expanduser(self.config.known_hosts_file) cmd.extend(["-o", f"UserKnownHostsFile={known_hosts}"]) if self.config.ssh_key: key_path = os.path.expanduser(self.config.ssh_key) cmd.extend(["-i", key_path]) return cmd def run_ssh(self, remote_cmd: str, stream: bool = False, timeout: int | None = None) -> subprocess.CompletedProcess | int: """Run a command on the remote host. If stream=True, streams stdout/stderr in real-time and returns the exit code. Otherwise returns CompletedProcess. """ cmd = self._ssh_base_cmd() + [remote_cmd] if stream: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) if proc.stdout is None: proc.kill() proc.wait() raise RuntimeError("remote process did not expose a stdout stream") output: queue.Queue[object] = queue.Queue() finished = object() def read_output() -> None: try: for line in proc.stdout: if not isinstance(line, str): raise RuntimeError("remote process emitted non-text output") output.put(line.rstrip("\n")) except BaseException as exc: output.put(exc) finally: output.put(finished) threading.Thread(target=read_output, daemon=True).start() deadline = time.monotonic() + timeout if timeout is not None else None try: while True: remaining = None if deadline is None else max(0, deadline - time.monotonic()) try: item = output.get(timeout=remaining) except queue.Empty as exc: raise subprocess.TimeoutExpired(cmd, timeout) from exc if item is finished: break if isinstance(item, BaseException): raise item self.on_log(item) remaining = None if deadline is None else max(0, deadline - time.monotonic()) proc.wait(timeout=remaining) except subprocess.TimeoutExpired: proc.kill() proc.wait() self.on_log("[red]Remote command timed out[/]") return 124 except KeyboardInterrupt: proc.kill() proc.wait() self.on_log("[yellow]Remote command cancelled[/]") raise return proc.returncode else: return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, ) def check_connection(self) -> bool: """Verify SSH connectivity.""" self.on_log(f"Testing SSH connection to {self.config.ssh_target}...") result = self.run_ssh("echo ok", timeout=30) if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0: self.on_log("SSH connection OK") return True self.on_log("[red]SSH connection failed[/]") return False def check_gpu(self) -> str | None: """Check for CUDA GPUs on remote. Returns nvidia-smi output or None.""" result = self.run_ssh( "nvidia-smi --query-gpu=index,name,memory.total,memory.free --format=csv,noheader", timeout=30, ) if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0: gpu_info = result.stdout.strip() lines = gpu_info.split("\n") self.on_log(f"Remote GPUs ({len(lines)} detected):") for line in lines: self.on_log(f" {line.strip()}") if self.config.gpus and self.config.gpus.lower() != "all": self.on_log(f" Selected GPUs: {self.config.gpus}") else: self.on_log(f" Using: all {len(lines)} GPUs") return gpu_info self.on_log("[yellow]No GPUs detected on remote (nvidia-smi failed)[/]") return None def ensure_obliteratus(self) -> bool: """Install or update obliteratus on the remote if needed.""" version_command = remote_python_command( self.config.python, ["-c", "import obliteratus; print(obliteratus.__version__)"], ) check = self.run_ssh( version_command, timeout=30, ) if ( isinstance(check, subprocess.CompletedProcess) and check.returncode == 0 and check.stdout.strip() == __version__ ): self.on_log(f"Obliteratus {__version__} already installed on remote") return True self.on_log(f"Installing Obliteratus {__version__} on remote...") install_cmd = remote_python_command( self.config.python, ["-m", "pip", "install", "--quiet", "--upgrade", self.config.install_source], ) rc = self.run_ssh(install_cmd, stream=True, timeout=self.config.install_timeout) if rc != 0: self.on_log("[red]Failed to install obliteratus on remote[/]") return False verify = self.run_ssh(version_command, timeout=30) if not ( isinstance(verify, subprocess.CompletedProcess) and verify.returncode == 0 and verify.stdout.strip() == __version__ ): self.on_log("[red]Installed Obliteratus version could not be verified[/]") return False self.on_log(f"Obliteratus {__version__} installed successfully") return True def sync_results_back(self, remote_output_dir: str, local_output_dir: str) -> bool: """Copy results from remote back to local machine via scp.""" local_path = Path(local_output_dir) local_path.mkdir(parents=True, exist_ok=True) self.on_log(f"Syncing results: {self.config.ssh_target}:{remote_output_dir} -> {local_output_dir}") cmd = self._scp_base_cmd() + [ remote_scp_spec(self.config.ssh_target, remote_output_dir, directory=True), str(local_path), ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) if result.returncode == 0: self.on_log(f"Results synced to {local_output_dir}") return True else: self.on_log(f"[red]SCP failed: {result.stderr}[/]") return False def build_obliterate_command( self, model: str, output_dir: str | None = None, method: str = "advanced", device: str = "auto", dtype: str = "float16", quantization: str | None = None, gpu_memory_utilization: float | None = None, n_directions: int | None = None, direction_method: str | None = None, regularization: float | None = None, refinement_passes: int | None = None, large_model: bool = False, verify_sample_size: int | None = None, min_layer_fraction: float | None = None, max_layer_fraction: float | None = None, harmless_pc_count: int | None = None, shield_concept_count: int | None = None, shield_ridge: float | None = None, shield_residualize: bool | None = None, shield_layer_penalty: float | None = None, projection_target: str | None = None, projection_row_fraction: float | None = None, refusal_max_tokens: int | None = None, ) -> str: """Build the remote obliteratus CLI command.""" remote_output = output_dir or f"{self.config.remote_dir}/output/{model.replace('/', '_')}" parts = [ "-m", "obliteratus", "obliterate", model, "--output-dir", remote_output, "--method", method, "--device", device, "--dtype", dtype, ] if quantization: parts.extend(["--quantization", quantization]) if gpu_memory_utilization is not None: parts.extend(["--gpu-memory-utilization", str(gpu_memory_utilization)]) if n_directions is not None: parts.extend(["--n-directions", str(n_directions)]) if direction_method: parts.extend(["--direction-method", direction_method]) if regularization is not None: parts.extend(["--regularization", str(regularization)]) if refinement_passes is not None: parts.extend(["--refinement-passes", str(refinement_passes)]) if large_model: parts.append("--large-model") if verify_sample_size is not None: parts.extend(["--verify-sample-size", str(verify_sample_size)]) optional_values = ( ("--min-layer-fraction", min_layer_fraction), ("--max-layer-fraction", max_layer_fraction), ("--harmless-pc-count", harmless_pc_count), ("--shield-concept-count", shield_concept_count), ("--shield-ridge", shield_ridge), ("--shield-layer-penalty", shield_layer_penalty), ("--projection-target", projection_target), ("--projection-row-fraction", projection_row_fraction), ("--refusal-max-tokens", refusal_max_tokens), ) for flag, value in optional_values: if value is not None: parts.extend([flag, str(value)]) if shield_residualize: parts.append("--shield-residualize") return remote_python_command(self.config.python, parts, gpus=self.config.gpus) def build_run_command(self, remote_config_path: str, output_dir: str | None = None, preset: str | None = None) -> str: """Build remote 'obliteratus run' command.""" parts = [ "-m", "obliteratus", "run", remote_config_path, ] if output_dir: parts.extend(["--output-dir", output_dir]) if preset: parts.extend(["--preset", preset]) return remote_python_command(self.config.python, parts, gpus=self.config.gpus) def build_tourney_command( self, model: str, output_dir: str | None = None, device: str = "auto", dtype: str = "float16", quantization: str | None = None, methods: list[str] | None = None, hub_org: str | None = None, hub_repo: str | None = None, dataset: str = "builtin", ) -> str: """Build remote 'obliteratus tourney' command.""" remote_output = output_dir or f"{self.config.remote_dir}/tourney/{model.replace('/', '_')}" parts = [ "-m", "obliteratus", "tourney", model, "--output-dir", remote_output, "--device", device, "--dtype", dtype, "--dataset", dataset, ] if quantization: parts.extend(["--quantization", quantization]) if hub_org: parts.extend(["--hub-org", hub_org]) if hub_repo: parts.extend(["--hub-repo", hub_repo]) if methods: parts.extend(["--methods"] + methods) return remote_python_command(self.config.python, parts, gpus=self.config.gpus) def upload_config(self, local_config_path: str) -> str: """Upload a YAML config without recursively redispatching remotely.""" remote_path = f"{self.config.remote_dir}/config.yaml" self.run_ssh(shlex.join(["mkdir", "-p", self.config.remote_dir])) source = Path(local_config_path) payload = yaml.safe_load(source.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError("remote study config must contain a YAML mapping") payload.pop("remote", None) with tempfile.TemporaryDirectory(prefix="obliteratus-remote-config-") as temp_dir: upload_path = Path(temp_dir) / "config.yaml" upload_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") cmd = self._scp_base_cmd() # scp uses -P not -p, already handled in _scp_base_cmd cmd += [str(upload_path), remote_scp_spec(self.config.ssh_target, remote_path)] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: raise RuntimeError(f"Failed to upload config: {result.stderr}") self.on_log(f"Config uploaded to {remote_path}") return remote_path def run_obliterate( self, model: str, local_output_dir: str | None = None, **kwargs, ) -> str | None: """Full remote obliteration: setup, run, sync results. Returns local path to results, or None on failure. """ # 1. Verify connection if not self.check_connection(): return None # 2. Check GPUs self.check_gpu() # 3. Ensure obliteratus is installed if not self.ensure_obliteratus(): return None # 4. Create remote working directory self.run_ssh(shlex.join(["mkdir", "-p", self.config.remote_dir])) # 5. Build and run the command remote_output = f"{self.config.remote_dir}/output/{model.replace('/', '_')}" cmd = self.build_obliterate_command(model, output_dir=remote_output, **kwargs) self.on_log(f"Running: {cmd}") rc = self.run_ssh(cmd, stream=True) if rc != 0: self.on_log(f"[red]Remote obliteration failed (exit code {rc})[/]") return None # 6. Sync results back if self.config.sync_results: local_output = local_output_dir or f"abliterated/{model.replace('/', '_')}" if self.sync_results_back(remote_output, local_output): return local_output return None self.on_log(f"Results on remote: {remote_output}") return remote_output def run_config( self, local_config_path: str, local_output_dir: str | None = None, preset: str | None = None, ) -> str | None: """Upload config, run study remotely, sync results.""" if not self.check_connection(): return None self.check_gpu() if not self.ensure_obliteratus(): return None # Upload config remote_config = self.upload_config(local_config_path) # Determine remote output dir remote_output = f"{self.config.remote_dir}/results" cmd = self.build_run_command(remote_config, output_dir=remote_output, preset=preset) self.on_log(f"Running: {cmd}") rc = self.run_ssh(cmd, stream=True) if rc != 0: self.on_log(f"[red]Remote run failed (exit code {rc})[/]") return None if self.config.sync_results: local_output = local_output_dir or "results" if self.sync_results_back(remote_output, local_output): return local_output return None return remote_output def run_tourney( self, model: str, local_output_dir: str | None = None, **kwargs, ) -> str | None: """Run tournament remotely, sync results.""" if not self.check_connection(): return None self.check_gpu() if not self.ensure_obliteratus(): return None remote_output = f"{self.config.remote_dir}/tourney/{model.replace('/', '_')}" cmd = self.build_tourney_command(model, output_dir=remote_output, **kwargs) self.on_log(f"Running: {cmd}") rc = self.run_ssh(cmd, stream=True) if rc != 0: self.on_log(f"[red]Remote tourney failed (exit code {rc})[/]") return None if self.config.sync_results: local_output = local_output_dir or f"/tmp/obliteratus_tourney/{model.replace('/', '_')}" if self.sync_results_back(remote_output, local_output): return local_output return None return remote_output