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
64 changes: 43 additions & 21 deletions docs/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>`. 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:
Expand Down Expand Up @@ -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

Expand All @@ -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.<agent>.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.<agent>.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

Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<agent>.auto_pull
auto_pull: true

# Remove the container automatically when it stops (default: true)
auto_remove: true
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/vibepod/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion src/vibepod/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -38,48 +38,55 @@ def _default_config() -> dict[str, Any]:
"claude": {
"enabled": True,
"image": DEFAULT_IMAGES["claude"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"gemini": {
"enabled": True,
"image": DEFAULT_IMAGES["gemini"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"opencode": {
"enabled": True,
"image": DEFAULT_IMAGES["opencode"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"devstral": {
"enabled": True,
"image": DEFAULT_IMAGES["devstral"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"auggie": {
"enabled": True,
"image": DEFAULT_IMAGES["auggie"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"copilot": {
"enabled": True,
"image": DEFAULT_IMAGES["copilot"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
},
"codex": {
"enabled": True,
"image": DEFAULT_IMAGES["codex"],
"auto_pull": None,
"env": {},
"volumes": [],
"init": [],
Expand Down
24 changes: 24 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
129 changes: 129 additions & 0 deletions tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down