diff --git a/docs/agents/index.md b/docs/agents/index.md index c43627a..88b9485 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -18,6 +18,41 @@ VibePod manages each agent as a Docker container. Credentials and config are per Start any agent for the first time with `vp run `. The container will prompt you to authenticate (browser OAuth, API key entry, or device flow depending on the provider). Once authenticated, credentials are written to the persisted config directory and reused on subsequent runs. +## Auto-pulling the latest image + +VibePod automatically pulls the latest image for an agent before every run. This ensures you always start with the most up-to-date container without manual intervention. + +To disable auto-pull globally: + +```yaml +auto_pull: false +``` + +Or for a specific agent only: + +```yaml +agents: + devstral: + auto_pull: false +``` + +Per-agent `auto_pull` takes precedence over the global setting. For example, you can disable it globally but keep it on for a specific agent: + +```yaml +auto_pull: false # skip pull by default +agents: + claude: + auto_pull: true # except claude — always pull +``` + +You can also force a one-off pull via the CLI flag regardless of config: + +```bash +vp run claude --pull +``` + +The resolution order is: `--pull` flag > per-agent `auto_pull` > global `auto_pull`. + ## Overriding the image You can point VibePod at a custom image via an environment variable: @@ -132,7 +167,7 @@ The `init` commands run on every `vp run` for that agent and must be idempotent. ## Detached mode -Use `-d` / `--detach` to start an agent container in the background without attaching your terminal. This is useful when you want to customise the container environment before launching the agent interactively. +Use `-d` / `--detach` to start an agent container in the background without attaching your terminal. The agent process starts immediately inside the container — `-d` only controls whether VibePod attaches your terminal to it. ### Basic usage @@ -147,31 +182,18 @@ The command prints the container name and returns immediately. You can also find vp list --running ``` -### Customizing the container before starting the agent +### Interacting with a detached container -A common workflow is to start detached, exec into the container to make adjustments, and then start the agent manually: +The agent is already running inside the container. You can exec into it to inspect state, install extra tools, or interact with the agent alongside its running process: -1. **Start the container in detached mode.** - - ```bash - vp run claude -d - # ✓ Started vibepod-claude-a1b2c3d4 - ``` - -2. **Exec into the running container.** - - Use the container name printed above (or grab it from `vp list`): - - ```bash - docker exec -it vibepod-claude-a1b2c3d4 bash - ``` - -3. **Apply your customizations** — install packages, edit config files, set environment variables, etc. +```bash +docker exec -it vibepod-claude-a1b2c3d4 bash +``` -4. **Start the agent process** from inside the container when you are ready. +Use the container name printed by `vp run -d` or shown in `vp list`. !!! tip - If you find yourself running the same setup steps every time, consider using [`agents..init`](#init-scripts-before-startup) commands or [extending the base image](#image-customization-workflows) instead. + If you need to run setup commands **before** the agent launches, use [`agents..init`](#init-scripts-before-startup) or [extend the base image](#image-customization-workflows) instead — these run inside the container before the agent process starts. ### Managing detached containers diff --git a/docs/configuration.md b/docs/configuration.md index 55c1b47..6711251 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,8 +18,9 @@ version: 1 # Agent to run when no argument is given to `vp run` default_agent: claude -# Pull the latest image before every run (default: false) -auto_pull: false +# Pull the latest image before every run (default: true) +# Can be overridden per agent with agents..auto_pull +auto_pull: true # Remove the container automatically when it stops (default: true) auto_remove: true @@ -37,6 +38,7 @@ agents: claude: enabled: true image: nezhar/claude-container:latest + auto_pull: null # Per-agent override: true/false, or null to use global auto_pull env: {} # Extra environment variables passed to the container volumes: [] # Reserved for future use init: [] # Optional shell commands run before agent startup diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 892f66a..32d7aa3 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -256,7 +256,11 @@ def run( manager.ensure_network(network_name) extra_network = network or _maybe_select_network(workspace_path, manager, network_name) - if pull or bool(config.get("auto_pull", False)): + agent_auto_pull = agent_cfg.get("auto_pull") + should_pull = pull or ( + agent_auto_pull if agent_auto_pull is not None else bool(config.get("auto_pull", False)) + ) + if should_pull: info(f"Pulling image: {image}") manager.pull_image(image) diff --git a/src/vibepod/core/config.py b/src/vibepod/core/config.py index 37c4ca8..1f13d3d 100644 --- a/src/vibepod/core/config.py +++ b/src/vibepod/core/config.py @@ -29,7 +29,7 @@ def _default_config() -> dict[str, Any]: return { "version": 1, "default_agent": "claude", - "auto_pull": False, + "auto_pull": True, "auto_remove": True, "network": "vibepod-network", "log_level": "info", @@ -38,6 +38,7 @@ def _default_config() -> dict[str, Any]: "claude": { "enabled": True, "image": DEFAULT_IMAGES["claude"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -45,6 +46,7 @@ def _default_config() -> dict[str, Any]: "gemini": { "enabled": True, "image": DEFAULT_IMAGES["gemini"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -52,6 +54,7 @@ def _default_config() -> dict[str, Any]: "opencode": { "enabled": True, "image": DEFAULT_IMAGES["opencode"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -59,6 +62,7 @@ def _default_config() -> dict[str, Any]: "devstral": { "enabled": True, "image": DEFAULT_IMAGES["devstral"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -66,6 +70,7 @@ def _default_config() -> dict[str, Any]: "auggie": { "enabled": True, "image": DEFAULT_IMAGES["auggie"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -73,6 +78,7 @@ def _default_config() -> dict[str, Any]: "copilot": { "enabled": True, "image": DEFAULT_IMAGES["copilot"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], @@ -80,6 +86,7 @@ def _default_config() -> dict[str, Any]: "codex": { "enabled": True, "image": DEFAULT_IMAGES["codex"], + "auto_pull": None, "env": {}, "volumes": [], "init": [], diff --git a/tests/test_config.py b/tests/test_config.py index 34e2a4a..b1d2c05 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -114,3 +114,27 @@ def test_default_config_exposes_agent_init(monkeypatch, tmp_path: Path) -> None: for agent in SUPPORTED_AGENTS: assert agents[agent]["init"] == [] + + +def test_default_config_exposes_agent_auto_pull(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + config = get_config() + agents = config.get("agents", {}) + assert isinstance(agents, dict) + + for agent in SUPPORTED_AGENTS: + assert agents[agent]["auto_pull"] is None + + +def test_per_agent_auto_pull_override(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + global_config = tmp_path / "config.yaml" + global_config.write_text( + "auto_pull: false\nagents:\n claude:\n auto_pull: true\n", + encoding="utf-8", + ) + config = get_config() + assert config["auto_pull"] is False + assert config["agents"]["claude"]["auto_pull"] is True + # Other agents should still have None (unset) + assert config["agents"]["gemini"]["auto_pull"] is None diff --git a/tests/test_run.py b/tests/test_run.py index b81601f..1468b47 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -262,6 +262,135 @@ def __init__(self) -> None: manager.resolve_launch_command("example/image:latest", None) +class _StubDockerManager: + """Minimal DockerManager stub that records pull_image calls.""" + + def __init__(self) -> None: + self.pulled: list[str] = [] + self._container = type( + "_Container", + (), + { + "name": "vibepod-claude-test", + "id": "abc123", + "status": "running", + "attrs": {"NetworkSettings": {"Networks": {}}}, + "reload": lambda self: None, + "labels": {}, + "logs": lambda self, **kw: b"", + }, + )() + + def ensure_network(self, name: str) -> None: + pass + + def pull_image(self, image: str) -> None: + self.pulled.append(image) + + def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] + pass + + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + return self._container + + def networks_with_running_containers(self) -> list[str]: + return [] + + +def _make_config( + global_auto_pull: bool = False, + agent_auto_pull: bool | None = None, +) -> dict: + agent_cfg: dict = {"env": {}, "init": []} + if agent_auto_pull is not None: + agent_cfg["auto_pull"] = agent_auto_pull + return { + "default_agent": "claude", + "auto_pull": global_auto_pull, + "auto_remove": True, + "network": "vibepod-network", + "agents": {"claude": agent_cfg}, + "proxy": {"enabled": False}, + "logging": {"enabled": False}, + } + + +def test_auto_pull_global_triggers_pull(monkeypatch, tmp_path: Path) -> None: + """Global auto_pull=true causes image pull on run.""" + stub = _StubDockerManager() + monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config(global_auto_pull=True)) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + assert len(stub.pulled) == 1 + + +def test_auto_pull_global_false_skips_pull(monkeypatch, tmp_path: Path) -> None: + """Global auto_pull=false skips image pull.""" + stub = _StubDockerManager() + monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config(global_auto_pull=False)) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + assert stub.pulled == [] + + +def test_auto_pull_per_agent_true_overrides_global_false(monkeypatch, tmp_path: Path) -> None: + """Per-agent auto_pull=true overrides global auto_pull=false.""" + stub = _StubDockerManager() + monkeypatch.setattr( + run_cmd, + "get_config", + lambda: _make_config(global_auto_pull=False, agent_auto_pull=True), + ) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + assert len(stub.pulled) == 1 + + +def test_auto_pull_per_agent_false_overrides_global_true(monkeypatch, tmp_path: Path) -> None: + """Per-agent auto_pull=false overrides global auto_pull=true.""" + stub = _StubDockerManager() + monkeypatch.setattr( + run_cmd, + "get_config", + lambda: _make_config(global_auto_pull=True, agent_auto_pull=False), + ) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + assert stub.pulled == [] + + +def test_auto_pull_cli_flag_overrides_config(monkeypatch, tmp_path: Path) -> None: + """--pull flag forces pull even when config disables it.""" + stub = _StubDockerManager() + monkeypatch.setattr( + run_cmd, + "get_config", + lambda: _make_config(global_auto_pull=False, agent_auto_pull=False), + ) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True, pull=True) + assert len(stub.pulled) == 1 + + +def test_auto_pull_per_agent_none_falls_back_to_global(monkeypatch, tmp_path: Path) -> None: + """Per-agent auto_pull=None (unset) falls back to global setting.""" + stub = _StubDockerManager() + monkeypatch.setattr( + run_cmd, + "get_config", + lambda: _make_config(global_auto_pull=True, agent_auto_pull=None), + ) + monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + assert len(stub.pulled) == 1 + + def test_run_accepts_short_agent_name(monkeypatch, tmp_path: Path) -> None: class _UnavailableDockerManager: def __init__(self) -> None: