Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = "Apache-2.0"
authors = [{ name = "sbx contributors" }]
dependencies = [
"smolvm==0.0.26",
"smolvm==0.0.28",
]

[project.optional-dependencies]
Expand Down
43 changes: 42 additions & 1 deletion src/sbx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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

Expand All @@ -29,6 +30,7 @@
)
from sbx.image import build_debian
from sbx.runtime import ConfigError as RuntimeConfigError
from sbx.runtime import smolvm_env

AGENTS = ("pi", "claude", "codex")
MIB = 1024 * 1024
Expand Down Expand Up @@ -118,12 +120,14 @@ def _smolvm_argv(args: Sequence[str]) -> list[str]:


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)


Expand Down Expand Up @@ -799,6 +803,42 @@ def _install_git_config(vm_id: str, user: str | None, git_config_text: str | Non
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}"
Expand Down Expand Up @@ -989,6 +1029,7 @@ def _post_start_actions(
cwd: str | None = None,
git_config_text: str | None = None,
) -> int:
_sync_guest_clock(vm_name)
if auth_port:
port_rc = network.expose_auth_port(vm_name, auth_host_port, auth_guest_port)
if port_rc != 0:
Expand Down Expand Up @@ -1978,7 +2019,7 @@ def _add_start_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--boot-timeout",
type=float,
help="Seconds to wait for VM boot/SSH readiness (default: [sbx].boot_timeout or 30).",
help="Seconds to wait for VM boot/SSH readiness (default: [sbx].boot_timeout or 60).",
)
parser.add_argument("--install-timeout", type=float)
write_config = parser.add_mutually_exclusive_group()
Expand Down
2 changes: 1 addition & 1 deletion src/sbx/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pathlib import Path

DEFAULT_BACKEND = "qemu"
DEFAULT_BOOT_TIMEOUT = 30.0
DEFAULT_BOOT_TIMEOUT = 60.0
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"
Expand Down
15 changes: 15 additions & 0 deletions src/sbx/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from typing import Any

DEBUG = False
SMOLVM_DISABLE_VERSION_CHECK_ENV = "SMOLVM_DISABLE_VERSION_CHECK"
SBX_SMOLVM_VERSION_NOTICES_ENV = "SBX_SMOLVM_VERSION_NOTICES"


class ConfigError(ValueError):
Expand Down Expand Up @@ -92,13 +94,26 @@ def smolvm_argv(args: Sequence[str]) -> list[str]:
]


def env_boolean(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}


def smolvm_env(env: Mapping[str, str] | None = None) -> dict[str, str]:
result = dict(os.environ if env is None else env)
if not env_boolean(result.get(SBX_SMOLVM_VERSION_NOTICES_ENV)):
result[SMOLVM_DISABLE_VERSION_CHECK_ENV] = "1"
return result


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)


Expand Down
73 changes: 58 additions & 15 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,43 @@ def test_smolvm_runner_does_not_need_console_script_on_path() -> None:
]


def test_smolvm_runner_suppresses_upstream_version_notice(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}

def fake_run(argv: list[str], **kwargs: object) -> int:
captured.update(kwargs)
return 0

monkeypatch.delenv("SBX_SMOLVM_VERSION_NOTICES", raising=False)
monkeypatch.setattr(cli, "_run", fake_run)

assert cli._run_smolvm(["doctor"]) == 0

env = captured["env"]
assert isinstance(env, dict)
assert env["SMOLVM_DISABLE_VERSION_CHECK"] == "1"


def test_smolvm_runner_allows_upstream_version_notice_when_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}

def fake_run(argv: list[str], **kwargs: object) -> int:
captured.update(kwargs)
return 0

monkeypatch.setenv("SBX_SMOLVM_VERSION_NOTICES", "true")
monkeypatch.delenv("SMOLVM_DISABLE_VERSION_CHECK", raising=False)
monkeypatch.setattr(cli, "_run", fake_run)

assert cli._run_smolvm(["doctor"]) == 0

env = captured["env"]
assert isinstance(env, dict)
assert "SMOLVM_DISABLE_VERSION_CHECK" not in env


def test_doctor_checks_qemu_by_default(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
Expand Down Expand Up @@ -393,7 +430,7 @@ def test_run_uses_local_toml_defaults(

assert rc == 0
assert capfd.readouterr().out == (
"codex start --name demo --backend qemu --boot-timeout 30 --mount .:/workspace --attach\n"
"codex start --name demo --backend qemu --boot-timeout 60 --mount .:/workspace --attach\n"
)


Expand Down Expand Up @@ -558,7 +595,7 @@ def fake_attach(vm_id: str, user: str, launch_command: str, cwd: str | None = No
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
"--json",
]
Expand Down Expand Up @@ -707,10 +744,14 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
]
assert captured["env"] == {"HOME": str(captured["temp_home"]), "SBX_TEST": "credential-free"}
assert captured["env"] == {
"HOME": str(captured["temp_home"]),
"SBX_TEST": "credential-free",
"SMOLVM_DISABLE_VERSION_CHECK": "1",
}


def test_env_vars_are_not_forwarded_by_default_with_host_credentials(
Expand Down Expand Up @@ -791,7 +832,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
]
assert captured["env"] is not None
Expand Down Expand Up @@ -896,6 +937,7 @@ 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)

assert cli.main(["run", "vm1", "--no-attach", "--no-auth-port"]) == 0
assert not (tmp_path / ".sbx.toml").exists()
Expand Down Expand Up @@ -981,7 +1023,7 @@ def fake_attach(vm_id: str, launch_command: str, cwd: str | None = None) -> int:
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--mount",
f"{project}:{project}",
"--writable-mounts",
Expand Down Expand Up @@ -1034,7 +1076,7 @@ def fake_attach(vm_id: str, launch_command: str, cwd: str | None = None) -> int:
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
"--json",
]
Expand Down Expand Up @@ -1069,6 +1111,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None

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.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)

Expand All @@ -1077,7 +1120,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
assert rc == 0
assert calls == [
["smolvm", "sandbox", "info", "vm1", "--json"],
["smolvm", "sandbox", "start", "vm1", "--boot-timeout", "30"],
["smolvm", "sandbox", "start", "vm1", "--boot-timeout", "60"],
["smolvm", "sandbox", "stop", "vm1"],
]

Expand Down Expand Up @@ -1185,7 +1228,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
]

Expand Down Expand Up @@ -1229,7 +1272,7 @@ def fake_run_capture(
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
"--json",
]
Expand Down Expand Up @@ -1274,7 +1317,7 @@ def fake_run_capture(
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
"--json",
]
Expand Down Expand Up @@ -1305,7 +1348,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
]

Expand Down Expand Up @@ -1592,7 +1635,7 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None
"--backend",
"qemu",
"--boot-timeout",
"30",
"60",
"--no-attach",
],
]
Expand Down Expand Up @@ -1631,7 +1674,7 @@ def test_cli_project_path_overrides_config(

assert rc == 0
assert capfd.readouterr().out == (
"pi start --backend qemu --boot-timeout 30 "
"pi start --backend qemu --boot-timeout 60 "
f"--mount {cli_project}:{cli_project} "
"--writable-mounts --no-attach\n"
)
Expand Down Expand Up @@ -1668,7 +1711,7 @@ def test_cli_flags_override_config(

assert rc == 0
assert capfd.readouterr().out == (
"claude start --name from-cli --backend qemu --boot-timeout 30 --attach\n"
"claude start --name from-cli --backend qemu --boot-timeout 60 --attach\n"
)


Expand Down
Loading