diff --git a/src/sbx/cli.py b/src/sbx/cli.py index 03197c0..4cc3eac 100644 --- a/src/sbx/cli.py +++ b/src/sbx/cli.py @@ -1,143 +1,42 @@ import argparse -import base64 import json import os -import re -import shlex -import shutil import signal import sqlite3 -import subprocess import sys import tempfile import tomllib from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager, suppress -from datetime import UTC, datetime from pathlib import Path from typing import Any import sbx.image.ls import sbx.network as network -from sbx import __version__ +from sbx import ( + __version__, + doctor, + guest_setup, + lifecycle_warnings, + runtime, + session_state, + vm_metadata, + vm_state, +) from sbx.completion import SUPPORTED_SHELLS, completion_script from sbx.constants import ( DEFAULT_BACKEND, DEFAULT_BOOT_TIMEOUT, - SBX_STATE_DIR, - SESSIONS_FILE, SMOLVM_DB_PATH, ) from sbx.image import build_debian -from sbx.runtime import ConfigError as RuntimeConfigError -from sbx.runtime import smolvm_env +from sbx.runtime import ConfigError AGENTS = ("pi", "claude", "codex") MIB = 1024 * 1024 LAUNCH_COMMANDS = {"pi": "pi", "claude": "claude", "codex": "codex"} -USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]*[$]?$", re.IGNORECASE) -VM_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") -ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -FORWARDABLE_ENV_VARS = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY") -SAFE_GIT_CONFIG_KEYS = ( - "user.name", - "user.email", - "init.defaultBranch", - "pull.rebase", - "push.default", - "core.autocrlf", - "core.eol", -) DEFAULT_CONFIG_PATHS = (Path.home() / ".config" / "sbx" / "config.toml",) LOCAL_CONFIG_PATHS = (Path.cwd() / ".sbx.toml",) -DEBUG = False - - -ConfigError = RuntimeConfigError - - -def _debug(message: str) -> None: - if DEBUG: - print(f"sbx debug: {message}", file=sys.stderr) - - -def _debug_command(argv: Sequence[str], env: Mapping[str, str] | None) -> None: - _debug(f"run: {shlex.join(list(argv))}") - active_env = env if env is not None else os.environ - env_source = "custom" if env is not None else "current" - interesting = { - key: active_env.get(key) - for key in ("HOME", "SMOLVM_DATA_DIR", "XDG_STATE_HOME") - if active_env.get(key) is not None - } - _debug(f"env source: {env_source}; {interesting}") - - -def _run(argv: Sequence[str], *, check: bool = False, env: Mapping[str, str] | None = None) -> int: - _debug_command(argv, env) - try: - proc = subprocess.run(list(argv), check=check, env=dict(env) if env is not None else None) - except FileNotFoundError: - print(f"sbx: command not found on PATH: {argv[0]}", file=sys.stderr) - return 127 - except subprocess.CalledProcessError as exc: - _debug(f"return code: {exc.returncode}") - return exc.returncode - _debug(f"return code: {proc.returncode}") - return proc.returncode - - -def _run_capture( - argv: Sequence[str], *, env: Mapping[str, str] | None = None -) -> subprocess.CompletedProcess[str] | None: - _debug_command(argv, env) - try: - result = subprocess.run( - list(argv), - check=False, - text=True, - capture_output=True, - env=dict(env) if env is not None else None, - ) - _debug(f"return code: {result.returncode}") - if result.stdout: - _debug(f"stdout: {result.stdout.strip()[:2000]}") - if result.stderr: - _debug(f"stderr: {result.stderr.strip()[:2000]}") - return result - except FileNotFoundError: - print(f"sbx: command not found on PATH: {argv[0]}", file=sys.stderr) - return None - - -def _smolvm_argv(args: Sequence[str]) -> list[str]: - return [ - sys.executable, - "-c", - "from smolvm.cli.main import main; raise SystemExit(main())", - *args, - ] - - -def _run_smolvm(args: Sequence[str], **kwargs: Any) -> int: - kwargs["env"] = smolvm_env(kwargs.get("env")) - return _run(_smolvm_argv(args), **kwargs) - - -def _run_smolvm_capture( - args: Sequence[str], **kwargs: Any -) -> subprocess.CompletedProcess[str] | None: - kwargs["env"] = smolvm_env(kwargs.get("env")) - return _run_capture(_smolvm_argv(args), **kwargs) - - -def _require(command: str, install_hint: str | None = None) -> bool: - if shutil.which(command): - return True - print(f"sbx: required command not found: {command}", file=sys.stderr) - if install_hint: - print(install_hint, file=sys.stderr) - return False def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: @@ -182,9 +81,9 @@ def load_config(explicit_path: str | None = None) -> dict[str, Any]: config: dict[str, Any] = {} for path in paths: if path.exists(): - _debug(f"loading config: {path}") + runtime.debug(f"loading config: {path}") config = _deep_merge(config, _read_toml(path)) - _debug(f"merged config: {config}") + runtime.debug(f"merged config: {config}") return config @@ -229,6 +128,28 @@ def _resolve_project_path(path_value: str) -> Path: return path.resolve(strict=False) +def _project_identity(args: argparse.Namespace, config: Mapping[str, Any]) -> dict[str, str]: + explicit = getattr(args, "config", None) + if explicit: + config_path = Path(str(explicit)).expanduser().resolve(strict=False) + root = config_path.parent + elif LOCAL_CONFIG_PATHS[0].exists(): + config_path = LOCAL_CONFIG_PATHS[0].resolve(strict=False) + root = config_path.parent + else: + project_path = _cfg(config, "sbx", "project_path") + if project_path is not None: + root = _resolve_project_path(str(project_path)) + config_path = root / ".sbx.toml" + else: + root = Path.cwd().resolve(strict=False) + config_path = root / ".sbx.toml" + return { + "project_root": str(root.expanduser().resolve(strict=False)), + "config_path": str(config_path.expanduser().resolve(strict=False)), + } + + def _same_path_mount(path_value: str) -> str: resolved = _resolve_project_path(path_value) return f"{resolved}:{resolved}" @@ -258,15 +179,39 @@ def _workspace_mounts_from_specs(mounts: Sequence[str], *, writable: bool) -> li return workspace_mounts +def _warn_running_mount_drift( + vm_name: str, mounts: Sequence[str] | None, *, writable_mounts: bool +) -> None: + if mounts is None: + return + current = vm_state.existing_vm_start_config(vm_name) + if current is None or current[0] != "running": + return + desired_mounts = _workspace_mounts_from_specs(mounts, writable=writable_mounts) + if current[1].get("workspace_mounts") != desired_mounts: + print( + f"sbx: VM '{vm_name}' is running with mounts that differ from current config.", + file=sys.stderr, + ) + print("sbx: Current session will use the VM's existing mounts.", file=sys.stderr) + print( + f"sbx: Run `sbx stop {vm_name}` then `sbx run {vm_name}` to apply config mounts.", + file=sys.stderr, + ) + + def _sync_existing_vm_start_config( vm_name: str, mounts: Sequence[str] | None, *, writable_mounts: bool, port_forwards: Sequence[str], + project: Mapping[str, str] | None = None, ) -> None: - db_path = SMOLVM_DB_PATH.expanduser() - if not db_path.exists(): + if project is not None: + vm_metadata.validate_vm_project(vm_name, project) + current = vm_state.existing_vm_start_config(vm_name) + if current is None or current[0] == "running": return desired_mounts = ( @@ -276,12 +221,8 @@ def _sync_existing_vm_start_config( ) desired_forwards = network.port_forwards_from_specs(port_forwards) updated: list[str] = [] - with sqlite3.connect(db_path) as conn: - conn.row_factory = sqlite3.Row - row = conn.execute("SELECT status, config FROM vms WHERE id = ?", (vm_name,)).fetchone() - if row is None or row["status"] == "running": - return - config = json.loads(row["config"]) + with sqlite3.connect(SMOLVM_DB_PATH.expanduser()) as conn: + config = current[1] if desired_mounts is not None and config.get("workspace_mounts") != desired_mounts: config["workspace_mounts"] = desired_mounts updated.append("mounts") @@ -297,126 +238,15 @@ def _sync_existing_vm_start_config( print(f"sbx: updated {', '.join(updated)} for existing VM '{vm_name}'") -def _sync_existing_vm_mounts_from_config( - vm_name: str, mounts: Sequence[str], *, writable_mounts: bool -) -> None: - _sync_existing_vm_start_config( - vm_name, mounts, writable_mounts=writable_mounts, port_forwards=[] - ) - - def _project_guest_cwd(path_value: Any) -> str | None: if path_value is None: return None return str(_resolve_project_path(str(path_value))) -def _validate_run_user(user: str) -> str: - if not USERNAME_RE.match(user): - raise ConfigError("[sbx].run_user must be a valid Linux user name") - return user - - -def _validate_vm_name(name: str) -> str: - if not VM_NAME_RE.match(name): - raise ConfigError( - "[sbx].name must be a valid hostname: lowercase letters, digits, hyphens, " - "1-63 chars, no leading/trailing hyphen" - ) - return name - - -def _validate_env_names(names: list[str]) -> list[str]: - invalid = [name for name in names if not ENV_NAME_RE.match(name)] - if invalid: - raise ConfigError(f"invalid env var name(s): {', '.join(invalid)}") - return names - - -def _sanitize_forwarded_env(env: dict[str, str], allowed: list[str]) -> dict[str, str]: - allowed_set = set(allowed) - for key in FORWARDABLE_ENV_VARS: - if key not in allowed_set: - env.pop(key, None) - return env - - -def _parse_managed_env_script(text: str) -> dict[str, str]: - env: dict[str, str] = {} - for line in text.splitlines(): - line = line.strip() - if line.startswith("export ") and "=" in line: - key, raw_value = line[len("export ") :].split("=", 1) - with suppress(ValueError): - env[key] = (shlex.split(raw_value) or [""])[0] - return env - - -def _sync_forwarded_env_direct_ssh(vm_id: str, values: dict[str, str], missing: list[str]) -> None: - from smolvm.env import ENV_FILE, build_env_script - - ssh_cmd = _ssh_command(vm_id) - completed = _run_capture([*ssh_cmd, f"cat {shlex.quote(ENV_FILE)} 2>/dev/null || true"]) - if completed is None: - raise ConfigError("failed to sync environment: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to read environment: {stderr}") - - env = _parse_managed_env_script(completed.stdout) - env.update(values) - for name in missing: - env.pop(name, None) - - encoded = base64.b64encode(build_env_script(env).encode("utf-8")).decode("ascii") - write_script = f""" -set -eu -_t=$(mktemp /tmp/.smolvm_env.XXXXXXXXXX) -trap 'rm -f "$_t"' EXIT -printf %s {shlex.quote(encoded)} | base64 -d > "$_t" -chmod 0644 "$_t" -mv "$_t" {shlex.quote(ENV_FILE)} -""" - completed = _run_capture([*ssh_cmd, "bash -lc " + shlex.quote(write_script)]) - if completed is None: - raise ConfigError("failed to sync environment: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to write environment: {stderr}") - - -def _sync_forwarded_env(vm_id: str, names: list[str]) -> None: - if not names: - return - values = {name: os.environ[name] for name in names if name in os.environ} - missing = [name for name in names if name not in os.environ] - - from smolvm.facade import SmolVM - - probe = SmolVM.from_id(vm_id) - try: - info = getattr(probe, "_info", None) - comm_channel = getattr(getattr(info, "config", None), "comm_channel", None) - finally: - probe.close() - - if comm_channel is None: - _sync_forwarded_env_direct_ssh(vm_id, values, missing) - return - - vm = SmolVM.from_id(vm_id) - try: - if values: - vm.set_env_vars(values) - if missing: - vm.unset_env_vars(missing) - finally: - vm.close() - - def _sync_forwarded_env_or_error(vm_id: str, names: list[str]) -> bool: try: - _sync_forwarded_env(vm_id, names) + guest_setup.sync_forwarded_env(vm_id, names, run_capture=runtime.run_capture) except Exception as exc: # noqa: BLE001 - keep CLI errors user-friendly. print(f"sbx: failed to sync environment for VM {vm_id!r}: {exc}", file=sys.stderr) return False @@ -449,35 +279,8 @@ def _validate_boot_timeout(value: Any) -> float: return timeout -def _credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict[str, str]: - """Return an environment that prevents SmolVM presets from seeing host credentials.""" - env = _sanitize_forwarded_env(dict(os.environ), forward_env) - real_home = Path.home() - real_smolvm_cache = real_home / ".smolvm" - real_smolvm_data = Path(env.get("SMOLVM_DATA_DIR", real_home / ".local" / "state" / "smolvm")) - temp_home.mkdir(parents=True, exist_ok=True) - real_smolvm_cache.mkdir(parents=True, exist_ok=True) - real_smolvm_data.mkdir(parents=True, exist_ok=True) - (temp_home / ".smolvm").symlink_to(real_smolvm_cache, target_is_directory=True) - - fake_bin = temp_home / ".sbx-bin" - fake_bin.mkdir() - security = fake_bin / "security" - security.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") - security.chmod(0o755) - - env["HOME"] = str(temp_home) - env["SMOLVM_DATA_DIR"] = str(real_smolvm_data) - env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" - _debug( - "credential isolation: " - f"real_cache={real_smolvm_cache}, real_data={real_smolvm_data}, temp_home={temp_home}" - ) - return env - - def _smolvm_info_vm(vm_id: str) -> Mapping[str, Any] | None: - completed = _run_smolvm_capture(["sandbox", "info", vm_id, "--json"]) + completed = runtime.run_smolvm_capture(["sandbox", "info", vm_id, "--json"]) if completed is None or completed.returncode != 0: return None try: @@ -496,132 +299,40 @@ def _get_existing_vm_status(vm_id: str) -> str | None: return status if isinstance(status, str) else None -def _int_or_none(value: Any) -> int | None: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _ceil_mib(size_bytes: int) -> int: - return (size_bytes + MIB - 1) // MIB - - -def _qcow2_virtual_size_mib(path: Path) -> int | None: - qemu_img = shutil.which("qemu-img") - if qemu_img is None: - return None - result = subprocess.run( - [qemu_img, "info", "--output=json", str(path)], - check=False, - text=True, - capture_output=True, - ) - if result.returncode != 0: - return None - try: - info = json.loads(result.stdout) - virtual_size = int(info["virtual-size"]) - except (KeyError, TypeError, ValueError, json.JSONDecodeError): - return None - return _ceil_mib(virtual_size) - - -def _rootfs_size_mib(rootfs_path: Path) -> int | None: - if rootfs_path.suffix.lower() == ".qcow2": - return _qcow2_virtual_size_mib(rootfs_path) - try: - return _ceil_mib(rootfs_path.stat().st_size) - except OSError: - return None - - -def _local_image_rootfs_size_mib(config: Mapping[str, Any]) -> int | None: - image = _path_from_config(_cfg(config, "sbx", "image")) - if image is None: - return None - try: - manifest = _local_image_manifest(image) - rootfs_path = _manifest_path(image, manifest, "rootfs") - if not rootfs_path.is_file(): - return None - except ConfigError: - return None - return _rootfs_size_mib(rootfs_path) - - -def _local_image_disk_size_error(configured_disk_size: int, image_size: int) -> str: - return ( - "configured disk_size is smaller than the local image rootfs:\n" - f" disk_size: {configured_disk_size} MiB\n" - f" local image rootfs: {image_size} MiB\n" - f"Set [sbx].disk_size to at least {image_size}, remove [sbx].disk_size, " - "or rebuild the configured local image with a rootfs no larger than " - f"{configured_disk_size} MiB." - ) - - -def _local_image_config_warnings(config: Mapping[str, Any]) -> list[str]: - configured_disk_size = _int_or_none(_cfg(config, "sbx", "disk_size")) - if configured_disk_size is None: - return [] - image_size = _local_image_rootfs_size_mib(config) - if image_size is None or configured_disk_size >= image_size: - return [] - return [_local_image_disk_size_error(configured_disk_size, image_size)] - - -def _existing_vm_config_mismatches(name: str, config: Mapping[str, Any]) -> list[str]: - vm = _smolvm_info_vm(name) - if vm is None: - return [] - - checks = ( - ("disk_size", "disk_size", " MiB"), - ("memory", "memory", " MiB"), - ("cpus", "vcpus", ""), - ) - mismatches: list[str] = [] - for config_key, vm_key, unit in checks: - configured = _int_or_none(_cfg(config, "sbx", config_key)) - existing = _int_or_none(vm.get(vm_key)) - if configured is None or existing is None or configured == existing: - continue - mismatches.append( - f"{config_key}: config requests {configured}{unit}, " - f"existing VM has {existing}{unit}" - ) - return mismatches - - -def _doctor_config_state(config: Mapping[str, Any]) -> None: - name = _cfg(config, "sbx", "name") - if not name: - return - - vm_name = str(name) - mismatches = _existing_vm_config_mismatches(vm_name, config) - image_warnings = _local_image_config_warnings(config) - if not mismatches and not image_warnings: - return - - print("sbx config/state:") - if mismatches: - print(f" warning: VM '{vm_name}' already exists and differs from .sbx.toml:") - for mismatch in mismatches: - print(f" {mismatch}") - if image_warnings: - print(" warning: configured disk_size is smaller than the local image rootfs:") - for warning in image_warnings: - for line in warning.splitlines(): - print(f" {line}") - if mismatches: - print( - " Existing VMs are reused as-is. " - f"Run `sbx recreate {vm_name} --force` to apply config changes." +def _print_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> None: + widths = [len(header) for header in headers] + for row in rows: + widths = [max(width, len(value)) for width, value in zip(widths, row, strict=True)] + header_line = " ".join( + header.ljust(width) for header, width in zip(headers, widths, strict=True) + ) + print(header_line.rstrip()) + for row in rows: + line = " ".join(value.ljust(width) for value, width in zip(row, widths, strict=True)) + print(line.rstrip()) + + +def cmd_list(args: argparse.Namespace) -> int: + metadata = vm_metadata.load_vm_metadata() + rows = [] + for vm in vm_state.smolvm_vms(all_vms=bool(getattr(args, "all", False))): + name = str(getattr(vm, "vm_id", "-")) + status = getattr(getattr(vm, "status", "-"), "value", getattr(vm, "status", "-")) + rootfs = getattr(getattr(vm, "config", None), "rootfs_path", None) + image_path = Path(rootfs) if rootfs is not None else None + image = "-" if image_path is None else image_path.parent.name or image_path.name or "-" + ssh_port = getattr(getattr(vm, "network", None), "ssh_host_port", None) + rows.append( + [ + name, + str(status), + metadata.get(name, {}).get("project_root", "-"), + image, + str(ssh_port) if ssh_port is not None else "-", + ] ) + _print_table(("NAME", "STATUS", "PROJECT", "IMAGE", "SSH"), rows) + return 0 def _print_boot_timeout_running_hint(vm_id: str, boot_timeout: float) -> None: @@ -639,6 +350,7 @@ def _print_boot_timeout_running_hint(vm_id: str, boot_timeout: float) -> None: "sbx: To make it persistent, set `[sbx].boot_timeout` in .sbx.toml.", file=sys.stderr, ) + print("sbx: If this keeps happening, run `sbx doctor`.", file=sys.stderr) def _maybe_print_boot_timeout_running_hint(vm_id: str | None, boot_timeout: float) -> bool: @@ -650,33 +362,19 @@ def _maybe_print_boot_timeout_running_hint(vm_id: str | None, boot_timeout: floa return True -def _mark_error_vm_stopped_for_restart(vm_id: str) -> None: - db_path = SMOLVM_DB_PATH.expanduser() - if not db_path.exists(): - return - - with sqlite3.connect(db_path) as conn: - conn.execute( - "UPDATE vms SET status = 'stopped', pid = NULL, socket_path = NULL WHERE id = ?", - (vm_id,), - ) - - -def _start_existing_vm_if_needed( - vm_id: str, status: str, boot_timeout: float, *, force_start: bool = False -) -> int: +def _start_existing_vm_if_needed(vm_id: str, status: str, boot_timeout: float) -> int: if status == "running": return 0 if status == "error": - if not force_start: - print( - f"sbx: VM '{vm_id}' is in error state; retry with `--force-start` or run " - f"`sbx recreate {vm_id} --force` to delete it and create a fresh VM.", - file=sys.stderr, - ) - return 1 - _mark_error_vm_stopped_for_restart(vm_id) - rc = _run_smolvm(["sandbox", "start", vm_id, "--boot-timeout", f"{boot_timeout:g}"]) + print(f"sbx: VM '{vm_id}' is in error state.", file=sys.stderr) + print( + "sbx: Run `sbx doctor --fix` to repair local VM bookkeeping, " + f"then retry `sbx run {vm_id}`.", + file=sys.stderr, + ) + print(f"sbx: If it still fails, run `sbx recreate {vm_id} --force`.", file=sys.stderr) + return 1 + rc = runtime.run_smolvm(["sandbox", "start", vm_id, "--boot-timeout", f"{boot_timeout:g}"]) if rc != 0: _maybe_print_boot_timeout_running_hint(vm_id, boot_timeout) return rc @@ -714,339 +412,49 @@ def _extract_started_vm_name(stdout: str) -> str: return name -def _host_git_config() -> str | None: - values: dict[str, str] = {} - for key in SAFE_GIT_CONFIG_KEYS: - try: - completed = subprocess.run( - ["git", "config", "--global", "--get", key], - check=False, - text=True, - capture_output=True, - ) - except FileNotFoundError: - _debug("git not found; skipping git config forwarding") - return None - if completed.returncode == 0: - value = completed.stdout.strip() - if value and "\n" not in value: - values[key] = value - if not values: - _debug("no safe global git config values found to forward") - return None - - sections: dict[str, list[tuple[str, str]]] = {} - for key, value in values.items(): - section, option = key.split(".", 1) - sections.setdefault(section, []).append((option, value)) - - lines: list[str] = [] - for section, entries in sections.items(): - lines.append(f"[{section}]") - for option, value in entries: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - lines.append(f'\t{option} = "{escaped}"') - lines.append("") - return "\n".join(lines) - - -def _set_vm_hostname(vm_id: str) -> None: - hostname = _validate_vm_name(vm_id) - script = r""" -set -eu -hostname "$1" -printf '%s\n' "$1" > /etc/hostname -if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then - sed -i "s/^127\\.0\\.1\\.1.*/127.0.1.1 $1/" /etc/hosts -else - printf '127.0.1.1 %s\n' "$1" >> /etc/hosts -fi -""" - cmd = _ssh_command(vm_id) - cmd.append( - "bash -s -- " + shlex.quote(hostname) + " <<'SBX_HOSTNAME'\n" + script + "SBX_HOSTNAME" - ) - completed = _run_capture(cmd) - if completed is None: - raise ConfigError("failed to set VM hostname: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to set VM hostname: {stderr}") - - -def _install_git_config(vm_id: str, user: str | None, git_config_text: str | None) -> None: - if not git_config_text: - return - if user is None: - home = "/root" - owner = "root:root" - else: - quoted_user = shlex.quote(user) - home = f"/home/{quoted_user}" - owner = f"{quoted_user}:{quoted_user}" - - encoded = base64.b64encode(git_config_text.encode("utf-8")).decode("ascii") - script = f""" -set -eu -install -d {shlex.quote(home)} -printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(home)}/.gitconfig -chown {owner} {shlex.quote(home)}/.gitconfig -chmod 600 {shlex.quote(home)}/.gitconfig -""" - cmd = _ssh_command(vm_id) - cmd.append("bash -lc " + shlex.quote(script)) - completed = _run_capture(cmd) - if completed is None: - raise ConfigError("failed to install git config: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to install git config: {stderr}") - - -def _host_timezone() -> str: - zoneinfo = Path("/usr/share/zoneinfo") - try: - target = Path("/etc/localtime").resolve() - return target.relative_to(zoneinfo).as_posix() - except (OSError, ValueError): - pass - try: - timezone = Path("/etc/timezone").read_text(encoding="utf-8").strip() - except OSError: - return "UTC" - return timezone or "UTC" - - -def _sync_guest_clock(vm_id: str) -> None: - timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") - timezone = _host_timezone() - script = f""" -set -eu -zone={shlex.quote(timezone)} -if [ -f "/usr/share/zoneinfo/$zone" ]; then - ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime - printf '%s\n' "$zone" > /etc/timezone -fi -date -u -s {shlex.quote(timestamp)} -""" - cmd = _ssh_command(vm_id) - cmd.append("bash -lc " + shlex.quote(script)) - completed = _run_capture(cmd) - if completed is None: - raise ConfigError("failed to sync VM clock: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to sync VM clock: {stderr}") - - -def _prepare_run_user(vm_id: str, user: str) -> None: - quoted_user = shlex.quote(user) - home = f"/home/{quoted_user}" - script = f""" -set -eu -host="$(hostname)" -if [ -n "$host" ] && ! getent hosts "$host" >/dev/null 2>&1; then - printf '127.0.1.1 %s\n' "$host" >> /etc/hosts -fi -if ! id -u {quoted_user} >/dev/null 2>&1; then - useradd -m -s /bin/bash {quoted_user} -fi -install -d -o {quoted_user} -g {quoted_user} {home} -for p in .ssh .pi .codex .claude .claude.json; do - if [ -e /root/$p ]; then - rm -rf {home}/$p - cp -a /root/$p {home}/$p - fi -done -chown -R {quoted_user}:{quoted_user} {home} -""" - cmd = _ssh_command(vm_id) - cmd.append("bash -lc " + shlex.quote(script)) - completed = _run_capture(cmd) - if completed is None: - raise ConfigError(f"failed to prepare run user {user!r}: ssh command not found") - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise ConfigError(f"failed to prepare run user {user!r}: {stderr}") - - -def _missing_vm_message(vm_id: str) -> str: - return ( - f"VM {vm_id!r} not found. `sbx shell` attaches to an existing sandbox; " - f"create it with `sbx run {vm_id}` or list VMs with `sbx ls -a`." - ) - - -def _ssh_command(vm_id: str) -> list[str]: - from smolvm.exceptions import VMNotFoundError - from smolvm.facade import SmolVM - - try: - vm = SmolVM.from_id(vm_id) - except VMNotFoundError as exc: - raise ConfigError(_missing_vm_message(vm_id)) from exc - try: - return list(vm._ssh_direct_command()) - finally: - vm.close() - - -def _read_json_object(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError): - return {} - return data if isinstance(data, dict) else {} - - -def _write_json_object(path: Path, data: Mapping[str, Any]) -> None: - SBX_STATE_DIR.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def _pid_is_alive(pid: int) -> bool: - if pid <= 0: - return False - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - return True - - -def _load_sessions() -> dict[str, Any]: - return _read_json_object(SESSIONS_FILE) - - -def _save_sessions(data: Mapping[str, Any]) -> None: - _write_json_object(SESSIONS_FILE, data) - - -def _active_sessions(vm_id: str) -> list[dict[str, Any]]: - data = _load_sessions() - raw_sessions = data.get(vm_id, {}).get("sessions", []) - sessions = [item for item in raw_sessions if isinstance(item, dict)] - active = [ - item - for item in sessions - if isinstance(item.get("pid"), int) and _pid_is_alive(int(item["pid"])) - ] - if active != sessions: - if active: - data.setdefault(vm_id, {})["sessions"] = active - else: - data.pop(vm_id, None) - _save_sessions(data) - return active - - -def _register_session(vm_id: str, kind: str) -> None: - data = _load_sessions() - sessions = _active_sessions(vm_id) - sessions.append({"pid": os.getpid(), "kind": kind}) - data = _load_sessions() - data.setdefault(vm_id, {})["sessions"] = sessions - _save_sessions(data) - _debug(f"registered {kind} session for {vm_id} with pid {os.getpid()}") - - -def _unregister_session(vm_id: str) -> None: - data = _load_sessions() - sessions = data.get(vm_id, {}).get("sessions", []) - remaining = [ - item - for item in sessions - if isinstance(item, dict) - and item.get("pid") != os.getpid() - and isinstance(item.get("pid"), int) - and _pid_is_alive(int(item["pid"])) - ] - if remaining: - data.setdefault(vm_id, {})["sessions"] = remaining - else: - data.pop(vm_id, None) - _save_sessions(data) - _debug(f"unregistered session for {vm_id}; remaining={len(remaining)}") - - def _stop_vm_if_last_session(vm_id: str, *, stop_on_exit: bool) -> None: if not stop_on_exit: - _debug(f"not stopping {vm_id}: stop_on_exit disabled") + runtime.debug(f"not stopping {vm_id}: stop_on_exit disabled") return - if _active_sessions(vm_id): - _debug(f"not stopping {vm_id}: other sbx sessions still active") + if session_state.active_sessions(vm_id): + runtime.debug(f"not stopping {vm_id}: other sbx sessions still active") return - _debug(f"stopping {vm_id}: no other sbx sessions active") - _run_smolvm(["sandbox", "stop", vm_id]) - - -def _attach_as_root(vm_id: str, launch_command: str, cwd: str | None = None) -> int: - from smolvm.env import ENV_FILE - - cmd = _ssh_command(vm_id) - - cd_prefix = f"cd {shlex.quote(cwd)} || exit; " if cwd is not None else "" - remote = ( - f"{cd_prefix}[ -r {ENV_FILE} ] && . {ENV_FILE}; " - 'export PATH="$HOME/.local/bin:$PATH"; ' - f"exec {launch_command}" - ) - cmd.insert(-1, "-t") - cmd.append(remote) - return _run(cmd) - - -def _attach_as_user(vm_id: str, user: str, launch_command: str, cwd: str | None = None) -> int: - from smolvm.env import ENV_FILE - - cmd = _ssh_command(vm_id) - - quoted_user = shlex.quote(user) - cd_prefix = f"cd {shlex.quote(cwd)} || exit; " if cwd is not None else "" - remote = f"sudo -iu {quoted_user} bash -lc " + shlex.quote( - f"{cd_prefix}[ -r {ENV_FILE} ] && . {ENV_FILE}; " - 'export PATH="$HOME/.local/bin:$PATH"; ' - f"exec {launch_command}" - ) - cmd.insert(-1, "-t") - cmd.append(remote) - return _run(cmd) + runtime.debug(f"stopping {vm_id}: no other sbx sessions active") + runtime.run_smolvm(["sandbox", "stop", vm_id]) def _post_start_actions( *, vm_name: str, - agent: str, + command: str, attach: bool, run_user: str | None, auth_port: bool, - auth_host_port: int, - auth_guest_port: int, + auth_host_port: int = 0, + auth_guest_port: int = 0, stop_on_exit: bool, - launch_command: str | None = None, cwd: str | None = None, git_config_text: str | None = None, + session_kind: str = "run", ) -> int: - _sync_guest_clock(vm_name) + guest_setup.sync_guest_clock(vm_name, run_capture=runtime.run_capture) if auth_port: port_rc = network.expose_auth_port(vm_name, auth_host_port, auth_guest_port) if port_rc != 0: return port_rc if not attach: return 0 - command = launch_command or LAUNCH_COMMANDS[agent] - _register_session(vm_name, "run") + session_state.register_session(vm_name, session_kind) try: if run_user is not None: - _prepare_run_user(vm_name, run_user) - _install_git_config(vm_name, run_user, git_config_text) - return _attach_as_user(vm_name, run_user, command, cwd=cwd) - _install_git_config(vm_name, None, git_config_text) - return _attach_as_root(vm_name, command, cwd=cwd) + guest_setup.prepare_run_user(vm_name, run_user, run_capture=runtime.run_capture) + guest_setup.install_git_config( + vm_name, run_user, git_config_text, run_capture=runtime.run_capture + ) + return guest_setup.attach(vm_name, command, user=run_user, cwd=cwd, run=runtime.run) finally: - _unregister_session(vm_name) + remaining = session_state.unregister_session(vm_name) + runtime.debug(f"unregistered session for {vm_name}; remaining={remaining}") _stop_vm_if_last_session(vm_name, stop_on_exit=stop_on_exit) @@ -1064,37 +472,6 @@ def _arg_or_config( return _cfg(config, section, key or attr, default) -def _path_from_config(value: Any) -> Path | None: - if value is None: - return None - return Path(str(value)).expanduser() - - -def _local_image_manifest(image: Path) -> dict[str, Any]: - if not image.is_dir(): - raise ConfigError("[sbx].image must point to a local image directory") - manifest_path = image / "smolvm-image.json" - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise ConfigError(f"image manifest not found: {manifest_path}") from exc - except json.JSONDecodeError as exc: - raise ConfigError(f"invalid image manifest JSON: {manifest_path}: {exc}") from exc - if not isinstance(manifest, dict): - raise ConfigError("image manifest must be a JSON object") - return manifest - - -def _manifest_path(image_dir: Path, manifest: Mapping[str, Any], key: str) -> Path: - value = manifest.get(key) - if not isinstance(value, str) or not value: - raise ConfigError(f"image manifest requires string field {key!r}") - path = Path(value).expanduser() - if not path.is_absolute(): - path = image_dir / path - return path - - def _manifest_sbx(manifest: Mapping[str, Any]) -> Mapping[str, Any]: sbx = manifest.get("sbx", {}) if not isinstance(sbx, Mapping): @@ -1279,7 +656,7 @@ def start() -> str: ) vm.start(boot_timeout=boot_timeout) vm.wait_for_ssh(timeout=boot_timeout) - _set_vm_hostname(str(vm.vm_id)) + guest_setup.set_hostname(str(vm.vm_id), run_capture=runtime.run_capture) ssh = vm._ensure_ssh_for_env() apply_preset(ssh, preset, install_timeout=install_timeout) return str(vm.vm_id) @@ -1287,12 +664,12 @@ def start() -> str: if vm is not None: vm.close() - env = _sanitize_forwarded_env(dict(os.environ), forward_env) + env = guest_setup.sanitize_forwarded_env(dict(os.environ), forward_env) temp_home_ctx = None if not copy_host_credentials: temp_home_ctx = tempfile.TemporaryDirectory(prefix="sbx-no-credentials-") - env = _credential_free_env(Path(temp_home_ctx.name), forward_env=forward_env) - _debug(f"credential-free HOME: {temp_home_ctx.name}") + env = guest_setup.credential_free_env(Path(temp_home_ctx.name), forward_env=forward_env) + runtime.debug(f"credential-free HOME: {temp_home_ctx.name}") try: with _patched_environ(env): @@ -1302,6 +679,7 @@ def start() -> str: temp_home_ctx.cleanup() _maybe_write_project_config(args, config, vm_name=vm_name, agent=agent, created=True) + vm_metadata.record_vm_project(vm_name, _project_identity(args, config)) if json_output: print(json.dumps({"vm": {"name": vm_name, "status": "running"}})) @@ -1313,7 +691,7 @@ def start() -> str: return _post_start_actions( vm_name=vm_name, - agent=agent, + command=LAUNCH_COMMANDS[agent], attach=attach, run_user=run_user, auth_port=auth_port, @@ -1358,8 +736,8 @@ def _start_local_image( if launch_command is not None and not isinstance(launch_command, str): raise ConfigError("image manifest field 'sbx.launch_command' must be a string") - kernel_path = _manifest_path(image_dir, manifest, "kernel") - rootfs_path = _manifest_path(image_dir, manifest, "rootfs") + kernel_path = lifecycle_warnings.manifest_path(image_dir, manifest, "kernel") + rootfs_path = lifecycle_warnings.manifest_path(image_dir, manifest, "rootfs") if not kernel_path.is_file(): raise ConfigError(f"image kernel not found: {kernel_path}") if not rootfs_path.is_file(): @@ -1368,7 +746,7 @@ def _start_local_image( initrd_value = manifest.get("initrd") initrd_path = None if initrd_value is not None: - initrd_path = _manifest_path(image_dir, manifest, "initrd") + initrd_path = lifecycle_warnings.manifest_path(image_dir, manifest, "initrd") if not initrd_path.is_file(): raise ConfigError(f"image initrd not found: {initrd_path}") @@ -1402,9 +780,11 @@ def _start_local_image( vm_config["vcpu_count"] = _validate_cpus(cpus_value) if disk_size is not None: disk_size_mib = int(disk_size) - image_size_mib = _rootfs_size_mib(rootfs_path) + image_size_mib = lifecycle_warnings.rootfs_size_mib(rootfs_path) if image_size_mib is not None and disk_size_mib < image_size_mib: - raise ConfigError(_local_image_disk_size_error(disk_size_mib, image_size_mib)) + raise ConfigError( + lifecycle_warnings.local_image_disk_size_error(disk_size_mib, image_size_mib) + ) vm_config["disk_size_mib"] = disk_size_mib if rootfs_path.suffix.lower() != ".qcow2": vm_config["grow_filesystem"] = True @@ -1421,13 +801,14 @@ def _start_local_image( vm.start(boot_timeout=boot_timeout) vm.wait_for_ssh(timeout=boot_timeout) vm_name = vm.vm_id - _set_vm_hostname(str(vm_name)) + guest_setup.set_hostname(str(vm_name), run_capture=runtime.run_capture) except Exception: vm.close() raise vm.close() _maybe_write_project_config(args, config, vm_name=str(vm_name), agent=agent, created=True) + vm_metadata.record_vm_project(str(vm_name), _project_identity(args, config)) if attach: print(f"Started '{vm_name}'. Launching {agent}...") @@ -1437,23 +818,24 @@ def _start_local_image( print(f"Started '{vm_name}'.") return _post_start_actions( vm_name=vm_name, - agent=agent, + command=launch_command or LAUNCH_COMMANDS[agent], attach=attach, run_user=run_user, auth_port=auth_port, auth_host_port=auth_host_port, auth_guest_port=auth_guest_port, stop_on_exit=stop_on_exit, - launch_command=launch_command, cwd=cwd, git_config_text=git_config_text, ) def cmd_doctor(args: argparse.Namespace) -> int: - rc = _run_smolvm(["doctor", "--backend", DEFAULT_BACKEND]) - _doctor_config_state(getattr(args, "config_data", {})) - return rc + rc = runtime.run_smolvm(["doctor", "--backend", DEFAULT_BACKEND]) + lifecycle_warnings.doctor_config_state( + getattr(args, "config_data", {}), smolvm_info=_smolvm_info_vm + ) + return rc or doctor.run_doctor_checks(fix=bool(getattr(args, "fix", False))) def cmd_completion(args: argparse.Namespace) -> int: @@ -1472,6 +854,8 @@ def cmd_image_ls(args: argparse.Namespace) -> int: def cmd_start(args: argparse.Namespace) -> int: config = args.config_data sbx_cfg = _sbx_config(config) + project_identity = _project_identity(args, config) + project_root = Path(project_identity["project_root"]) if args.name is None and args.name_arg is not None: args.name = args.name_arg @@ -1531,14 +915,14 @@ def cmd_start(args: argparse.Namespace) -> int: attach = True if args.attach is None else bool(args.attach) run_user = _arg_or_config(args, "run_user", config, "sbx") if run_user is not None: - run_user = _validate_run_user(str(run_user)) + run_user = guest_setup.validate_run_user(str(run_user)) copy_host_credentials = bool( _arg_or_config(args, "copy_host_credentials", config, "sbx", default=False) ) - forward_env = _validate_env_names( + forward_env = guest_setup.validate_env_names( args.env if args.env is not None else _list_value(sbx_cfg.get("env"), key="[sbx].env") ) - _debug( + runtime.debug( "run options: " f"agent={agent!r}, name={getattr(args, 'name', None)!r}, attach={attach!r}, " f"run_user={run_user!r}, copy_host_credentials={copy_host_credentials!r}, " @@ -1550,7 +934,7 @@ def cmd_start(args: argparse.Namespace) -> int: auth_guest_port = int(_arg_or_config(args, "auth_guest_port", config, "sbx", default=1455)) stop_on_exit = bool(_arg_or_config(args, "stop_on_exit", config, "sbx", default=True)) git_config = bool(_arg_or_config(args, "git_config", config, "sbx", default=True)) - git_config_text = _host_git_config() if git_config else None + git_config_text = guest_setup.host_git_config(project_root) if git_config else None cpus_value = _arg_or_config(args, "cpus", config, "sbx") cpus = _validate_cpus(cpus_value) if cpus_value is not None else None try: @@ -1562,9 +946,9 @@ def cmd_start(args: argparse.Namespace) -> int: requested_name = _arg_or_config(args, "name", config, "sbx") if requested_name: - requested_name = _validate_vm_name(str(requested_name)) + requested_name = guest_setup.validate_vm_name(str(requested_name)) existing_status = _get_existing_vm_status(str(requested_name)) - _debug(f"existing VM lookup: name={requested_name!r}, status={existing_status!r}") + runtime.debug(f"existing VM lookup: name={requested_name!r}, status={existing_status!r}") if existing_status is not None: if existing_status != "running": try: @@ -1573,15 +957,19 @@ def cmd_start(args: argparse.Namespace) -> int: effective_mounts, writable_mounts=writable_mounts, port_forwards=port_forwards, + project=project_identity, ) except ConfigError as exc: print(f"sbx: {exc}", file=sys.stderr) return 2 + else: + _warn_running_mount_drift( + str(requested_name), effective_mounts, writable_mounts=writable_mounts + ) start_rc = _start_existing_vm_if_needed( str(requested_name), existing_status, boot_timeout, - force_start=bool(getattr(args, "force_start", False)), ) if start_rc != 0: return start_rc @@ -1590,9 +978,10 @@ def cmd_start(args: argparse.Namespace) -> int: _maybe_write_project_config( args, config, vm_name=str(requested_name), agent=str(agent), created=False ) + vm_metadata.record_vm_project(str(requested_name), project_identity) return _post_start_actions( vm_name=str(requested_name), - agent=str(agent), + command=LAUNCH_COMMANDS[str(agent)], attach=attach, run_user=str(run_user) if run_user is not None else None, auth_port=auth_port, @@ -1603,10 +992,10 @@ def cmd_start(args: argparse.Namespace) -> int: git_config_text=git_config_text, ) - image = _path_from_config(_arg_or_config(args, "image", config, "sbx")) + image = lifecycle_warnings.path_from_config(_arg_or_config(args, "image", config, "sbx")) if image is not None: try: - manifest = _local_image_manifest(image) + manifest = lifecycle_warnings.local_image_manifest(image) return _start_local_image( args=args, config=config, @@ -1681,26 +1070,29 @@ def cmd_start(args: argparse.Namespace) -> int: argv.append("--json") temp_home_ctx = None - smolvm_env = _sanitize_forwarded_env(dict(os.environ), forward_env) + smolvm_env = guest_setup.sanitize_forwarded_env(dict(os.environ), forward_env) if not copy_host_credentials: temp_home_ctx = tempfile.TemporaryDirectory(prefix="sbx-no-credentials-") - smolvm_env = _credential_free_env(Path(temp_home_ctx.name), forward_env=forward_env) - _debug(f"credential-free HOME: {temp_home_ctx.name}") + smolvm_env = guest_setup.credential_free_env( + Path(temp_home_ctx.name), forward_env=forward_env + ) + runtime.debug(f"credential-free HOME: {temp_home_ctx.name}") if not managed_start: try: - rc = _run_smolvm(argv, env=smolvm_env) + rc = runtime.run_smolvm(argv, env=smolvm_env) if rc == 0 and requested_name: - _set_vm_hostname(str(requested_name)) + guest_setup.set_hostname(str(requested_name), run_capture=runtime.run_capture) _maybe_write_project_config( args, config, vm_name=str(requested_name), agent=str(agent), created=True ) + vm_metadata.record_vm_project(str(requested_name), project_identity) return rc finally: if temp_home_ctx is not None: temp_home_ctx.cleanup() - completed = _run_smolvm_capture(argv, env=smolvm_env) + completed = runtime.run_smolvm_capture(argv, env=smolvm_env) if temp_home_ctx is not None: temp_home_ctx.cleanup() if completed is None: @@ -1718,8 +1110,9 @@ def cmd_start(args: argparse.Namespace) -> int: return completed.returncode vm_name = _extract_started_vm_name(completed.stdout) - _set_vm_hostname(vm_name) + guest_setup.set_hostname(vm_name, run_capture=runtime.run_capture) _maybe_write_project_config(args, config, vm_name=vm_name, agent=str(agent), created=True) + vm_metadata.record_vm_project(vm_name, project_identity) if auth_port: port_rc = network.expose_auth_port(vm_name, auth_host_port, auth_guest_port) if port_rc != 0: @@ -1733,21 +1126,18 @@ def cmd_start(args: argparse.Namespace) -> int: else: print(f"Started '{vm_name}'. Auth callback port: localhost:{auth_host_port}") - if not attach: - return 0 - _register_session(vm_name, "run") - try: - if run_user is not None: - _prepare_run_user(vm_name, str(run_user)) - _install_git_config(vm_name, str(run_user), git_config_text) - return _attach_as_user( - vm_name, str(run_user), LAUNCH_COMMANDS[str(agent)], cwd=project_guest_cwd - ) - _install_git_config(vm_name, None, git_config_text) - return _attach_as_root(vm_name, LAUNCH_COMMANDS[str(agent)], cwd=project_guest_cwd) - finally: - _unregister_session(vm_name) - _stop_vm_if_last_session(vm_name, stop_on_exit=stop_on_exit) + return _post_start_actions( + vm_name=vm_name, + command=LAUNCH_COMMANDS[str(agent)], + attach=attach, + run_user=str(run_user) if run_user is not None else None, + auth_port=False, + auth_host_port=auth_host_port, + auth_guest_port=auth_guest_port, + stop_on_exit=stop_on_exit, + cwd=project_guest_cwd, + git_config_text=git_config_text, + ) def _confirm_destructive_action(message: str, *, force: bool) -> bool: @@ -1760,117 +1150,124 @@ def _confirm_destructive_action(message: str, *, force: bool) -> bool: return answer in {"y", "yes"} -def _vm_name_from_arg_or_config( - args: argparse.Namespace, config: Mapping[str, Any], command: str -) -> str | None: - name = getattr(args, "name", None) or _cfg(config, "sbx", "name") - if not name: - print(f"sbx: {command} requires a VM name argument or [sbx].name", file=sys.stderr) - return None - return str(name) - - -def cmd_passthrough(args: argparse.Namespace) -> int: +def cmd_shell(args: argparse.Namespace) -> int: config = args.config_data - if args.action == "ls": - smolvm_command = ["sandbox", "list"] - if getattr(args, "all", False): - smolvm_command.append("--all") - elif args.action == "shell": - name = _vm_name_from_arg_or_config(args, config, "shell") - if name is None: - return 2 - try: - forward_env = _validate_env_names( - _list_value(_sbx_config(config).get("env"), key="[sbx].env") - ) - except ConfigError as exc: - print(f"sbx: {exc}", file=sys.stderr) - return 2 - run_user = None if args.root else args.run_user or _cfg(config, "sbx", "run_user") - if run_user is not None: - run_user = _validate_run_user(str(run_user)) - project_guest_cwd = _project_guest_cwd( - args.project_path or _cfg(config, "sbx", "project_path") + project_identity = _project_identity(args, config) + name = runtime.vm_name_from_arg_or_config(args, config, "shell") + if name is None: + return 2 + try: + forward_env = guest_setup.validate_env_names( + _list_value(_sbx_config(config).get("env"), key="[sbx].env") ) - smolvm_command = ["sandbox", "ssh", name] - keep_running = bool(getattr(args, "keep_running", False)) - stop_on_exit = bool(_cfg(config, "sbx", "stop_on_exit", True)) and not keep_running - git_config = bool( - args.git_config - if args.git_config is not None - else _cfg(config, "sbx", "git_config", True) + except ConfigError as exc: + print(f"sbx: {exc}", file=sys.stderr) + return 2 + run_user = None if args.root else args.run_user or _cfg(config, "sbx", "run_user") + if run_user is not None: + run_user = guest_setup.validate_run_user(str(run_user)) + project_path = args.project_path or _cfg(config, "sbx", "project_path") + project_guest_cwd = _project_guest_cwd(project_path) + smolvm_command = ["sandbox", "ssh", name] + keep_running = bool(getattr(args, "keep_running", False)) + stop_on_exit = bool(_cfg(config, "sbx", "stop_on_exit", True)) and not keep_running + git_config = bool( + args.git_config if args.git_config is not None else _cfg(config, "sbx", "git_config", True) + ) + git_config_text = ( + guest_setup.host_git_config(Path(project_identity["project_root"])) if git_config else None + ) + try: + port_forwards = _list_value( + _sbx_config(config).get("port_forwards"), key="[sbx].port_forwards" ) - git_config_text = _host_git_config() if git_config else None - try: - port_forwards = _list_value( - _sbx_config(config).get("port_forwards"), key="[sbx].port_forwards" - ) - network.port_forwards_from_specs(port_forwards) - except ConfigError as exc: - print(f"sbx: {exc}", file=sys.stderr) - return 2 - existing_status = _get_existing_vm_status(name) - if ( - run_user is not None or project_guest_cwd is not None or git_config_text is not None - ) and existing_status is None: - print(f"sbx: {_missing_vm_message(name)}", file=sys.stderr) - return 1 - if existing_status is not None: - if existing_status != "running": - try: - _sync_existing_vm_start_config( - name, None, writable_mounts=False, port_forwards=port_forwards - ) - except ConfigError as exc: - print(f"sbx: {exc}", file=sys.stderr) - return 2 - start_rc = _start_existing_vm_if_needed( - name, - existing_status, - DEFAULT_BOOT_TIMEOUT, - force_start=bool(getattr(args, "force_start", False)), + network.port_forwards_from_specs(port_forwards) + except ConfigError as exc: + print(f"sbx: {exc}", file=sys.stderr) + return 2 + existing_status = _get_existing_vm_status(name) + if ( + run_user is not None or project_guest_cwd is not None or git_config_text is not None + ) and existing_status is None: + print(f"sbx: {runtime.missing_vm_message(name)}", file=sys.stderr) + return 1 + if existing_status is not None: + if existing_status != "running": + try: + _sync_existing_vm_start_config( + name, + None, + writable_mounts=False, + port_forwards=port_forwards, + project=project_identity, + ) + except ConfigError as exc: + print(f"sbx: {exc}", file=sys.stderr) + return 2 + else: + sbx_cfg = _sbx_config(config) + mounts = _list_value(sbx_cfg.get("mount"), key="[sbx].mount") + effective_mounts = [] + if project_path is not None: + effective_mounts.append(_same_path_mount(str(project_path))) + effective_mounts.extend( + mount if ":" in mount else _same_path_mount(mount) for mount in mounts ) - if start_rc != 0: - return start_rc - if not _sync_forwarded_env_or_error(name, forward_env): - return 1 - _register_session(name, "shell") - try: - if run_user is not None: - _prepare_run_user(name, run_user) - _install_git_config(name, run_user, git_config_text) - return _attach_as_user(name, run_user, "bash", cwd=project_guest_cwd) - if project_guest_cwd is not None or git_config_text is not None: - _install_git_config(name, None, git_config_text) - return _attach_as_root(name, "bash", cwd=project_guest_cwd) - return _run_smolvm(smolvm_command) - finally: - _unregister_session(name) - _stop_vm_if_last_session(name, stop_on_exit=stop_on_exit) - elif args.action == "stop": - name = _vm_name_from_arg_or_config(args, config, "stop") - if name is None: - return 2 - smolvm_command = ["sandbox", "stop", name] - elif args.action == "rm": - name = _vm_name_from_arg_or_config(args, config, "rm") - if name is None: - return 2 - force = args.force - if not _confirm_destructive_action(f"Destroy VM '{name}'?", force=force): - return 2 - return _delete_vm(name) - else: # Defensive guard; argparse should prevent this. - print(f"sbx: unsupported passthrough command: {args.action}", file=sys.stderr) + if effective_mounts: + _warn_running_mount_drift( + name, effective_mounts, writable_mounts=project_path is not None + ) + start_rc = _start_existing_vm_if_needed( + name, + existing_status, + DEFAULT_BOOT_TIMEOUT, + ) + if start_rc != 0: + return start_rc + if not _sync_forwarded_env_or_error(name, forward_env): + return 1 + if run_user is not None or project_guest_cwd is not None or git_config_text is not None: + return _post_start_actions( + vm_name=name, + command="bash", + attach=True, + run_user=run_user, + auth_port=False, + stop_on_exit=stop_on_exit, + cwd=project_guest_cwd, + git_config_text=git_config_text, + session_kind="shell", + ) + session_state.register_session(name, "shell") + try: + return runtime.run_smolvm(smolvm_command) + finally: + remaining = session_state.unregister_session(name) + runtime.debug(f"unregistered session for {name}; remaining={remaining}") + _stop_vm_if_last_session(name, stop_on_exit=stop_on_exit) + + +def cmd_stop(args: argparse.Namespace) -> int: + config = args.config_data + name = runtime.vm_name_from_arg_or_config(args, config, "stop") + if name is None: return 2 + return runtime.run_smolvm(["sandbox", "stop", name]) - return _run_smolvm(smolvm_command) + +def cmd_remove(args: argparse.Namespace) -> int: + config = args.config_data + name = runtime.vm_name_from_arg_or_config(args, config, str(args.action)) + if name is None: + return 2 + if not _confirm_destructive_action(f"Destroy VM '{name}'?", force=args.force): + return 2 + return _delete_vm(name) def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None) -> int: extra = list(extra_args or []) - completed = _run_smolvm_capture(["sandbox", "delete", vm_id, *extra, "--json"]) + completed = runtime.run_smolvm_capture(["sandbox", "delete", vm_id, *extra, "--json"]) if completed is None: return 127 if completed.stderr: @@ -1883,6 +1280,11 @@ def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None) -> int: failed = [] if completed.returncode == 0: + with suppress(ConfigError): + metadata = vm_metadata.load_vm_metadata() + if vm_id in metadata: + metadata.pop(vm_id, None) + vm_metadata.save_vm_metadata(metadata) print(f"Destroyed VM '{vm_id}'.") return 0 @@ -2053,16 +1455,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"sbx {__version__}") sub = parser.add_subparsers(dest="action", required=True) - force_start_parent = argparse.ArgumentParser(add_help=False) - force_start_parent.add_argument( - "--force-start", - action="store_true", - help="Retry starting an existing VM even when its status is error.", - ) - - run = sub.add_parser( - "run", help="Run an agent session in a sandbox.", parents=[force_start_parent] - ) + run = sub.add_parser("run", help="Run an agent session in a sandbox.") _add_start_options(run) run.set_defaults(func=cmd_start) @@ -2077,18 +1470,17 @@ def build_parser() -> argparse.ArgumentParser: _add_start_options(recreate) recreate.set_defaults(func=cmd_recreate) - rm = sub.add_parser("rm", help="Remove a sandbox.") - rm.add_argument("name", nargs="?", help="Sandbox name. Defaults to [sbx].name.") - rm.add_argument("--force", action="store_true", help="Do not prompt for confirmation.") - rm.set_defaults(func=cmd_passthrough) + for remove_name in ("remove", "rm"): + remove = sub.add_parser(remove_name, help="Remove a sandbox.") + remove.add_argument("name", nargs="?", help="Sandbox name. Defaults to [sbx].name.") + remove.add_argument("--force", action="store_true", help="Do not prompt for confirmation.") + remove.set_defaults(func=cmd_remove) stop = sub.add_parser("stop", help="Stop a sandbox.") stop.add_argument("name", nargs="?", help="Sandbox name. Defaults to [sbx].name.") - stop.set_defaults(func=cmd_passthrough) + stop.set_defaults(func=cmd_stop) - shell = sub.add_parser( - "shell", help="Open a shell in a sandbox.", parents=[force_start_parent] - ) + shell = sub.add_parser("shell", help="Open a shell in a sandbox.") shell.add_argument( "--keep-running", action="store_true", @@ -2122,16 +1514,17 @@ def build_parser() -> argparse.ArgumentParser: help="Open the shell as root, ignoring [sbx].run_user.", ) shell.add_argument("name", nargs="?", help="Sandbox name. Defaults to [sbx].name.") - shell.set_defaults(func=cmd_passthrough) - - ls_p = sub.add_parser("ls", help="List sandboxes.") - ls_p.add_argument( - "-a", - "--all", - action="store_true", - help="List all sandboxes, including stopped ones.", - ) - ls_p.set_defaults(func=cmd_passthrough) + shell.set_defaults(func=cmd_shell) + + for list_name in ("list", "ls"): + list_parser = sub.add_parser(list_name, help="List sandboxes.") + list_parser.add_argument( + "-a", + "--all", + action="store_true", + help="List all sandboxes, including stopped ones.", + ) + list_parser.set_defaults(func=cmd_list) network_parser = sub.add_parser("network", help="Expert networking helpers.") network_sub = network_parser.add_subparsers(dest="network_action", required=True) @@ -2208,6 +1601,11 @@ def build_parser() -> argparse.ArgumentParser: list_images_parser.set_defaults(func=cmd_image_ls) doctor = sub.add_parser("doctor", help="Run non-sudo diagnostics for the configured backend.") + doctor.add_argument( + "--fix", + action="store_true", + help="Repair safe local sbx/SmolVM bookkeeping issues found by doctor.", + ) doctor.set_defaults(func=cmd_doctor) completion = sub.add_parser("completion", help="Generate shell completion script.") @@ -2235,17 +1633,15 @@ def _normalize_argv(argv: Sequence[str]) -> list[str]: def main(argv: Sequence[str] | None = None) -> int: - global DEBUG - os.environ.setdefault("PYTHONUNBUFFERED", "1") raw_argv = list(argv) if argv is not None else sys.argv[1:] normalized_argv = _normalize_argv(raw_argv) parser = build_parser() args = parser.parse_args(normalized_argv) - DEBUG = bool(args.debug) - _debug(f"argv: {raw_argv}") + runtime.DEBUG = bool(args.debug) + runtime.debug(f"argv: {raw_argv}") if normalized_argv != raw_argv: - _debug(f"normalized argv: {normalized_argv}") + runtime.debug(f"normalized argv: {normalized_argv}") if args.action in {"completion", "image"}: args.config_data = {} else: diff --git a/src/sbx/completion.py b/src/sbx/completion.py index c2a5756..547d05a 100644 --- a/src/sbx/completion.py +++ b/src/sbx/completion.py @@ -4,9 +4,11 @@ "run", "create", "recreate", + "remove", "rm", "stop", "shell", + "list", "ls", "network", "image", @@ -51,7 +53,6 @@ "--help", ) SHELL_OPTIONS = ( - "--force-start", "--keep-running", "--run-user", "--project-path", @@ -62,8 +63,9 @@ ) LS_OPTIONS = ("--all", "-a", "--help") RM_OPTIONS = ("--force", "--help") +DOCTOR_OPTIONS = ("--fix", "--help") STOP_OPTIONS = ("--help",) -RUN_OPTIONS = ("--force-start", *START_OPTIONS) +RUN_OPTIONS = START_OPTIONS AUTH_PORT_OPTIONS = ("--guest-port", "--host-port", "--replace", "--help") NETWORK_STATUS_OPTIONS = ("--host-port", "--help") IMAGE_BUILD_DEBIAN_OPTIONS = ( @@ -114,6 +116,7 @@ def bash_completion() -> str: shell_options = _words(SHELL_OPTIONS) ls_options = _words(LS_OPTIONS) rm_options = _words(RM_OPTIONS) + doctor_options = _words(DOCTOR_OPTIONS) stop_options = _words(STOP_OPTIONS) network_commands = _words(NETWORK_COMMANDS) image_commands = _words(IMAGE_COMMANDS) @@ -175,10 +178,10 @@ def bash_completion() -> str: shell) COMPREPLY=( $(compgen -W "{shell_options}" -- "$cur") ) ;; - ls) + list|ls) COMPREPLY=( $(compgen -W "{ls_options}" -- "$cur") ) ;; - rm) + remove|rm) COMPREPLY=( $(compgen -W "{rm_options}" -- "$cur") ) ;; stop) @@ -220,6 +223,9 @@ def bash_completion() -> str: COMPREPLY=( $(compgen -W "{image_ls_options}" -- "$cur") ) fi ;; + doctor) + COMPREPLY=( $(compgen -W "{doctor_options}" -- "$cur") ) + ;; completion) COMPREPLY=( $(compgen -W "{shells}" -- "$cur") ) ;; @@ -237,6 +243,7 @@ def zsh_completion() -> str: shell_options = _zsh_words(SHELL_OPTIONS) ls_options = _zsh_words(LS_OPTIONS) rm_options = _zsh_words(RM_OPTIONS) + doctor_options = _zsh_words(DOCTOR_OPTIONS) stop_options = _zsh_words(STOP_OPTIONS) network_commands = _zsh_words(NETWORK_COMMANDS) auth_port_options = _zsh_words(AUTH_PORT_OPTIONS) @@ -249,7 +256,7 @@ def zsh_completion() -> str: # zsh completion for sbx _sbx() {{ local -a commands run_options create_options recreate_options shell_options - local -a ls_options rm_options stop_options network_commands + local -a ls_options rm_options doctor_options stop_options network_commands local -a auth_port_options network_status_options local -a image_commands image_build_debian_options image_ls_options shells commands=({commands}) @@ -259,6 +266,7 @@ def zsh_completion() -> str: shell_options=({shell_options}) ls_options=({ls_options}) rm_options=({rm_options}) + doctor_options=({doctor_options}) stop_options=({stop_options}) network_commands=({network_commands}) auth_port_options=({auth_port_options}) @@ -286,10 +294,10 @@ def zsh_completion() -> str: shell) _describe 'option' shell_options ;; - ls) + list|ls) _describe 'option' ls_options ;; - rm) + remove|rm) _describe 'option' rm_options ;; stop) @@ -321,6 +329,9 @@ def zsh_completion() -> str: _arguments '*: :->args' fi ;; + doctor) + _describe 'option' doctor_options + ;; completion) _describe 'shell' shells ;; @@ -346,14 +357,18 @@ def fish_completion() -> str: for option in GLOBAL_OPTIONS: lines.append(f"complete -c sbx -f -l {option.removeprefix('--')}") for command in COMMANDS: - lines.append( - "complete -c sbx -f -n '__fish_use_subcommand' " - f"-a {command}" - ) + lines.append(f"complete -c sbx -f -n '__fish_use_subcommand' -a {command}") for command, options in ( ("run", RUN_OPTIONS), ("create", START_OPTIONS), ("recreate", ("--force", *START_OPTIONS)), + ("shell", SHELL_OPTIONS), + ("list", LS_OPTIONS), + ("ls", LS_OPTIONS), + ("remove", RM_OPTIONS), + ("rm", RM_OPTIONS), + ("doctor", DOCTOR_OPTIONS), + ("stop", STOP_OPTIONS), ): for option in options: lines.append( @@ -361,24 +376,7 @@ def fish_completion() -> str: f"{_fish_flag(option)}" ) for agent in AGENTS: - lines.append( - "complete -c sbx -f -n '__fish_seen_argument -l agent' " - f"-a {agent}" - ) - for option in SHELL_OPTIONS: - lines.append( - f"complete -c sbx -f -n '__fish_seen_subcommand_from shell' {_fish_flag(option)}" - ) - for option in LS_OPTIONS: - lines.append( - f"complete -c sbx -f -n '__fish_seen_subcommand_from ls' {_fish_flag(option)}" - ) - for option in RM_OPTIONS: - lines.append(f"complete -c sbx -f -n '__fish_seen_subcommand_from rm' {_fish_flag(option)}") - for option in STOP_OPTIONS: - lines.append( - f"complete -c sbx -f -n '__fish_seen_subcommand_from stop' {_fish_flag(option)}" - ) + lines.append(f"complete -c sbx -f -n '__fish_seen_argument -l agent' -a {agent}") network_subcommands = _words(NETWORK_COMMANDS) for command in NETWORK_COMMANDS: lines.append( @@ -404,14 +402,10 @@ def fish_completion() -> str: ) for option in IMAGE_BUILD_DEBIAN_OPTIONS: lines.append( - "complete -c sbx -f -n '__fish_seen_subcommand_from build-debian' " - f"{_fish_flag(option)}" + f"complete -c sbx -f -n '__fish_seen_subcommand_from build-debian' {_fish_flag(option)}" ) for option in IMAGE_LS_OPTIONS: - lines.append( - "complete -c sbx -f -n '__fish_seen_subcommand_from ls' " - f"{_fish_flag(option)}" - ) + lines.append(f"complete -c sbx -f -n '__fish_seen_subcommand_from ls' {_fish_flag(option)}") for shell in COMPLETION_SHELLS: lines.append(f"complete -c sbx -f -n '__fish_seen_subcommand_from completion' -a {shell}") return "\n".join(lines) + "\n" diff --git a/src/sbx/constants.py b/src/sbx/constants.py index df632f7..7eff633 100644 --- a/src/sbx/constants.py +++ b/src/sbx/constants.py @@ -6,4 +6,5 @@ SBX_STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "sbx" TUNNELS_FILE = SBX_STATE_DIR / "tunnels.json" SESSIONS_FILE = SBX_STATE_DIR / "sessions.json" +SBX_VMS_FILE = SBX_STATE_DIR / "vms.json" SMOLVM_DB_PATH = Path.home() / ".local" / "state" / "smolvm" / "smolvm.db" diff --git a/src/sbx/doctor.py b/src/sbx/doctor.py new file mode 100644 index 0000000..b679911 --- /dev/null +++ b/src/sbx/doctor.py @@ -0,0 +1,102 @@ +from pathlib import Path + +import sbx.network as network +from sbx import session_state, vm_metadata, vm_state +from sbx.constants import SBX_VMS_FILE +from sbx.runtime import ConfigError + + +def doctor_metadata(*, fix: bool) -> int: + try: + metadata = vm_metadata.load_vm_metadata() + except ConfigError as exc: + print(f"sbx metadata: {exc}") + if fix and SBX_VMS_FILE.exists(): + backup = SBX_VMS_FILE.with_suffix(SBX_VMS_FILE.suffix + ".bak") + SBX_VMS_FILE.replace(backup) + print(f" fixed: moved corrupt metadata to {backup}") + return 0 + return 1 + + if not metadata: + return 0 + existing = {str(getattr(vm, "vm_id", "")) for vm in vm_state.smolvm_vms(all_vms=True)} + changed = False + for name, item in list(metadata.items()): + reason = None + if name not in existing: + reason = "VM missing from SmolVM" + elif not Path(item["project_root"]).exists(): + reason = f"project path missing: {item['project_root']}" + if reason is None: + continue + print(f"sbx metadata: {name}: {reason}") + if fix: + metadata.pop(name, None) + changed = True + print(f" fixed: removed metadata for {name}") + if changed: + vm_metadata.save_vm_metadata(metadata) + return 0 + + +def doctor_sessions(*, fix: bool) -> None: + data = session_state.load_sessions() + changed = False + for vm_id, vm_data in list(data.items()): + raw = vm_data.get("sessions", []) if isinstance(vm_data, dict) else [] + sessions = [item for item in raw if isinstance(item, dict)] + active = session_state.live_sessions(raw) + if active == sessions: + continue + changed = True + print(f"sbx sessions: {vm_id}: stale session record(s)") + if fix: + if active: + data.setdefault(vm_id, {})["sessions"] = active + else: + data.pop(vm_id, None) + if changed and fix: + session_state.save_sessions(data) + print(" fixed: removed stale session record(s)") + + +def doctor_tunnels(*, fix: bool) -> None: + data = network._load_tunnels() + changed = False + for vm_id, vm_data in list(data.items()): + if not isinstance(vm_data, dict): + data.pop(vm_id, None) + changed = True + continue + tunnel = vm_data.get("auth_port") + pid = tunnel.get("pid") if isinstance(tunnel, dict) else None + if not isinstance(pid, int) or not session_state.pid_is_alive(pid): + print(f"sbx tunnels: {vm_id}: stale auth tunnel record") + if fix: + vm_data.pop("auth_port", None) + if not vm_data: + data.pop(vm_id, None) + changed = True + if changed and fix: + network._save_tunnels(data) + print(" fixed: removed stale tunnel record(s)") + + +def doctor_error_vms(*, fix: bool) -> None: + for vm in vm_state.smolvm_vms(all_vms=True): + if getattr(getattr(vm, "status", None), "value", getattr(vm, "status", None)) != "error": + continue + name = str(getattr(vm, "vm_id", "")) + print(f"smolvm state: {name}: VM is in error state") + if fix: + vm_state.mark_error_vm_stopped_for_restart(name) + print(f" fixed: {name} marked stopped") + + +def run_doctor_checks(*, fix: bool) -> int: + meta_rc = doctor_metadata(fix=fix) + doctor_sessions(fix=fix) + doctor_tunnels(fix=fix) + doctor_error_vms(fix=fix) + return meta_rc diff --git a/src/sbx/guest_setup.py b/src/sbx/guest_setup.py new file mode 100644 index 0000000..1ece062 --- /dev/null +++ b/src/sbx/guest_setup.py @@ -0,0 +1,379 @@ +import base64 +import os +import re +import shlex +import subprocess +from collections.abc import Callable +from contextlib import suppress +from datetime import UTC, datetime +from pathlib import Path + +from sbx import runtime +from sbx.runtime import ConfigError, ssh_command + +ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]*[$]?$", re.IGNORECASE) +VM_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +FORWARDABLE_ENV_VARS = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY") +SAFE_GIT_CONFIG_KEYS = ( + "user.name", + "user.email", + "init.defaultBranch", + "pull.rebase", + "push.default", + "core.autocrlf", + "core.eol", +) + + +def validate_vm_name(name: str) -> str: + if not VM_NAME_RE.match(name): + raise ConfigError( + "[sbx].name must be a valid hostname: lowercase letters, digits, hyphens, " + "1-63 chars, no leading/trailing hyphen" + ) + return name + + +def validate_run_user(user: str) -> str: + if not USERNAME_RE.match(user): + raise ConfigError("[sbx].run_user must be a valid Linux user name") + return user + + +def validate_env_names(names: list[str]) -> list[str]: + invalid = [name for name in names if not ENV_NAME_RE.match(name)] + if invalid: + raise ConfigError(f"invalid env var name(s): {', '.join(invalid)}") + return names + + +def sanitize_forwarded_env(env: dict[str, str], allowed: list[str]) -> dict[str, str]: + allowed_set = set(allowed) + for key in FORWARDABLE_ENV_VARS: + if key not in allowed_set: + env.pop(key, None) + return env + + +def credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict[str, str]: + env = sanitize_forwarded_env(dict(os.environ), forward_env) + real_home = Path.home() + real_smolvm_cache = real_home / ".smolvm" + real_smolvm_data = Path(env.get("SMOLVM_DATA_DIR", real_home / ".local" / "state" / "smolvm")) + temp_home.mkdir(parents=True, exist_ok=True) + real_smolvm_cache.mkdir(parents=True, exist_ok=True) + real_smolvm_data.mkdir(parents=True, exist_ok=True) + (temp_home / ".smolvm").symlink_to(real_smolvm_cache, target_is_directory=True) + + fake_bin = temp_home / ".sbx-bin" + fake_bin.mkdir() + security = fake_bin / "security" + security.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + security.chmod(0o755) + + env["HOME"] = str(temp_home) + env["SMOLVM_DATA_DIR"] = str(real_smolvm_data) + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + return env + + +def host_git_config(project_root: Path | None = None) -> str | None: + values: dict[str, str] = {} + for key in SAFE_GIT_CONFIG_KEYS: + cmd = ["git", "config", "--global", "--get", key] + if project_root is not None: + cmd = ["git", "-C", str(project_root), "config", "--get", key] + try: + completed = subprocess.run(cmd, check=False, text=True, capture_output=True) + except FileNotFoundError: + return None + if completed.returncode != 0 and project_root is not None: + completed = subprocess.run( + ["git", "config", "--global", "--get", key], + check=False, + text=True, + capture_output=True, + ) + if completed.returncode == 0: + value = completed.stdout.strip() + if value and "\n" not in value: + values[key] = value + if not values: + return None + + sections: dict[str, list[tuple[str, str]]] = {} + for key, value in values.items(): + section, option = key.split(".", 1) + sections.setdefault(section, []).append((option, value)) + + lines: list[str] = [] + for section, entries in sections.items(): + lines.append(f"[{section}]") + for option, value in entries: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + lines.append(f'\t{option} = "{escaped}"') + lines.append("") + return "\n".join(lines) + + +def parse_managed_env_script(text: str) -> dict[str, str]: + env: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if line.startswith("export ") and "=" in line: + key, raw_value = line[len("export ") :].split("=", 1) + with suppress(ValueError): + env[key] = (shlex.split(raw_value) or [""])[0] + return env + + +def sync_forwarded_env_direct_ssh( + vm_id: str, + values: dict[str, str], + missing: list[str], + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + from smolvm.env import ENV_FILE, build_env_script + + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + ssh_cmd = ssh(vm_id) + completed = run_capture([*ssh_cmd, f"cat {shlex.quote(ENV_FILE)} 2>/dev/null || true"]) + if completed is None: + raise ConfigError("failed to sync environment: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to read environment: {stderr}") + + env = parse_managed_env_script(completed.stdout) + env.update(values) + for name in missing: + env.pop(name, None) + + encoded = base64.b64encode(build_env_script(env).encode("utf-8")).decode("ascii") + write_script = f""" +set -eu +_t=$(mktemp /tmp/.smolvm_env.XXXXXXXXXX) +trap 'rm -f "$_t"' EXIT +printf %s {shlex.quote(encoded)} | base64 -d > "$_t" +chmod 0644 "$_t" +mv "$_t" {shlex.quote(ENV_FILE)} +""" + completed = run_capture([*ssh_cmd, "bash -lc " + shlex.quote(write_script)]) + if completed is None: + raise ConfigError("failed to sync environment: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to write environment: {stderr}") + + +def sync_forwarded_env( + vm_id: str, + names: list[str], + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + if not names: + return + values = {name: os.environ[name] for name in names if name in os.environ} + missing = [name for name in names if name not in os.environ] + + from smolvm.facade import SmolVM + + vm = SmolVM.from_id(vm_id) + try: + info = getattr(vm, "_info", None) + comm_channel = getattr(getattr(info, "config", None), "comm_channel", None) + if comm_channel is None: + sync_forwarded_env_direct_ssh(vm_id, values, missing, run_capture=run_capture, ssh=ssh) + return + if values: + vm.set_env_vars(values) + if missing: + vm.unset_env_vars(missing) + finally: + vm.close() + + +def host_timezone() -> str: + zoneinfo = Path("/usr/share/zoneinfo") + try: + target = Path("/etc/localtime").resolve() + return target.relative_to(zoneinfo).as_posix() + except (OSError, ValueError): + pass + try: + timezone = Path("/etc/timezone").read_text(encoding="utf-8").strip() + except OSError: + return "UTC" + return timezone or "UTC" + + +def sync_guest_clock( + vm_id: str, + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") + timezone = host_timezone() + script = f""" +set -eu +zone={shlex.quote(timezone)} +if [ -f "/usr/share/zoneinfo/$zone" ]; then + ln -sf "/usr/share/zoneinfo/$zone" /etc/localtime + printf '%s\n' "$zone" > /etc/timezone +fi +date -u -s {shlex.quote(timestamp)} +""" + cmd = ssh(vm_id) + cmd.append("bash -lc " + shlex.quote(script)) + completed = run_capture(cmd) + if completed is None: + raise ConfigError("failed to sync VM clock: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to sync VM clock: {stderr}") + + +def set_hostname( + vm_id: str, + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + hostname = validate_vm_name(vm_id) + script = r""" +set -eu +hostname "$1" +printf '%s\n' "$1" > /etc/hostname +if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then + sed -i "s/^127\\.0\\.1\\.1.*/127.0.1.1 $1/" /etc/hosts +else + printf '127.0.1.1 %s\n' "$1" >> /etc/hosts +fi +""" + cmd = ssh(vm_id) + cmd.append( + "bash -s -- " + shlex.quote(hostname) + " <<'SBX_HOSTNAME'\n" + script + "SBX_HOSTNAME" + ) + completed = run_capture(cmd) + if completed is None: + raise ConfigError("failed to set VM hostname: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to set VM hostname: {stderr}") + + +def install_git_config( + vm_id: str, + user: str | None, + git_config_text: str | None, + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + if not git_config_text: + return + if user is None: + home = "/root" + owner = "root:root" + else: + quoted_user = shlex.quote(user) + home = f"/home/{quoted_user}" + owner = f"{quoted_user}:{quoted_user}" + + encoded = base64.b64encode(git_config_text.encode("utf-8")).decode("ascii") + script = f""" +set -eu +install -d {shlex.quote(home)} +printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(home)}/.gitconfig +chown {owner} {shlex.quote(home)}/.gitconfig +chmod 600 {shlex.quote(home)}/.gitconfig +""" + cmd = ssh(vm_id) + cmd.append("bash -lc " + shlex.quote(script)) + completed = run_capture(cmd) + if completed is None: + raise ConfigError("failed to install git config: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to install git config: {stderr}") + + +def prepare_run_user( + vm_id: str, + user: str, + *, + run_capture: Callable[[list[str]], subprocess.CompletedProcess[str] | None] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> None: + run_capture = run_capture or runtime.run_capture + ssh = ssh or ssh_command + quoted_user = shlex.quote(user) + home = f"/home/{quoted_user}" + script = f""" +set -eu +host="$(hostname)" +if [ -n "$host" ] && ! getent hosts "$host" >/dev/null 2>&1; then + printf '127.0.1.1 %s\n' "$host" >> /etc/hosts +fi +if ! id -u {quoted_user} >/dev/null 2>&1; then + useradd -m -s /bin/bash {quoted_user} +fi +install -d -o {quoted_user} -g {quoted_user} {home} +for p in .ssh .pi .codex .claude .claude.json; do + if [ -e /root/$p ]; then + rm -rf {home}/$p + cp -a /root/$p {home}/$p + fi +done +chown -R {quoted_user}:{quoted_user} {home} +""" + cmd = ssh(vm_id) + cmd.append("bash -lc " + shlex.quote(script)) + completed = run_capture(cmd) + if completed is None: + raise ConfigError(f"failed to prepare run user {user!r}: ssh command not found") + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise ConfigError(f"failed to prepare run user {user!r}: {stderr}") + + +def attach( + vm_id: str, + launch_command: str, + user: str | None = None, + cwd: str | None = None, + *, + run: Callable[[list[str]], int] | None = None, + ssh: Callable[[str], list[str]] | None = None, +) -> int: + from smolvm.env import ENV_FILE + + run = run or runtime.run + ssh = ssh or ssh_command + cmd = ssh(vm_id) + cd_prefix = f"cd {shlex.quote(cwd)} || exit; " if cwd is not None else "" + remote = ( + f"{cd_prefix}[ -r {ENV_FILE} ] && . {ENV_FILE}; " + 'export PATH="$HOME/.local/bin:$PATH"; ' + f"exec {launch_command}" + ) + if user is not None: + remote = f"sudo -iu {shlex.quote(user)} bash -lc " + shlex.quote(remote) + cmd.insert(-1, "-t") + cmd.append(remote) + return run(cmd) diff --git a/src/sbx/lifecycle_warnings.py b/src/sbx/lifecycle_warnings.py new file mode 100644 index 0000000..6b3c351 --- /dev/null +++ b/src/sbx/lifecycle_warnings.py @@ -0,0 +1,181 @@ +import json +import shutil +import subprocess +from collections.abc import Callable, Mapping +from contextlib import suppress +from pathlib import Path +from typing import Any + +from sbx.runtime import ConfigError + +MIB = 1024 * 1024 + + +def cfg(config: Mapping[str, Any], section: str, key: str, default: Any = None) -> Any: + value = config.get(section, {}) + if not isinstance(value, Mapping): + return default + return value.get(key, default) + + +def int_or_none(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def ceil_mib(size_bytes: int) -> int: + return (size_bytes + MIB - 1) // MIB + + +def qcow2_virtual_size_mib(path: Path) -> int | None: + qemu_img = shutil.which("qemu-img") + if qemu_img is None: + return None + result = subprocess.run( + [qemu_img, "info", "--output=json", str(path)], + check=False, + text=True, + capture_output=True, + ) + if result.returncode != 0: + return None + try: + info = json.loads(result.stdout) + virtual_size = int(info["virtual-size"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return None + return ceil_mib(virtual_size) + + +def rootfs_size_mib(rootfs_path: Path) -> int | None: + if rootfs_path.suffix.lower() == ".qcow2": + return qcow2_virtual_size_mib(rootfs_path) + try: + return ceil_mib(rootfs_path.stat().st_size) + except OSError: + return None + + +def path_from_config(value: Any) -> Path | None: + if value is None: + return None + return Path(str(value)).expanduser() + + +def local_image_manifest(image: Path) -> dict[str, Any]: + if not image.is_dir(): + raise ConfigError("[sbx].image must point to a local image directory") + manifest_path = image / "smolvm-image.json" + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ConfigError(f"image manifest not found: {manifest_path}") from exc + except json.JSONDecodeError as exc: + raise ConfigError(f"invalid image manifest JSON: {manifest_path}: {exc}") from exc + if not isinstance(raw, dict): + raise ConfigError("image manifest must be a JSON object") + return raw + + +def manifest_path(image_dir: Path, manifest: Mapping[str, Any], key: str) -> Path: + value = manifest.get(key) + if not isinstance(value, str): + raise ConfigError(f"image manifest requires string field {key!r}") + path = Path(value).expanduser() + if not path.is_absolute(): + path = image_dir / path + return path + + +def local_image_rootfs_size_mib(config: Mapping[str, Any]) -> int | None: + image = path_from_config(cfg(config, "sbx", "image")) + if image is None: + return None + with suppress(ConfigError): + manifest = local_image_manifest(image) + rootfs_path = manifest_path(image, manifest, "rootfs") + if rootfs_path.is_file(): + return rootfs_size_mib(rootfs_path) + return None + + +def local_image_disk_size_error(configured_disk_size: int, image_size: int) -> str: + return ( + "configured disk_size is smaller than the local image rootfs:\n" + f" disk_size: {configured_disk_size} MiB\n" + f" local image rootfs: {image_size} MiB\n" + f"Set [sbx].disk_size to at least {image_size}, remove [sbx].disk_size, " + "or rebuild the configured local image with a rootfs no larger than " + f"{configured_disk_size} MiB." + ) + + +def local_image_config_warnings(config: Mapping[str, Any]) -> list[str]: + configured_disk_size = int_or_none(cfg(config, "sbx", "disk_size")) + if configured_disk_size is None: + return [] + image_size = local_image_rootfs_size_mib(config) + if image_size is None or configured_disk_size >= image_size: + return [] + return [local_image_disk_size_error(configured_disk_size, image_size)] + + +def existing_vm_config_mismatches( + name: str, + config: Mapping[str, Any], + *, + smolvm_info: Callable[[str], Mapping[str, Any] | None], +) -> list[str]: + vm = smolvm_info(name) + if vm is None: + return [] + + checks = ( + ("disk_size", "disk_size", " MiB"), + ("memory", "memory", " MiB"), + ("cpus", "vcpus", ""), + ) + mismatches: list[str] = [] + for config_key, vm_key, unit in checks: + configured = int_or_none(cfg(config, "sbx", config_key)) + existing = int_or_none(vm.get(vm_key)) + if configured is None or existing is None or configured == existing: + continue + mismatches.append( + f"{config_key}: config requests {configured}{unit}, existing VM has {existing}{unit}" + ) + return mismatches + + +def doctor_config_state( + config: Mapping[str, Any], *, smolvm_info: Callable[[str], Mapping[str, Any] | None] +) -> None: + name = cfg(config, "sbx", "name") + if not name: + return + + vm_name = str(name) + mismatches = existing_vm_config_mismatches(vm_name, config, smolvm_info=smolvm_info) + image_warnings = local_image_config_warnings(config) + if not mismatches and not image_warnings: + return + + print("sbx config/state:") + if mismatches: + print(f" warning: VM '{vm_name}' already exists and differs from .sbx.toml:") + for mismatch in mismatches: + print(f" {mismatch}") + if image_warnings: + print(" warning: configured disk_size is smaller than the local image rootfs:") + for warning in image_warnings: + for line in warning.splitlines(): + print(f" {line}") + if mismatches: + print( + " Existing VMs are reused as-is. " + f"Run `sbx recreate {vm_name} --force` to apply config changes." + ) diff --git a/src/sbx/network.py b/src/sbx/network.py index 11e7851..1c03344 100644 --- a/src/sbx/network.py +++ b/src/sbx/network.py @@ -7,6 +7,7 @@ import sys import time from collections.abc import Mapping, Sequence +from contextlib import suppress from typing import Any from sbx.constants import SBX_STATE_DIR, TUNNELS_FILE @@ -19,7 +20,6 @@ run, run_smolvm_capture, ssh_command, - suppress_process_errors, vm_name_from_arg_or_config, write_json_object, ) @@ -123,13 +123,13 @@ def _close_tracked_auth_tunnel(vm_id: str) -> bool: return False pid = int(tracked["pid"]) - with suppress_process_errors(): + with suppress(ProcessLookupError, PermissionError, OSError): os.killpg(pid, signal.SIGTERM) deadline = time.monotonic() + 3 while time.monotonic() < deadline and pid_is_alive(pid): time.sleep(0.1) if pid_is_alive(pid): - with suppress_process_errors(): + with suppress(ProcessLookupError, PermissionError, OSError): os.killpg(pid, signal.SIGKILL) _remove_auth_tunnel_record(vm_id) return True @@ -210,7 +210,7 @@ def expose_auth_port(vm_id: str, host_port: int, guest_port: int, *, replace: bo return 0 time.sleep(0.1) - with suppress_process_errors(): + with suppress(ProcessLookupError, PermissionError, OSError): os.killpg(proc.pid, signal.SIGTERM) print(f"sbx: auth port tunnel did not become ready on localhost:{host_port}", file=sys.stderr) return 1 diff --git a/src/sbx/runtime.py b/src/sbx/runtime.py index e6c70c7..1e02f64 100644 --- a/src/sbx/runtime.py +++ b/src/sbx/runtime.py @@ -1,10 +1,9 @@ -import argparse import json import os import shlex -import shutil import subprocess import sys +from argparse import Namespace from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -19,7 +18,7 @@ class ConfigError(ValueError): def vm_name_from_arg_or_config( - args: argparse.Namespace, config: Mapping[str, Any] | None, command: str + args: Namespace, config: Mapping[str, Any] | None, command: str ) -> str | None: name = getattr(args, "name", None) sbx = (config or {}).get("sbx", {}) @@ -117,15 +116,6 @@ def run_smolvm_capture( return run_capture(smolvm_argv(args), **kwargs) -def require(command: str, install_hint: str | None = None) -> bool: - if shutil.which(command): - return True - print(f"sbx: required command not found: {command}", file=sys.stderr) - if install_hint: - print(install_hint, file=sys.stderr) - return False - - def missing_vm_message(vm_id: str) -> str: return ( f"VM {vm_id!r} not found. `sbx shell` attaches to an existing sandbox; " @@ -147,10 +137,14 @@ def ssh_command(vm_id: str) -> list[str]: vm.close() -def read_json_object(path: Path) -> dict[str, Any]: +def read_json_object(path: Path, *, error: str | None = None) -> dict[str, Any]: try: data = json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError): + except FileNotFoundError: + return {} + except json.JSONDecodeError as exc: + if error is not None: + raise ConfigError(f"{error}: {path}: {exc}") from exc return {} return data if isinstance(data, dict) else {} @@ -170,11 +164,3 @@ def pid_is_alive(pid: int) -> bool: except PermissionError: return True return True - - -class suppress_process_errors: - def __enter__(self) -> None: - return None - - def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: - return isinstance(exc, (ProcessLookupError, PermissionError, OSError)) diff --git a/src/sbx/session_state.py b/src/sbx/session_state.py new file mode 100644 index 0000000..4f73a4c --- /dev/null +++ b/src/sbx/session_state.py @@ -0,0 +1,68 @@ +import os +from collections.abc import Mapping +from typing import Any + +from sbx.constants import SBX_STATE_DIR, SESSIONS_FILE +from sbx.runtime import pid_is_alive, read_json_object, write_json_object + + +def load_sessions() -> dict[str, Any]: + return read_json_object(SESSIONS_FILE) + + +def save_sessions(data: Mapping[str, Any]) -> None: + write_json_object(SESSIONS_FILE, data, state_dir=SBX_STATE_DIR) + + +def live_sessions(raw_sessions: object) -> list[dict[str, Any]]: + sessions = ( + [item for item in raw_sessions if isinstance(item, dict)] + if isinstance(raw_sessions, list) + else [] + ) + return [ + item + for item in sessions + if isinstance(item.get("pid"), int) and pid_is_alive(int(item["pid"])) + ] + + +def active_sessions(vm_id: str) -> list[dict[str, Any]]: + data = load_sessions() + raw_sessions = data.get(vm_id, {}).get("sessions", []) + sessions = [item for item in raw_sessions if isinstance(item, dict)] + active = live_sessions(raw_sessions) + if active != sessions: + if active: + data.setdefault(vm_id, {})["sessions"] = active + else: + data.pop(vm_id, None) + save_sessions(data) + return active + + +def register_session(vm_id: str, kind: str) -> None: + sessions = active_sessions(vm_id) + sessions.append({"pid": os.getpid(), "kind": kind}) + data = load_sessions() + data.setdefault(vm_id, {})["sessions"] = sessions + save_sessions(data) + + +def unregister_session(vm_id: str) -> int: + data = load_sessions() + sessions = data.get(vm_id, {}).get("sessions", []) + remaining = [ + item + for item in sessions + if isinstance(item, dict) + and item.get("pid") != os.getpid() + and isinstance(item.get("pid"), int) + and pid_is_alive(int(item["pid"])) + ] + if remaining: + data.setdefault(vm_id, {})["sessions"] = remaining + else: + data.pop(vm_id, None) + save_sessions(data) + return len(remaining) diff --git a/src/sbx/vm_metadata.py b/src/sbx/vm_metadata.py new file mode 100644 index 0000000..db8e974 --- /dev/null +++ b/src/sbx/vm_metadata.py @@ -0,0 +1,36 @@ +from collections.abc import Mapping + +from sbx.constants import SBX_STATE_DIR, SBX_VMS_FILE +from sbx.runtime import ConfigError, read_json_object, write_json_object + + +def load_vm_metadata() -> dict[str, dict[str, str]]: + raw = read_json_object(SBX_VMS_FILE, error="invalid sbx VM metadata") + data: dict[str, dict[str, str]] = {} + for name, value in raw.items(): + if isinstance(name, str) and isinstance(value, dict): + project_root = value.get("project_root") + config_path = value.get("config_path") + if isinstance(project_root, str) and isinstance(config_path, str): + data[name] = {"project_root": project_root, "config_path": config_path} + return data + + +def save_vm_metadata(data: Mapping[str, Mapping[str, str]]) -> None: + write_json_object(SBX_VMS_FILE, data, state_dir=SBX_STATE_DIR) + + +def record_vm_project(vm_name: str, project: Mapping[str, str]) -> None: + data = load_vm_metadata() + data[vm_name] = {"project_root": project["project_root"], "config_path": project["config_path"]} + save_vm_metadata(data) + + +def validate_vm_project(vm_name: str, project: Mapping[str, str]) -> None: + saved = load_vm_metadata().get(vm_name) + if saved and saved.get("project_root") != project["project_root"]: + raise ConfigError( + f"VM {vm_name!r} belongs to {saved.get('project_root')}; " + f"refusing to update it from {project['project_root']}. " + "Run `sbx doctor --fix` to repair stale metadata." + ) diff --git a/src/sbx/vm_state.py b/src/sbx/vm_state.py new file mode 100644 index 0000000..f18c85a --- /dev/null +++ b/src/sbx/vm_state.py @@ -0,0 +1,54 @@ +import json +import sqlite3 +from pathlib import Path +from typing import Any + +from sbx.constants import SMOLVM_DB_PATH + + +def smolvm_vms(all_vms: bool = False) -> list[Any]: + from smolvm.types import VMState + from smolvm.vm import SmolVMManager + + status = None if all_vms else VMState.RUNNING + return SmolVMManager().list_vms(status=status) + + +def existing_vm_start_config(vm_name: str) -> tuple[str, dict[str, Any]] | None: + db_path = SMOLVM_DB_PATH.expanduser() + if not db_path.exists(): + return None + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT status, config FROM vms WHERE id = ?", (vm_name,)).fetchone() + if row is None: + return None + return str(row["status"]), json.loads(row["config"]) + + +def mark_error_vm_stopped_for_restart(vm_id: str) -> None: + db_path = SMOLVM_DB_PATH.expanduser() + if not db_path.exists(): + return + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + UPDATE vms + SET status = 'stopped', pid = NULL, socket_path = NULL + WHERE id = ? AND status = 'error' + """, + (vm_id,), + ) + + +def vm_image(vm: Any) -> str: + rootfs = getattr(getattr(vm, "config", None), "rootfs_path", None) + if rootfs is None: + return "-" + path = Path(rootfs) + return path.parent.name or path.name or "-" + + +def vm_ssh_port(vm: Any) -> str: + port = getattr(getattr(vm, "network", None), "ssh_host_port", None) + return str(port) if port is not None else "-" diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index d6d42e8..2dc8ad9 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -16,12 +16,8 @@ def test_bump_version_updates_project_files( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str ) -> None: (tmp_path / "src/sbx").mkdir(parents=True) - (tmp_path / "pyproject.toml").write_text( - 'name = "sbx"\nversion = "0.1.0"\n', encoding="utf-8" - ) - (tmp_path / "src/sbx/__init__.py").write_text( - '__version__ = "0.1.0"\n', encoding="utf-8" - ) + (tmp_path / "pyproject.toml").write_text('name = "sbx"\nversion = "0.1.0"\n', encoding="utf-8") + (tmp_path / "src/sbx/__init__.py").write_text('__version__ = "0.1.0"\n', encoding="utf-8") (tmp_path / "uv.lock").write_text( 'name = "other"\nversion = "9.9.9"\n\nname = "sbx"\nversion = "0.1.0"\n', encoding="utf-8", @@ -34,9 +30,7 @@ def test_bump_version_updates_project_files( bump_version.bump(version) - assert f'version = "{version}"' in (tmp_path / "pyproject.toml").read_text( - encoding="utf-8" - ) + assert f'version = "{version}"' in (tmp_path / "pyproject.toml").read_text(encoding="utf-8") assert f'__version__ = "{version}"' in (tmp_path / "src/sbx/__init__.py").read_text( encoding="utf-8" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index caaac0f..b22f14a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,21 +3,25 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace import pytest -from sbx import cli +from sbx import cli, guest_setup, runtime, vm_metadata, vm_state -_ORIGINAL_HOST_GIT_CONFIG = cli._host_git_config +_ORIGINAL_HOST_GIT_CONFIG = guest_setup.host_git_config @pytest.fixture(autouse=True) def isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr(cli, "DEBUG", False) + monkeypatch.setattr(cli.runtime, "DEBUG", False) monkeypatch.setattr(cli, "DEFAULT_CONFIG_PATHS", (tmp_path / "home-config.toml",)) monkeypatch.setattr(cli, "LOCAL_CONFIG_PATHS", (tmp_path / ".sbx.toml",)) - monkeypatch.setattr(cli, "_host_git_config", lambda: None) - monkeypatch.setattr(cli, "_set_vm_hostname", lambda name: None) + monkeypatch.setattr(vm_metadata, "SBX_STATE_DIR", tmp_path / "state") + monkeypatch.setattr(vm_metadata, "SBX_VMS_FILE", tmp_path / "state" / "vms.json") + monkeypatch.setattr(guest_setup, "host_git_config", lambda project_root=None: None) + monkeypatch.setattr(guest_setup, "set_hostname", lambda *args, **kwargs: None) + monkeypatch.setattr(guest_setup, "sync_guest_clock", lambda *args, **kwargs: None) def install_fake_smolvm(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: @@ -27,16 +31,18 @@ def install_fake_smolvm(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path smolvm.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\"\n", encoding="utf-8") smolvm.chmod(0o755) monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}") - monkeypatch.setattr(cli, "_smolvm_argv", lambda args: ["smolvm", *args]) + monkeypatch.setattr(cli.runtime, "smolvm_argv", lambda args: ["smolvm", *args]) return smolvm def print_smolvm_args(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(cli, "_run_smolvm", lambda args, **kwargs: print(" ".join(args)) or 0) + monkeypatch.setattr( + cli.runtime, "run_smolvm", lambda args, **kwargs: print(" ".join(args)) or 0 + ) def test_smolvm_runner_does_not_need_console_script_on_path() -> None: - assert cli._smolvm_argv(["doctor"]) == [ + assert runtime.smolvm_argv(["doctor"]) == [ sys.executable, "-c", "from smolvm.cli.main import main; raise SystemExit(main())", @@ -52,9 +58,9 @@ def fake_run(argv: list[str], **kwargs: object) -> int: return 0 monkeypatch.delenv("SBX_SMOLVM_VERSION_NOTICES", raising=False) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(runtime, "run", fake_run) - assert cli._run_smolvm(["doctor"]) == 0 + assert runtime.run_smolvm(["doctor"]) == 0 env = captured["env"] assert isinstance(env, dict) @@ -72,9 +78,9 @@ def fake_run(argv: list[str], **kwargs: object) -> int: monkeypatch.setenv("SBX_SMOLVM_VERSION_NOTICES", "true") monkeypatch.delenv("SMOLVM_DISABLE_VERSION_CHECK", raising=False) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(runtime, "run", fake_run) - assert cli._run_smolvm(["doctor"]) == 0 + assert runtime.run_smolvm(["doctor"]) == 0 env = captured["env"] assert isinstance(env, dict) @@ -110,11 +116,11 @@ def test_doctor_warns_when_config_differs_from_existing_vm( smolvm = bin_dir / "smolvm" smolvm.write_text( "#!/bin/sh\n" - "if [ \"$1\" = doctor ]; then printf '%s\\n' \"$*\"; exit 0; fi\n" - "if [ \"$1\" = sandbox ] && [ \"$2\" = info ]; then\n" + 'if [ "$1" = doctor ]; then printf \'%s\\n\' "$*"; exit 0; fi\n' + 'if [ "$1" = sandbox ] && [ "$2" = info ]; then\n' " printf '%s\\n' " - "'{\"data\":{\"vm\":{\"status\":\"stopped\",\"disk_size\":81920," - "\"memory\":8192,\"vcpus\":4}}}'\n" + '\'{"data":{"vm":{"status":"stopped","disk_size":81920,' + '"memory":8192,"vcpus":4}}}\'\n' " exit 0\n" "fi\n" "exit 1\n", @@ -122,7 +128,7 @@ def test_doctor_warns_when_config_differs_from_existing_vm( ) smolvm.chmod(0o755) monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}") - monkeypatch.setattr(cli, "_smolvm_argv", lambda args: ["smolvm", *args]) + monkeypatch.setattr(cli.runtime, "smolvm_argv", lambda args: ["smolvm", *args]) rc = cli.main(["doctor"]) @@ -159,11 +165,11 @@ def test_doctor_warns_when_local_image_is_larger_than_requested_disk( smolvm = bin_dir / "smolvm" smolvm.write_text( "#!/bin/sh\n" - "if [ \"$1\" = doctor ]; then printf '%s\\n' \"$*\"; exit 0; fi\n" - "if [ \"$1\" = sandbox ] && [ \"$2\" = info ]; then\n" + 'if [ "$1" = doctor ]; then printf \'%s\\n\' "$*"; exit 0; fi\n' + 'if [ "$1" = sandbox ] && [ "$2" = info ]; then\n' " printf '%s\\n' " - "'{\"data\":{\"vm\":{\"status\":\"stopped\",\"disk_size\":10," - "\"memory\":512,\"vcpus\":2}}}'\n" + '\'{"data":{"vm":{"status":"stopped","disk_size":10,' + '"memory":512,"vcpus":2}}}\'\n' " exit 0\n" "fi\n" "exit 1\n", @@ -171,7 +177,7 @@ def test_doctor_warns_when_local_image_is_larger_than_requested_disk( ) smolvm.chmod(0o755) monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}") - monkeypatch.setattr(cli, "_smolvm_argv", lambda args: ["smolvm", *args]) + monkeypatch.setattr(cli.runtime, "smolvm_argv", lambda args: ["smolvm", *args]) rc = cli.main(["doctor"]) @@ -199,46 +205,50 @@ def test_run_rejects_non_qemu_backend( def test_debug_prints_commands_to_stderr( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, capfd: pytest.CaptureFixture[str], ) -> None: - install_fake_smolvm(monkeypatch, tmp_path) + monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=False: []) rc = cli.main(["--debug", "ls"]) captured = capfd.readouterr() assert rc == 0 assert "sbx debug: argv: ['--debug', 'ls']" in captured.err - assert "sbx debug: run: smolvm sandbox list" in captured.err -def test_list_passthrough_does_not_require_name( +def test_list_does_not_require_name( monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], ) -> None: - print_smolvm_args(monkeypatch) + vm = SimpleNamespace( + vm_id="vm1", + status="running", + config=SimpleNamespace(rootfs_path=Path("/images/debian/rootfs.ext4")), + network=SimpleNamespace(ssh_host_port=2204), + ) + monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=False: [vm]) + vm_metadata.record_vm_project( + "vm1", {"project_root": "/project", "config_path": "/project/.sbx.toml"} + ) - rc = cli.main(["ls"]) + rc = cli.main(["list"]) assert rc == 0 - assert capfd.readouterr().out == "sandbox list\n" + assert capfd.readouterr().out == ( + "NAME STATUS PROJECT IMAGE SSH\nvm1 running /project debian 2204\n" + ) -def test_list_all_includes_stopped_vms( +def test_list_all_aliases_include_stopped_vms( monkeypatch: pytest.MonkeyPatch, - capfd: pytest.CaptureFixture[str], ) -> None: - print_smolvm_args(monkeypatch) + calls: list[bool] = [] + monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=False: calls.append(all_vms) or []) - rc = cli.main(["ls", "--all"]) + assert cli.main(["ls", "--all"]) == 0 + assert cli.main(["ls", "-a"]) == 0 - assert rc == 0 - assert capfd.readouterr().out == "sandbox list --all\n" - - rc = cli.main(["ls", "-a"]) - - assert rc == 0 - assert capfd.readouterr().out == "sandbox list --all\n" + assert calls == [True, True] def test_shell_uses_configured_default_name( @@ -264,15 +274,15 @@ def test_shell_uses_configured_run_user( config.write_text('[sbx]\nname = "vm1"\nrun_user = "agent"\n', encoding="utf-8") captured: dict[str, object] = {} - def fake_prepare(vm_id: str, user: str) -> None: + def fake_prepare(vm_id: str, user: str, **kwargs: object) -> None: captured["prepare"] = (vm_id, user) - def fake_attach(vm_id: str, user: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, user, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, kwargs.get("user"), launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_prepare_run_user", fake_prepare) - monkeypatch.setattr(cli, "_attach_as_user", fake_attach) + monkeypatch.setattr(guest_setup, "prepare_run_user", fake_prepare) + monkeypatch.setattr(guest_setup, "attach", fake_attach) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") rc = cli.main(["--config", str(config), "shell", "--keep-running"]) @@ -295,14 +305,14 @@ def test_shell_uses_configured_project_path_as_cwd( ) captured: dict[str, object] = {} - monkeypatch.setattr(cli, "_prepare_run_user", lambda vm_id, user: None) + monkeypatch.setattr(guest_setup, "prepare_run_user", lambda *args, **kwargs: None) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") - def fake_attach(vm_id: str, user: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, user, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, kwargs.get("user"), launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_attach_as_user", fake_attach) + monkeypatch.setattr(guest_setup, "attach", fake_attach) rc = cli.main(["--config", str(config), "shell", "--keep-running"]) @@ -323,11 +333,11 @@ def test_shell_root_uses_configured_project_path_as_cwd( ) captured: dict[str, object] = {} - def fake_attach(vm_id: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_attach_as_root", fake_attach) + monkeypatch.setattr(guest_setup, "attach", fake_attach) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") rc = cli.main(["--config", str(config), "shell", "--root", "--keep-running"]) @@ -368,8 +378,12 @@ def test_shell_syncs_env_from_config_before_attach( config.write_text('[sbx]\nname = "vm1"\nenv = ["SBX_TOKEN"]\n', encoding="utf-8") calls: list[str] = [] - monkeypatch.setattr(cli, "_sync_forwarded_env", lambda *args: calls.append("sync")) - monkeypatch.setattr(cli, "_run_smolvm", lambda *args, **kwargs: calls.append("attach") or 0) + monkeypatch.setattr( + guest_setup, "sync_forwarded_env", lambda *args, **kwargs: calls.append("sync") + ) + monkeypatch.setattr( + cli.runtime, "run_smolvm", lambda *args, **kwargs: calls.append("attach") or 0 + ) assert cli.main(["--config", str(config), "shell", "--keep-running"]) == 0 assert calls == ["sync", "attach"] @@ -387,8 +401,12 @@ def test_shell_starts_stopped_vm_before_env_sync( monkeypatch.setattr( cli, "_start_existing_vm_if_needed", lambda *args, **kwargs: calls.append("start") or 0 ) - monkeypatch.setattr(cli, "_sync_forwarded_env", lambda *args: calls.append("sync")) - monkeypatch.setattr(cli, "_run_smolvm", lambda *args, **kwargs: calls.append("attach") or 0) + monkeypatch.setattr( + guest_setup, "sync_forwarded_env", lambda *args, **kwargs: calls.append("sync") + ) + monkeypatch.setattr( + cli.runtime, "run_smolvm", lambda *args, **kwargs: calls.append("attach") or 0 + ) assert cli.main(["--config", str(config), "shell", "--keep-running"]) == 0 assert calls == ["start", "sync", "attach"] @@ -402,7 +420,7 @@ def test_shell_invalid_env_fails_before_attach( config.write_text('[sbx]\nname = "vm1"\nenv = ["BAD-NAME"]\n', encoding="utf-8") monkeypatch.setattr( - cli, "_run_smolvm", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError) + cli.runtime, "run_smolvm", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError) ) assert cli.main(["--config", str(config), "shell", "--keep-running"]) == 2 @@ -565,17 +583,17 @@ def fake_run_capture( stderr="", ) - def fake_prepare(vm_id: str, user: str) -> None: + def fake_prepare(vm_id: str, user: str, **kwargs: object) -> None: captured["prepare"] = (vm_id, user) - def fake_attach(vm_id: str, user: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, user, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, kwargs.get("user"), launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) - monkeypatch.setattr(cli, "_prepare_run_user", fake_prepare) - monkeypatch.setattr(cli, "_attach_as_user", fake_attach) + monkeypatch.setattr(guest_setup, "prepare_run_user", fake_prepare) + monkeypatch.setattr(guest_setup, "attach", fake_attach) config = tmp_path / "config.toml" config.write_text( """ @@ -628,10 +646,10 @@ def fake_run( return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") return subprocess.CompletedProcess(argv, 0, stdout=f"{value}\n", stderr="") - monkeypatch.setattr(cli, "_host_git_config", _ORIGINAL_HOST_GIT_CONFIG) - monkeypatch.setattr(cli.subprocess, "run", fake_run) + monkeypatch.setattr(guest_setup, "host_git_config", _ORIGINAL_HOST_GIT_CONFIG) + monkeypatch.setattr(guest_setup.subprocess, "run", fake_run) - assert cli._host_git_config() == ( + assert guest_setup.host_git_config() == ( '[user]\n\tname = "Ada Lovelace"\n\temail = "ada@example.test"\n\n' '[init]\n\tdefaultBranch = "main"\n' ) @@ -643,14 +661,16 @@ def test_git_config_defaults_on_for_managed_run( ) -> None: install_fake_smolvm(monkeypatch, tmp_path) captured: dict[str, object] = {} - monkeypatch.setattr(cli, "_host_git_config", lambda: "[user]\n\tname = Test\n") + monkeypatch.setattr( + guest_setup, "host_git_config", lambda project_root=None: "[user]\n\tname = Test\n" + ) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) monkeypatch.setattr( - cli, - "_install_git_config", - lambda vm_id, user, text: captured.update({"git": (vm_id, user, text)}), + guest_setup, + "install_git_config", + lambda vm_id, user, text, **kwargs: captured.update({"git": (vm_id, user, text)}), ) - monkeypatch.setattr(cli, "_attach_as_root", lambda vm_id, launch_command, cwd=None: 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) def fake_run_capture( argv: list[str], *, env: dict[str, str] | None = None @@ -662,7 +682,7 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) assert cli.main(["run"]) == 0 assert captured["git"] == ("vm1", None, "[user]\n\tname = Test\n") @@ -674,14 +694,16 @@ def test_no_git_config_disables_forwarding( ) -> None: install_fake_smolvm(monkeypatch, tmp_path) captured: dict[str, object] = {} - monkeypatch.setattr(cli, "_host_git_config", lambda: "[user]\n\tname = Test\n") + monkeypatch.setattr( + guest_setup, "host_git_config", lambda project_root=None: "[user]\n\tname = Test\n" + ) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) monkeypatch.setattr( - cli, - "_install_git_config", - lambda vm_id, user, text: captured.update({"git": (vm_id, user, text)}), + guest_setup, + "install_git_config", + lambda vm_id, user, text, **kwargs: captured.update({"git": (vm_id, user, text)}), ) - monkeypatch.setattr(cli, "_attach_as_root", lambda vm_id, launch_command, cwd=None: 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) def fake_run_capture( argv: list[str], *, env: dict[str, str] | None = None @@ -693,7 +715,7 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) assert cli.main(["run", "--no-git-config"]) == 0 assert captured["git"] == ("vm1", None, None) @@ -708,7 +730,7 @@ def test_credential_free_env_preserves_real_smolvm_state( monkeypatch.setenv("HOME", str(home)) monkeypatch.delenv("SMOLVM_DATA_DIR", raising=False) - env = cli._credential_free_env(tmp_path / "temp-home", forward_env=[]) + env = guest_setup.credential_free_env(tmp_path / "temp-home", forward_env=[]) assert env["HOME"] == str(tmp_path / "temp-home") assert env["SMOLVM_DATA_DIR"] == str(home / ".local" / "state" / "smolvm") @@ -731,8 +753,8 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["env"] = env return 0 - monkeypatch.setattr(cli, "_credential_free_env", fake_credential_free_env) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(guest_setup, "credential_free_env", fake_credential_free_env) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main(["run", "--no-attach", "--no-auth-port"]) @@ -766,7 +788,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["env"] = env return 0 - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main(["run", "--no-attach", "--copy-host-credentials", "--no-auth-port"]) @@ -787,7 +809,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["env"] = env return 0 - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main( [ @@ -819,8 +841,8 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["env"] = env return 0 - monkeypatch.setattr(cli, "_credential_free_env", fail_credential_free_env) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(guest_setup, "credential_free_env", fail_credential_free_env) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main(["run", "--no-attach", "--copy-host-credentials", "--no-auth-port"]) @@ -845,7 +867,7 @@ def test_destroy_deletes_vm( ) -> None: install_fake_smolvm(monkeypatch, tmp_path) - rc = cli.main(["rm", "vm1", "--force"]) + rc = cli.main(["remove", "vm1", "--force"]) assert rc == 0 assert capfd.readouterr().out == "Destroyed VM 'vm1'.\n" @@ -895,9 +917,7 @@ def test_write_config_updates_only_missing_values( tmp_path: Path, capfd: pytest.CaptureFixture[str], ) -> None: - (tmp_path / ".sbx.toml").write_text( - '[sbx]\nname = "vm1"\nmemory = 4096\n', encoding="utf-8" - ) + (tmp_path / ".sbx.toml").write_text('[sbx]\nname = "vm1"\nmemory = 4096\n', encoding="utf-8") install_fake_smolvm(monkeypatch, tmp_path) rc = cli.main( @@ -927,8 +947,8 @@ def test_reusing_existing_vm_writes_config_only_when_requested( smolvm = bin_dir / "smolvm" smolvm.write_text( "#!/bin/sh\n" - "if [ \"$1\" = sandbox ] && [ \"$2\" = info ]; then\n" - " printf '%s\\n' '{\"data\":{\"vm\":{\"status\":\"stopped\"}}}'\n" + 'if [ "$1" = sandbox ] && [ "$2" = info ]; then\n' + ' printf \'%s\\n\' \'{"data":{"vm":{"status":"stopped"}}}\'\n' " exit 0\n" "fi\n" "printf '%s\\n' \"$*\"\n", @@ -936,8 +956,8 @@ def test_reusing_existing_vm_writes_config_only_when_requested( ) smolvm.chmod(0o755) monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}") - monkeypatch.setattr(cli, "_smolvm_argv", lambda args: ["smolvm", *args]) - monkeypatch.setattr(cli, "_sync_guest_clock", lambda vm_id: None) + monkeypatch.setattr(cli.runtime, "smolvm_argv", lambda args: ["smolvm", *args]) + monkeypatch.setattr(guest_setup, "sync_guest_clock", lambda vm_id, **kwargs: None) assert cli.main(["run", "vm1", "--no-attach", "--no-auth-port"]) == 0 assert not (tmp_path / ".sbx.toml").exists() @@ -998,12 +1018,12 @@ def fake_run_capture( stderr="", ) - def fake_attach(vm_id: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) - monkeypatch.setattr(cli, "_attach_as_root", fake_attach) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) + monkeypatch.setattr(guest_setup, "attach", fake_attach) rc = cli.main( [ @@ -1058,13 +1078,13 @@ def fake_expose(vm_id: str, host_port: int, guest_port: int) -> int: captured["expose"] = (vm_id, host_port, guest_port) return 0 - def fake_attach(vm_id: str, launch_command: str, cwd: str | None = None) -> int: - captured["attach"] = (vm_id, launch_command, cwd) + def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: + captured["attach"] = (vm_id, launch_command, kwargs.get("cwd")) return 0 - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) monkeypatch.setattr(cli.network, "expose_auth_port", fake_expose) - monkeypatch.setattr(cli, "_attach_as_root", fake_attach) + monkeypatch.setattr(guest_setup, "attach", fake_attach) rc = cli.main(["run", "--copy-host-credentials"]) @@ -1109,11 +1129,11 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None calls.append(argv) return 0 - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) - monkeypatch.setattr(cli, "_run", fake_run) - monkeypatch.setattr(cli, "_sync_guest_clock", lambda vm_id: None) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run", fake_run) + monkeypatch.setattr(guest_setup, "sync_guest_clock", lambda vm_id, **kwargs: None) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) - monkeypatch.setattr(cli, "_attach_as_root", lambda vm_id, launch_command, cwd=None: 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) rc = cli.main(["run", "--name", "vm1"]) @@ -1145,7 +1165,7 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) rc = cli.main(["run", "--name", "vm1"]) @@ -1175,7 +1195,7 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) rc = cli.main(["run", "--name", "vm1"]) @@ -1213,8 +1233,8 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["create"] = argv return 0 - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main(["run", "pi-sbx", "--no-auth-port", "--no-attach"]) @@ -1256,9 +1276,9 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) - monkeypatch.setattr(cli, "_attach_as_root", lambda vm_id, launch_command, cwd=None: 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) rc = cli.main(["run", "pi-sbx"]) @@ -1301,9 +1321,9 @@ def fake_run_capture( stderr="", ) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) - monkeypatch.setattr(cli, "_attach_as_root", lambda vm_id, launch_command, cwd=None: 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) rc = cli.main(["run", "--name", "vm1", "--copy-host-credentials"]) @@ -1334,7 +1354,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None captured["argv"] = argv return 0 - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main(["create", "--name", "vm1", "--copy-host-credentials", "--no-auth-port"]) @@ -1530,11 +1550,11 @@ def test_network_commands_default_to_configured_name( monkeypatch.setattr( cli.network, "expose_auth_port", - lambda vm_id, host_port, guest_port, *, replace=False: captured.setdefault( - "expose", (vm_id, host_port, guest_port, replace) - ) - and 0, + lambda vm_id, host_port, guest_port, *, replace=False: ( + captured.setdefault("expose", (vm_id, host_port, guest_port, replace)) and 0 + ), ) + def fake_close_auth_tunnel(vm_id: str) -> bool: captured["close"] = vm_id return False @@ -1609,7 +1629,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None return 0 monkeypatch.setattr(cli, "_delete_vm", fake_delete) - monkeypatch.setattr(cli, "_run", fake_run) + monkeypatch.setattr(cli.runtime, "run", fake_run) rc = cli.main( [ @@ -1819,10 +1839,12 @@ def test_create_sets_hostname_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa install_fake_smolvm(monkeypatch, tmp_path) hostnames: list[str] = [] - monkeypatch.setattr(cli, "_set_vm_hostname", hostnames.append) monkeypatch.setattr( - cli, - "_run_smolvm_capture", + guest_setup, "set_hostname", lambda vm_id, **kwargs: hostnames.append(vm_id) + ) + monkeypatch.setattr( + cli.runtime, + "run_smolvm_capture", lambda argv, **kwargs: subprocess.CompletedProcess( argv, 0, @@ -1837,7 +1859,9 @@ def test_create_sets_hostname_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa def test_existing_vm_start_does_not_reset_hostname(monkeypatch: pytest.MonkeyPatch) -> None: hostnames: list[str] = [] - monkeypatch.setattr(cli, "_set_vm_hostname", hostnames.append) + monkeypatch.setattr( + guest_setup, "set_hostname", lambda vm_id, **kwargs: hostnames.append(vm_id) + ) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda name: "stopped") monkeypatch.setattr(cli, "_start_existing_vm_if_needed", lambda *args, **kwargs: 0) monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) diff --git a/tests/test_cli_extra.py b/tests/test_cli_extra.py index 8e71ea6..b77d75b 100644 --- a/tests/test_cli_extra.py +++ b/tests/test_cli_extra.py @@ -10,20 +10,23 @@ import smolvm.facade import smolvm.utils -from sbx import cli +from sbx import cli, guest_setup, lifecycle_warnings, runtime, session_state, vm_metadata, vm_state @pytest.fixture(autouse=True) def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr(cli, "DEBUG", False) + monkeypatch.setattr(cli.runtime, "DEBUG", False) monkeypatch.setattr(cli, "DEFAULT_CONFIG_PATHS", (tmp_path / "home-config.toml",)) monkeypatch.setattr(cli, "LOCAL_CONFIG_PATHS", (tmp_path / ".sbx.toml",)) - monkeypatch.setattr(cli, "SBX_STATE_DIR", tmp_path / "state") + monkeypatch.setattr(vm_metadata, "SBX_STATE_DIR", tmp_path / "state") + monkeypatch.setattr(vm_metadata, "SBX_VMS_FILE", tmp_path / "state" / "vms.json") + monkeypatch.setattr(session_state, "SBX_STATE_DIR", tmp_path / "state") + monkeypatch.setattr(session_state, "SESSIONS_FILE", tmp_path / "state" / "sessions.json") monkeypatch.setattr(cli.network, "SBX_STATE_DIR", tmp_path / "state") monkeypatch.setattr(cli.network, "TUNNELS_FILE", tmp_path / "state" / "tunnels.json") - monkeypatch.setattr(cli, "SESSIONS_FILE", tmp_path / "state" / "sessions.json") monkeypatch.setattr(cli, "SMOLVM_DB_PATH", tmp_path / "smolvm.db") - monkeypatch.setattr(cli, "_set_vm_hostname", lambda name: None) + monkeypatch.setattr(vm_state, "SMOLVM_DB_PATH", tmp_path / "smolvm.db") + monkeypatch.setattr(guest_setup, "set_hostname", lambda *args, **kwargs: None) @pytest.fixture @@ -84,11 +87,6 @@ def fake_vm_config(**kwargs: object) -> dict[str, object]: return captured -@pytest.fixture -def completed_ok() -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess(["cmd"], 0, stdout="ok", stderr="") - - def _write_vm_row(db_path: Path, vm_id: str, *, status: str, config: dict[str, object]) -> None: with sqlite3.connect(db_path) as conn: conn.execute("CREATE TABLE vms (id TEXT PRIMARY KEY, status TEXT, config TEXT)") @@ -107,6 +105,105 @@ def _read_vm_config(db_path: Path, vm_id: str) -> dict[str, object]: return config +def test_vm_metadata_load_save_and_project_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + project = tmp_path / "project" + project.mkdir() + config = project / ".sbx.toml" + config.write_text('[sbx]\nproject_path = "."\n', encoding="utf-8") + monkeypatch.setattr(cli, "LOCAL_CONFIG_PATHS", (config,)) + monkeypatch.chdir(project) + + args = SimpleNamespace(config=None) + identity = cli._project_identity(args, {"sbx": {"project_path": "."}}) + vm_metadata.record_vm_project("vm1", identity) + + assert vm_metadata.load_vm_metadata() == {"vm1": identity} + assert identity["project_root"] == str(project.resolve()) + assert identity["config_path"] == str(config.resolve()) + + +def test_vm_metadata_corrupt_json_raises() -> None: + vm_metadata.SBX_VMS_FILE.parent.mkdir(parents=True) + vm_metadata.SBX_VMS_FILE.write_text("not json", encoding="utf-8") + + with pytest.raises(cli.ConfigError, match="invalid sbx VM metadata"): + vm_metadata.load_vm_metadata() + + +def test_sync_existing_vm_mounts_rejects_wrong_project(tmp_path: Path) -> None: + old = tmp_path / "old" + new = tmp_path / "new" + old.mkdir() + new.mkdir() + _write_vm_row(cli.SMOLVM_DB_PATH, "vm1", status="stopped", config={"workspace_mounts": []}) + vm_metadata.record_vm_project( + "vm1", {"project_root": str(old), "config_path": str(old / ".sbx.toml")} + ) + + with pytest.raises(cli.ConfigError, match="belongs to"): + cli._sync_existing_vm_start_config( + "vm1", + [f"{new}:/workspace"], + writable_mounts=True, + port_forwards=[], + project={"project_root": str(new), "config_path": str(new / ".sbx.toml")}, + ) + + assert _read_vm_config(cli.SMOLVM_DB_PATH, "vm1") == {"workspace_mounts": []} + + +def test_warn_running_mount_drift_does_not_rewrite( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + host = tmp_path / "project" + host.mkdir() + config = {"workspace_mounts": [{"host_path": "/old", "guest_path": "/old"}]} + _write_vm_row(cli.SMOLVM_DB_PATH, "vm1", status="running", config=config) + + cli._warn_running_mount_drift("vm1", [f"{host}:/workspace"], writable_mounts=True) + + assert "mounts that differ" in capsys.readouterr().err + assert _read_vm_config(cli.SMOLVM_DB_PATH, "vm1") == config + + +def test_host_git_config_uses_repo_local_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(argv) + if argv[:3] == ["git", "-C", str(tmp_path)] and argv[-1] == "user.email": + return subprocess.CompletedProcess(argv, 0, stdout="repo@example.com\n", stderr="") + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") + + monkeypatch.setattr(guest_setup.subprocess, "run", fake_run) + + assert guest_setup.host_git_config(tmp_path) == '[user]\n\temail = "repo@example.com"\n' + assert ["git", "-C", str(tmp_path), "config", "--get", "user.email"] in calls + + +def test_doctor_fix_repairs_local_bookkeeping( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + missing = tmp_path / "missing" + vm_metadata.record_vm_project( + "vm1", {"project_root": str(missing), "config_path": str(missing / ".sbx.toml")} + ) + session_state.save_sessions({"vm1": {"sessions": [{"pid": -1, "kind": "run"}]}}) + cli.network._save_tunnels({"vm1": {"auth_port": {"pid": -1, "host_port": 1}}}) + monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=True: []) + monkeypatch.setattr(cli.runtime, "run_smolvm", lambda argv: 0) + + assert cli.main(["doctor", "--fix"]) == 0 + + assert vm_metadata.load_vm_metadata() == {} + assert session_state.load_sessions() == {} + assert cli.network._load_tunnels() == {} + + def test_sync_existing_vm_mounts_updates_stale_stopped_vm( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -119,8 +216,8 @@ def test_sync_existing_vm_mounts_updates_stale_stopped_vm( config={"workspace_mounts": [{"host_path": "/old", "guest_path": "/old"}]}, ) - cli._sync_existing_vm_mounts_from_config( - "vm1", [f"{host}:/workspace"], writable_mounts=True + cli._sync_existing_vm_start_config( + "vm1", [f"{host}:/workspace"], writable_mounts=True, port_forwards=[] ) assert _read_vm_config(cli.SMOLVM_DB_PATH, "vm1")["workspace_mounts"] == [ @@ -147,8 +244,8 @@ def test_sync_existing_vm_mounts_skips_matching_config( } _write_vm_row(cli.SMOLVM_DB_PATH, "vm1", status="stopped", config=config) - cli._sync_existing_vm_mounts_from_config( - "vm1", [f"{host}:/workspace"], writable_mounts=False + cli._sync_existing_vm_start_config( + "vm1", [f"{host}:/workspace"], writable_mounts=False, port_forwards=[] ) assert _read_vm_config(cli.SMOLVM_DB_PATH, "vm1") == config @@ -166,8 +263,10 @@ def test_cmd_start_syncs_env_before_existing_vm_attach( monkeypatch.setattr( cli, "_start_existing_vm_if_needed", lambda *args, **kwargs: calls.append("start") or 0 ) - monkeypatch.setattr(cli, "_host_git_config", lambda: None) - monkeypatch.setattr(cli, "_sync_forwarded_env", lambda *args: calls.append("sync")) + monkeypatch.setattr(guest_setup, "host_git_config", lambda project_root=None: None) + monkeypatch.setattr( + guest_setup, "sync_forwarded_env", lambda *args, **kwargs: calls.append("sync") + ) monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: calls.append("attach") or 0) assert cli.main(["--config", str(config), "run"]) == 0 @@ -197,10 +296,10 @@ def test_cmd_start_does_not_sync_running_vm_mounts( calls: list[str] = [] monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") - monkeypatch.setattr(cli, "_host_git_config", lambda: None) + monkeypatch.setattr(guest_setup, "host_git_config", lambda project_root=None: None) monkeypatch.setattr( cli, - "_sync_existing_vm_mounts_from_config", + "_sync_existing_vm_start_config", lambda *args, **kwargs: calls.append("sync"), ) monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) @@ -255,12 +354,12 @@ def test_workspace_mount_specs_reject_duplicate_guest_paths(tmp_path: Path) -> N def test_sync_existing_vm_mounts_missing_db_or_row_does_nothing( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - cli._sync_existing_vm_mounts_from_config("missing", [], writable_mounts=False) + cli._sync_existing_vm_start_config("missing", [], writable_mounts=False, port_forwards=[]) assert capsys.readouterr().out == "" with sqlite3.connect(cli.SMOLVM_DB_PATH) as conn: conn.execute("CREATE TABLE vms (id TEXT PRIMARY KEY, status TEXT, config TEXT)") - cli._sync_existing_vm_mounts_from_config("missing", [], writable_mounts=False) + cli._sync_existing_vm_start_config("missing", [], writable_mounts=False, port_forwards=[]) assert capsys.readouterr().out == "" @@ -271,7 +370,9 @@ def test_start_local_image_happy_path( ) -> None: captured: dict[str, object] = {} calls: list[str] = [] - monkeypatch.setattr(cli, "_sync_forwarded_env", lambda *args: calls.append("sync")) + monkeypatch.setattr( + guest_setup, "sync_forwarded_env", lambda *args, **kwargs: calls.append("sync") + ) monkeypatch.setattr( cli, "_post_start_actions", @@ -283,7 +384,7 @@ def test_start_local_image_happy_path( args=args, config={"sbx": {}}, image_dir=local_image_dir, - manifest=cli._local_image_manifest(local_image_dir), + manifest=lifecycle_warnings.local_image_manifest(local_image_dir), agent="pi", mounts=[".:/workspace"], writable_mounts=True, @@ -313,7 +414,7 @@ def test_start_local_image_happy_path( assert vm_config["port_forwards"] == [ {"host_address": "127.0.0.1", "host_port": 8080, "guest_port": 3000} ] - assert captured["launch_command"] == "custom-pi" + assert captured["command"] == "custom-pi" assert captured["git_config_text"] == "[user]\n" @@ -324,20 +425,16 @@ def test_run_helpers_and_require_errors( def missing_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: raise FileNotFoundError - monkeypatch.setattr(cli.subprocess, "run", missing_run) - assert cli._run(["missing"]) == 127 - assert cli._run_capture(["missing"]) is None + monkeypatch.setattr(runtime.subprocess, "run", missing_run) + assert runtime.run(["missing"]) == 127 + assert runtime.run_capture(["missing"]) is None assert "command not found" in capsys.readouterr().err def called_process_error(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: raise subprocess.CalledProcessError(9, ["cmd"]) - monkeypatch.setattr(cli.subprocess, "run", called_process_error) - assert cli._run(["cmd"]) == 9 - - monkeypatch.setattr(cli.shutil, "which", lambda command: None) - assert cli._require("missing", "install it") is False - assert "install it" in capsys.readouterr().err + monkeypatch.setattr(runtime.subprocess, "run", called_process_error) + assert runtime.run(["cmd"]) == 9 def test_simple_helper_error_branches(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -345,9 +442,9 @@ def test_simple_helper_error_branches(tmp_path: Path, capsys: pytest.CaptureFixt assert cli._resolve_project_path(str(tmp_path / "missing")) == tmp_path / "missing" assert cli._same_path_mount(str(tmp_path)) == f"{tmp_path}:{tmp_path}" with pytest.raises(cli.ConfigError, match="run_user"): - cli._validate_run_user("bad user") + guest_setup.validate_run_user("bad user") with pytest.raises(cli.ConfigError, match="invalid env var"): - cli._validate_env_names(["1BAD"]) + guest_setup.validate_env_names(["1BAD"]) cli._print_start_failure('{"error":{"message":"QEMU exited early"}}') assert "Try `sbx recreate" in capsys.readouterr().err @@ -366,16 +463,16 @@ def from_id(cls, vm_id: str) -> FakeVm: return FakeVm() monkeypatch.setattr(smolvm.facade, "SmolVM", FakeSmolVM) - assert cli._ssh_command("vm1") == ["ssh", "root@host"] + assert guest_setup.ssh_command("vm1") == ["ssh", "root@host"] - monkeypatch.setattr(cli.os, "kill", lambda pid, sig: None) - assert cli._pid_is_alive(123) is True + monkeypatch.setattr(session_state.os, "kill", lambda pid, sig: None) + assert session_state.pid_is_alive(123) is True def missing_process(pid: int, sig: int) -> None: raise ProcessLookupError - monkeypatch.setattr(cli.os, "kill", missing_process) - assert cli._pid_is_alive(123) is False + monkeypatch.setattr(session_state.os, "kill", missing_process) + assert session_state.pid_is_alive(123) is False def test_ssh_command_missing_vm_raises_config_error(monkeypatch: pytest.MonkeyPatch) -> None: @@ -389,7 +486,7 @@ def from_id(cls, vm_id: str) -> object: monkeypatch.setattr(smolvm.facade, "SmolVM", MissingSmolVM) with pytest.raises(cli.ConfigError, match="VM 'reviewhero' not found"): - cli._ssh_command("reviewhero") + guest_setup.ssh_command("reviewhero") def test_shell_prechecks_missing_managed_vm_before_registering_session( @@ -404,7 +501,7 @@ def test_shell_prechecks_missing_managed_vm_before_registering_session( def fail_register(vm_id: str, kind: str) -> None: raise AssertionError("session should not be registered for a missing VM") - monkeypatch.setattr(cli, "_register_session", fail_register) + monkeypatch.setattr(session_state, "register_session", fail_register) rc = cli.main(["--config", str(config), "shell"]) @@ -424,7 +521,7 @@ def test_shell_reports_missing_vm_from_smolvm_without_traceback( config = tmp_path / "config.toml" config.write_text('[sbx]\nname = "reviewhero"\nrun_user = "agent"\n', encoding="utf-8") monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") - monkeypatch.setattr(cli, "_host_git_config", lambda: None) + monkeypatch.setattr(guest_setup, "host_git_config", lambda project_root=None: None) monkeypatch.setattr(cli, "_stop_vm_if_last_session", lambda vm_id, *, stop_on_exit: None) class MissingSmolVM: @@ -469,7 +566,7 @@ def close(self) -> None: args=args, config={"sbx": {}}, image_dir=local_image_dir, - manifest=cli._local_image_manifest(local_image_dir), + manifest=lifecycle_warnings.local_image_manifest(local_image_dir), agent="pi", mounts=[], writable_mounts=False, @@ -554,28 +651,28 @@ def test_start_local_image_validation_errors( def test_local_image_manifest_errors(tmp_path: Path) -> None: with pytest.raises(cli.ConfigError, match="must point to a local image directory"): - cli._local_image_manifest(tmp_path / "missing") + lifecycle_warnings.local_image_manifest(tmp_path / "missing") image = tmp_path / "image" image.mkdir() with pytest.raises(cli.ConfigError, match="manifest not found"): - cli._local_image_manifest(image) + lifecycle_warnings.local_image_manifest(image) (image / "smolvm-image.json").write_text("not-json", encoding="utf-8") with pytest.raises(cli.ConfigError, match="invalid image manifest JSON"): - cli._local_image_manifest(image) + lifecycle_warnings.local_image_manifest(image) (image / "smolvm-image.json").write_text("[]", encoding="utf-8") with pytest.raises(cli.ConfigError, match="must be a JSON object"): - cli._local_image_manifest(image) + lifecycle_warnings.local_image_manifest(image) def test_git_config_missing_git_and_escaping(monkeypatch: pytest.MonkeyPatch) -> None: def missing_git(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: raise FileNotFoundError - monkeypatch.setattr(cli.subprocess, "run", missing_git) - assert cli._host_git_config() is None + monkeypatch.setattr(guest_setup.subprocess, "run", missing_git) + assert guest_setup.host_git_config() is None values = {"user.name": 'Ada "Back\\slash"', "user.email": "multi\nline"} @@ -585,25 +682,25 @@ def fake_git(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[s return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") return subprocess.CompletedProcess(argv, 0, stdout=value + "\n", stderr="") - monkeypatch.setattr(cli.subprocess, "run", fake_git) - assert cli._host_git_config() == '[user]\n\tname = "Ada \\"Back\\\\slash\\""\n' + monkeypatch.setattr(guest_setup.subprocess, "run", fake_git) + assert guest_setup.host_git_config() == '[user]\n\tname = "Ada \\"Back\\\\slash\\""\n' def test_install_git_config_for_root_and_user( monkeypatch: pytest.MonkeyPatch, ) -> None: commands: list[list[str]] = [] - monkeypatch.setattr(cli, "_ssh_command", lambda vm_id: ["ssh", "root@host"]) + monkeypatch.setattr(guest_setup, "ssh_command", lambda vm_id: ["ssh", "root@host"]) def fake_run_capture(argv: list[str], *, env: dict[str, str] | None = None): commands.append(argv) return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) - cli._install_git_config("vm1", None, "[user]\n") - cli._install_git_config("vm1", "agent", "[user]\n") - cli._install_git_config("vm1", "agent", None) + guest_setup.install_git_config("vm1", None, "[user]\n", run_capture=fake_run_capture) + guest_setup.install_git_config("vm1", "agent", "[user]\n", run_capture=fake_run_capture) + guest_setup.install_git_config("vm1", "agent", None, run_capture=fake_run_capture) assert "/root/.gitconfig" in commands[0][-1] assert "/home/agent/.gitconfig" in commands[1][-1] @@ -613,38 +710,43 @@ def fake_run_capture(argv: list[str], *, env: dict[str, str] | None = None): def test_prepare_run_user_success_and_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "_ssh_command", lambda vm_id: ["ssh", "root@host"]) + monkeypatch.setattr(guest_setup, "ssh_command", lambda vm_id: ["ssh", "root@host"]) captured: dict[str, object] = {} def fake_ok(argv: list[str], *, env: dict[str, str] | None = None): captured["argv"] = argv return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - monkeypatch.setattr(cli, "_run_capture", fake_ok) - cli._prepare_run_user("vm1", "agent") + monkeypatch.setattr(cli.runtime, "run_capture", fake_ok) + guest_setup.prepare_run_user("vm1", "agent", run_capture=fake_ok) assert "getent hosts" in captured["argv"][-1] assert "127.0.1.1" in captured["argv"][-1] assert ".ssh .pi .codex .claude .claude.json" in captured["argv"][-1] - monkeypatch.setattr(cli, "_run_capture", lambda argv, **kwargs: None) + monkeypatch.setattr(cli.runtime, "run_capture", lambda argv, **kwargs: None) with pytest.raises(cli.ConfigError, match="ssh command not found"): - cli._prepare_run_user("vm1", "agent") + guest_setup.prepare_run_user("vm1", "agent", run_capture=lambda argv: None) def fake_fail(argv: list[str], *, env: dict[str, str] | None = None): return subprocess.CompletedProcess(argv, 1, stdout="", stderr="bad") - monkeypatch.setattr(cli, "_run_capture", fake_fail) + monkeypatch.setattr(cli.runtime, "run_capture", fake_fail) with pytest.raises(cli.ConfigError, match="bad"): - cli._prepare_run_user("vm1", "agent") + guest_setup.prepare_run_user("vm1", "agent", run_capture=fake_fail) def test_attach_commands(monkeypatch: pytest.MonkeyPatch) -> None: commands: list[list[str]] = [] - monkeypatch.setattr(cli, "_ssh_command", lambda vm_id: ["ssh", "root@host"]) - monkeypatch.setattr(cli, "_run", lambda argv: commands.append(list(argv)) or 0) + monkeypatch.setattr(guest_setup, "ssh_command", lambda vm_id: ["ssh", "root@host"]) + + def run(argv: list[str]) -> int: + commands.append(list(argv)) + return 0 + + monkeypatch.setattr(cli.runtime, "run", run) - assert cli._attach_as_root("vm1", "pi", cwd="/workspace") == 0 - assert cli._attach_as_user("vm1", "agent", "pi", cwd="/workspace") == 0 + assert guest_setup.attach("vm1", "pi", cwd="/workspace", run=run) == 0 + assert guest_setup.attach("vm1", "pi", user="agent", cwd="/workspace", run=run) == 0 assert "-t" in commands[0] assert "cd /workspace" in commands[0][-1] @@ -659,11 +761,10 @@ def fake_run_capture(argv: list[str]) -> subprocess.CompletedProcess[str]: captured.extend(argv) return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - monkeypatch.setattr(cli, "_ssh_command", lambda vm_id: ["ssh", vm_id]) - monkeypatch.setattr(cli, "_host_timezone", lambda: "America/Chicago") - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(guest_setup, "ssh_command", lambda vm_id: ["ssh", vm_id]) + monkeypatch.setattr(guest_setup, "host_timezone", lambda: "America/Chicago") - cli._sync_guest_clock("vm1") + guest_setup.sync_guest_clock("vm1", run_capture=fake_run_capture) assert captured[:2] == ["ssh", "vm1"] assert "America/Chicago" in captured[2] @@ -676,27 +777,38 @@ def test_post_start_actions_paths(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( cli.network, "expose_auth_port", lambda *args: calls.append(("port", args)) or 0 ) - monkeypatch.setattr(cli, "_register_session", lambda *args: calls.append(("register", args))) monkeypatch.setattr( - cli, "_unregister_session", lambda *args: calls.append(("unregister", args)) + session_state, "register_session", lambda *args: calls.append(("register", args)) + ) + monkeypatch.setattr( + session_state, "unregister_session", lambda *args: calls.append(("unregister", args)) or 0 ) monkeypatch.setattr( cli, "_stop_vm_if_last_session", lambda *args, **kwargs: calls.append(("stop", kwargs)) ) - monkeypatch.setattr(cli, "_sync_guest_clock", lambda *args: calls.append(("clock", args))) - monkeypatch.setattr(cli, "_prepare_run_user", lambda *args: calls.append(("prepare", args))) - monkeypatch.setattr(cli, "_install_git_config", lambda *args: calls.append(("git", args))) monkeypatch.setattr( - cli, "_attach_as_user", lambda *args, **kwargs: calls.append(("user", args)) or 0 + guest_setup, "sync_guest_clock", lambda *args, **kwargs: calls.append(("clock", args)) + ) + monkeypatch.setattr( + guest_setup, + "prepare_run_user", + lambda *args, **kwargs: calls.append(("prepare", args)), + ) + monkeypatch.setattr( + guest_setup, + "install_git_config", + lambda *args, **kwargs: calls.append(("git", args)), ) monkeypatch.setattr( - cli, "_attach_as_root", lambda *args, **kwargs: calls.append(("root", args)) or 0 + guest_setup, + "attach", + lambda *args, **kwargs: calls.append(("user" if kwargs.get("user") else "root", args)) or 0, ) assert ( cli._post_start_actions( vm_name="vm1", - agent="pi", + command="pi", attach=False, run_user=None, auth_port=True, @@ -712,7 +824,7 @@ def test_post_start_actions_paths(monkeypatch: pytest.MonkeyPatch) -> None: assert ( cli._post_start_actions( vm_name="vm1", - agent="pi", + command="pi", attach=True, run_user="agent", auth_port=False, @@ -735,7 +847,7 @@ def test_post_start_actions_paths(monkeypatch: pytest.MonkeyPatch) -> None: def test_tunnel_and_session_state(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(cli, "_pid_is_alive", lambda pid: pid == 123) + monkeypatch.setattr(session_state, "pid_is_alive", lambda pid: pid == 123) monkeypatch.setattr(cli.network, "pid_is_alive", lambda pid: pid == 123) cli.network._record_auth_tunnel("vm1", pid=123, host_port=1455, guest_port=1455) assert cli.network._tracked_auth_tunnel("vm1") == { @@ -753,16 +865,16 @@ def test_tunnel_and_session_state(monkeypatch: pytest.MonkeyPatch) -> None: cli.network._save_tunnels({"bad": [], "dead": {"auth_port": {"pid": 999, "host_port": 1}}}) assert cli.network._tracked_auth_tunnel_for_host_port(1) is None - cli._save_sessions( + session_state.save_sessions( {"vm1": {"sessions": [{"pid": 123, "kind": "run"}, {"pid": 999, "kind": "run"}]}} ) - assert cli._active_sessions("vm1") == [{"pid": 123, "kind": "run"}] + assert session_state.active_sessions("vm1") == [{"pid": 123, "kind": "run"}] stopped: list[list[str]] = [] - monkeypatch.setattr(cli, "_run_smolvm", lambda argv: stopped.append(list(argv)) or 0) + monkeypatch.setattr(cli.runtime, "run_smolvm", lambda argv: stopped.append(list(argv)) or 0) cli._stop_vm_if_last_session("vm1", stop_on_exit=False) assert stopped == [] - cli._save_sessions({}) + session_state.save_sessions({}) cli._stop_vm_if_last_session("vm1", stop_on_exit=True) assert stopped == [["sandbox", "stop", "vm1"]] @@ -805,6 +917,7 @@ def test_foreground_port_forward_uses_one_ssh_for_multiple_ports( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, list[str]] = {} + def fake_run(argv: list[str]) -> int: captured["argv"] = list(argv) return 0 @@ -812,9 +925,12 @@ def fake_run(argv: list[str]) -> int: monkeypatch.setattr(cli.network, "ssh_command", lambda vm_id: ["ssh", "root@host"]) monkeypatch.setattr(cli.network, "run", fake_run) - assert cli.network._foreground_port_forward( - "vm1", [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)] - ) == 0 + assert ( + cli.network._foreground_port_forward( + "vm1", [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)] + ) + == 0 + ) assert captured["argv"] == [ "ssh", "-N", @@ -831,13 +947,13 @@ def fake_run(argv: list[str]) -> int: def test_delete_vm_error_paths( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setattr(cli, "_run_capture", lambda argv, **kwargs: None) + monkeypatch.setattr(cli.runtime, "run_capture", lambda argv, **kwargs: None) assert cli._delete_vm("vm1") == 127 not_found = json.dumps({"data": {"failed": [{"error": "VM 'vm1' not found"}]}}) monkeypatch.setattr( - cli, - "_run_capture", + cli.runtime, + "run_capture", lambda argv, **kwargs: subprocess.CompletedProcess( argv, 1, stdout=not_found, stderr="warn\n" ), @@ -848,8 +964,8 @@ def test_delete_vm_error_paths( assert "nothing to destroy" in captured.out monkeypatch.setattr( - cli, - "_run_capture", + cli.runtime, + "run_capture", lambda argv, **kwargs: subprocess.CompletedProcess(argv, 3, stdout="not-json", stderr=""), ) assert cli._delete_vm("vm1") == 3 @@ -897,7 +1013,7 @@ def test_network_status_variants( assert cli.network.cmd_status(type("Args", (), {"name": "vm1", "host_port": 1455})()) == 0 assert "active" in capsys.readouterr().out - monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda name: None) + monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda *args, **kwargs: None) monkeypatch.setattr(cli.network, "_localhost_port_is_listening", lambda port: True) assert cli.network.cmd_status(type("Args", (), {"name": "vm1", "host_port": 1455})()) == 0 assert "busy/untracked" in capsys.readouterr().out @@ -929,11 +1045,9 @@ def close(self) -> None: monkeypatch.setenv("SBX_PRESENT", "value") monkeypatch.delenv("SBX_MISSING", raising=False) - cli._sync_forwarded_env("vm1", ["SBX_PRESENT", "SBX_MISSING"]) + guest_setup.sync_forwarded_env("vm1", ["SBX_PRESENT", "SBX_MISSING"]) assert calls == [ - ("from_id", {}), - ("close", None), ("from_id", {}), ("set", {"SBX_PRESENT": "value"}), ("unset", ["SBX_MISSING"]), @@ -962,16 +1076,17 @@ def fake_run_capture(argv: list[str], **kwargs: object) -> subprocess.CompletedP return subprocess.CompletedProcess(argv, 0, "export OLD=kept\nexport SBX_TOKEN=old\n", "") monkeypatch.setattr(smolvm.facade, "SmolVM", FakeSmolVM, raising=False) - monkeypatch.setattr(cli, "_ssh_command", lambda vm_id: ["ssh", vm_id]) - monkeypatch.setattr(cli, "_run_capture", fake_run_capture) + monkeypatch.setattr(guest_setup, "ssh_command", lambda vm_id: ["ssh", vm_id]) + monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) monkeypatch.setenv("SBX_TOKEN", "value") - cli._sync_forwarded_env("vm1", ["SBX_TOKEN", "MISSING"]) + guest_setup.sync_forwarded_env("vm1", ["SBX_TOKEN", "MISSING"], run_capture=fake_run_capture) - assert calls[0:2] == [("from_id", {}), ("close", None)] - assert calls[2] == ("ssh", "cat /etc/profile.d/smolvm_env.sh 2>/dev/null || true") - assert calls[3][0] == "ssh" - assert "base64 -d" in str(calls[3][1]) + assert calls[0] == ("from_id", {}) + assert calls[1] == ("ssh", "cat /etc/profile.d/smolvm_env.sh 2>/dev/null || true") + assert calls[2][0] == "ssh" + assert "base64 -d" in str(calls[2][1]) + assert calls[3] == ("close", None) def test_sync_forwarded_env_empty_allowlist_skips_smolvm( @@ -984,7 +1099,7 @@ def from_id(cls, vm_id: str) -> Self: monkeypatch.setattr(smolvm.facade, "SmolVM", FakeSmolVM, raising=False) - cli._sync_forwarded_env("vm1", []) + guest_setup.sync_forwarded_env("vm1", []) def test_config_and_validation_error_branches(tmp_path: Path) -> None: @@ -1001,9 +1116,9 @@ def test_config_and_validation_error_branches(tmp_path: Path) -> None: with pytest.raises(cli.ConfigError, match="must be a string or an array"): cli._list_value(["ok", 1], key="x") with pytest.raises(cli.ConfigError, match="run_user"): - cli._validate_run_user("bad user") + guest_setup.validate_run_user("bad user") with pytest.raises(cli.ConfigError, match="invalid env var"): - cli._validate_env_names(["BAD-NAME"]) + guest_setup.validate_env_names(["BAD-NAME"]) with pytest.raises(cli.ConfigError, match="could not read"): cli._extract_started_vm_name("not-json") with pytest.raises(cli.ConfigError, match="did not include"): @@ -1013,7 +1128,7 @@ def test_config_and_validation_error_branches(tmp_path: Path) -> None: def test_command_edge_cases( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setattr(cli, "_run_smolvm", lambda *args, **kwargs: 7) + monkeypatch.setattr(cli.runtime, "run_smolvm", lambda *args, **kwargs: 7) assert cli.cmd_doctor(type("Args", (), {})()) == 7 monkeypatch.setattr(cli, "_confirm_destructive_action", lambda *args, **kwargs: False) @@ -1123,7 +1238,7 @@ def test_network_status_inactive( "run_smolvm_capture", lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout=payload, stderr=""), ) - monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda name: None) + monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda *args, **kwargs: None) monkeypatch.setattr(cli.network, "_localhost_port_is_listening", lambda port: False) assert cli.network.cmd_status(type("Args", (), {"name": "vm1", "host_port": 1455})()) == 0 @@ -1186,14 +1301,16 @@ def test_read_toml_oserror_and_create_alias( def test_post_start_actions_auth_port_failure_skips_attach(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[str] = [] - monkeypatch.setattr(cli, "_sync_guest_clock", lambda vm_id: calls.append("clock")) + monkeypatch.setattr( + guest_setup, "sync_guest_clock", lambda vm_id, **kwargs: calls.append("clock") + ) monkeypatch.setattr(cli.network, "expose_auth_port", lambda *args: calls.append("port") or 9) - monkeypatch.setattr(cli, "_attach_as_root", lambda *args, **kwargs: calls.append("attach") or 0) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: calls.append("attach") or 0) assert ( cli._post_start_actions( vm_name="vm1", - agent="pi", + command="pi", attach=True, run_user=None, auth_port=True, @@ -1208,7 +1325,7 @@ def test_post_start_actions_auth_port_failure_skips_attach(monkeypatch: pytest.M def test_close_auth_port_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda name: {"pid": 123}) - monkeypatch.setattr(cli.network, "_remove_auth_tunnel_record", lambda name: None) + monkeypatch.setattr(cli.network, "_remove_auth_tunnel_record", lambda *args, **kwargs: None) monkeypatch.setattr(cli.network.time, "sleep", lambda seconds: None) times = iter([0.0, 4.0]) monkeypatch.setattr(cli.network.time, "monotonic", lambda: next(times)) @@ -1222,9 +1339,11 @@ def test_close_auth_port_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) - def test_stop_vm_skips_when_other_sessions_active(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(cli, "_active_sessions", lambda vm_id: [{"pid": 123, "kind": "run"}]) + monkeypatch.setattr( + session_state, "active_sessions", lambda vm_id: [{"pid": 123, "kind": "run"}] + ) stopped: list[list[str]] = [] - monkeypatch.setattr(cli, "_run", lambda argv: stopped.append(list(argv)) or 0) + monkeypatch.setattr(cli.runtime, "run", lambda argv: stopped.append(list(argv)) or 0) cli._stop_vm_if_last_session("vm1", stop_on_exit=True) @@ -1235,8 +1354,8 @@ def test_delete_vm_success_path( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr( - cli, - "_run_capture", + cli.runtime, + "run_capture", lambda argv, **kwargs: subprocess.CompletedProcess( argv, 0, stdout='{"data": {}}', stderr="" ), @@ -1276,7 +1395,7 @@ def test_local_image_without_launch_command_uses_agent_fallback( ) == 0 ) - assert captured["launch_command"] is None + assert captured["command"] == "pi" vm_config = fake_smolvm_sdk["vm_config"] assert isinstance(vm_config, dict) assert vm_config["memory"] == 1024 @@ -1478,21 +1597,14 @@ def test_recreate_success_deletes_then_starts(monkeypatch: pytest.MonkeyPatch) - def test_start_existing_vm_if_needed_variants(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[list[str]] = [] - marked: list[str] = [] - monkeypatch.setattr(cli, "_run_smolvm", lambda argv: calls.append(list(argv)) or 0) - monkeypatch.setattr( - cli, "_mark_error_vm_stopped_for_restart", lambda vm_id: marked.append(vm_id) - ) + monkeypatch.setattr(cli.runtime, "run_smolvm", lambda argv: calls.append(list(argv)) or 0) assert cli._start_existing_vm_if_needed("vm1", "running", 60) == 0 assert calls == [] assert cli._start_existing_vm_if_needed("vm1", "stopped", 60) == 0 assert calls == [["sandbox", "start", "vm1", "--boot-timeout", "60"]] assert cli._start_existing_vm_if_needed("vm1", "error", 60) == 1 - assert marked == [] - assert cli._start_existing_vm_if_needed("vm1", "error", 60, force_start=True) == 0 - assert marked == ["vm1"] - assert calls[-1] == ["sandbox", "start", "vm1", "--boot-timeout", "60"] + assert calls == [["sandbox", "start", "vm1", "--boot-timeout", "60"]] def test_mark_error_vm_stopped_for_restart_clears_stale_runtime_fields() -> None: @@ -1505,7 +1617,7 @@ def test_mark_error_vm_stopped_for_restart_clears_stale_runtime_fields() -> None ("vm1", "error", 123, "/tmp/stale.sock"), ) - cli._mark_error_vm_stopped_for_restart("vm1") + vm_state.mark_error_vm_stopped_for_restart("vm1") with sqlite3.connect(cli.SMOLVM_DB_PATH) as conn: row = conn.execute( @@ -1521,32 +1633,17 @@ def test_mark_error_vm_stopped_for_restart_clears_stale_runtime_fields() -> None ["shell", "--force-start", "--keep-running", "--no-git-config", "vm1"], ], ) -def test_force_start_is_passed_to_existing_vm_start( - monkeypatch: pytest.MonkeyPatch, argv: list[str] -) -> None: - captured: dict[str, object] = {} - monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "error") - monkeypatch.setattr( - cli, - "_start_existing_vm_if_needed", - lambda vm_id, status, timeout, *, force_start=False: captured.update( - {"vm_id": vm_id, "status": status, "force_start": force_start} - ) - or 0, - ) - monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) - monkeypatch.setattr(cli, "_sync_forwarded_env_or_error", lambda vm_id, names: True) - monkeypatch.setattr(cli, "_run_smolvm", lambda argv: 0) - - assert cli.main(argv) == 0 - assert captured == {"vm_id": "vm1", "status": "error", "force_start": True} +def test_force_start_is_rejected(argv: list[str]) -> None: + with pytest.raises(SystemExit) as exc: + cli.main(argv) + assert exc.value.code == 2 def test_start_existing_vm_timeout_hint_when_vm_is_running( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr(cli, "_run", lambda argv, **kwargs: 1) + monkeypatch.setattr(cli.runtime, "run", lambda argv, **kwargs: 1) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") assert cli._start_existing_vm_if_needed("vm1", "stopped", 60) == 1 @@ -1588,20 +1685,20 @@ def test_pid_is_alive_permission_error(monkeypatch: pytest.MonkeyPatch) -> None: def permission_error(pid: int, sig: int) -> None: raise PermissionError - monkeypatch.setattr(cli.os, "kill", permission_error) - assert cli._pid_is_alive(123) is True + monkeypatch.setattr(session_state.os, "kill", permission_error) + assert session_state.pid_is_alive(123) is True def test_unregister_session_keeps_other_active_sessions(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(cli.os, "getpid", lambda: 111) - monkeypatch.setattr(cli, "_pid_is_alive", lambda pid: pid == 222) - cli._save_sessions( + monkeypatch.setattr(session_state.os, "getpid", lambda: 111) + monkeypatch.setattr(session_state, "pid_is_alive", lambda pid: pid == 222) + session_state.save_sessions( {"vm1": {"sessions": [{"pid": 111, "kind": "run"}, {"pid": 222, "kind": "shell"}]}} ) - cli._unregister_session("vm1") + session_state.unregister_session("vm1") - assert cli._load_sessions() == {"vm1": {"sessions": [{"pid": 222, "kind": "shell"}]}} + assert session_state.load_sessions() == {"vm1": {"sessions": [{"pid": 222, "kind": "shell"}]}} def test_network_auth_port_wrapper(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_completion.py b/tests/test_completion.py index 71b4675..f5cb5b0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -33,7 +33,6 @@ def test_completion_scripts_include_all_command_option_groups() -> None: "close-auth-port", "status", "build-debian", - "force-start", "force", "host-port", "guest-port", @@ -52,7 +51,7 @@ def test_bash_completion_completes_redirection_targets(tmp_path) -> None: source {str(script)!r} COMP_WORDS=(sbx completion bash) COMP_CWORD=3 -COMP_LINE={f'sbx completion bash > {str(target)[:-1]}'!r} +COMP_LINE={f"sbx completion bash > {str(target)[:-1]}"!r} COMP_POINT=${{#COMP_LINE}} _sbx_complete printf '%s\n' "${{COMPREPLY[@]}}"