Skip to content

Commit 0dd48f1

Browse files
committed
Auto-fetch container image for agent on start
1 parent 531760b commit 0dd48f1

6 files changed

Lines changed: 213 additions & 25 deletions

File tree

docs/agents/index.md

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@ VibePod manages each agent as a Docker container. Credentials and config are per
1818

1919
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.
2020

21+
## Auto-pulling the latest image
22+
23+
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.
24+
25+
To disable auto-pull globally:
26+
27+
```yaml
28+
auto_pull: false
29+
```
30+
31+
Or for a specific agent only:
32+
33+
```yaml
34+
agents:
35+
devstral:
36+
auto_pull: false
37+
```
38+
39+
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:
40+
41+
```yaml
42+
auto_pull: false # skip pull by default
43+
agents:
44+
claude:
45+
auto_pull: true # except claude — always pull
46+
```
47+
48+
You can also force a one-off pull via the CLI flag regardless of config:
49+
50+
```bash
51+
vp run claude --pull
52+
```
53+
54+
The resolution order is: `--pull` flag > per-agent `auto_pull` > global `auto_pull`.
55+
2156
## Overriding the image
2257

2358
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.
132167

133168
## Detached mode
134169

135-
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.
170+
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.
136171

137172
### Basic usage
138173

@@ -147,31 +182,18 @@ The command prints the container name and returns immediately. You can also find
147182
vp list --running
148183
```
149184

150-
### Customizing the container before starting the agent
185+
### Interacting with a detached container
151186

152-
A common workflow is to start detached, exec into the container to make adjustments, and then start the agent manually:
187+
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:
153188

154-
1. **Start the container in detached mode.**
155-
156-
```bash
157-
vp run claude -d
158-
# ✓ Started vibepod-claude-a1b2c3d4
159-
```
160-
161-
2. **Exec into the running container.**
162-
163-
Use the container name printed above (or grab it from `vp list`):
164-
165-
```bash
166-
docker exec -it vibepod-claude-a1b2c3d4 bash
167-
```
168-
169-
3. **Apply your customizations** — install packages, edit config files, set environment variables, etc.
189+
```bash
190+
docker exec -it vibepod-claude-a1b2c3d4 bash
191+
```
170192

171-
4. **Start the agent process** from inside the container when you are ready.
193+
Use the container name printed by `vp run -d` or shown in `vp list`.
172194

173195
!!! tip
174-
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.
196+
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.
175197

176198
### Managing detached containers
177199

docs/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ version: 1
1818
# Agent to run when no argument is given to `vp run`
1919
default_agent: claude
2020

21-
# Pull the latest image before every run (default: false)
22-
auto_pull: false
21+
# Pull the latest image before every run (default: true)
22+
# Can be overridden per agent with agents.<agent>.auto_pull
23+
auto_pull: true
2324

2425
# Remove the container automatically when it stops (default: true)
2526
auto_remove: true
@@ -37,6 +38,7 @@ agents:
3738
claude:
3839
enabled: true
3940
image: nezhar/claude-container:latest
41+
auto_pull: null # Per-agent override: true/false, or null to use global auto_pull
4042
env: {} # Extra environment variables passed to the container
4143
volumes: [] # Reserved for future use
4244
init: [] # Optional shell commands run before agent startup

src/vibepod/commands/run.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,11 @@ def run(
256256
manager.ensure_network(network_name)
257257
extra_network = network or _maybe_select_network(workspace_path, manager, network_name)
258258

259-
if pull or bool(config.get("auto_pull", False)):
259+
agent_auto_pull = agent_cfg.get("auto_pull")
260+
should_pull = pull or (
261+
agent_auto_pull if agent_auto_pull is not None else bool(config.get("auto_pull", False))
262+
)
263+
if should_pull:
260264
info(f"Pulling image: {image}")
261265
manager.pull_image(image)
262266

src/vibepod/core/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def _default_config() -> dict[str, Any]:
2929
return {
3030
"version": 1,
3131
"default_agent": "claude",
32-
"auto_pull": False,
32+
"auto_pull": True,
3333
"auto_remove": True,
3434
"network": "vibepod-network",
3535
"log_level": "info",
@@ -38,48 +38,55 @@ def _default_config() -> dict[str, Any]:
3838
"claude": {
3939
"enabled": True,
4040
"image": DEFAULT_IMAGES["claude"],
41+
"auto_pull": None,
4142
"env": {},
4243
"volumes": [],
4344
"init": [],
4445
},
4546
"gemini": {
4647
"enabled": True,
4748
"image": DEFAULT_IMAGES["gemini"],
49+
"auto_pull": None,
4850
"env": {},
4951
"volumes": [],
5052
"init": [],
5153
},
5254
"opencode": {
5355
"enabled": True,
5456
"image": DEFAULT_IMAGES["opencode"],
57+
"auto_pull": None,
5558
"env": {},
5659
"volumes": [],
5760
"init": [],
5861
},
5962
"devstral": {
6063
"enabled": True,
6164
"image": DEFAULT_IMAGES["devstral"],
65+
"auto_pull": None,
6266
"env": {},
6367
"volumes": [],
6468
"init": [],
6569
},
6670
"auggie": {
6771
"enabled": True,
6872
"image": DEFAULT_IMAGES["auggie"],
73+
"auto_pull": None,
6974
"env": {},
7075
"volumes": [],
7176
"init": [],
7277
},
7378
"copilot": {
7479
"enabled": True,
7580
"image": DEFAULT_IMAGES["copilot"],
81+
"auto_pull": None,
7682
"env": {},
7783
"volumes": [],
7884
"init": [],
7985
},
8086
"codex": {
8187
"enabled": True,
8288
"image": DEFAULT_IMAGES["codex"],
89+
"auto_pull": None,
8390
"env": {},
8491
"volumes": [],
8592
"init": [],

tests/test_config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,27 @@ def test_default_config_exposes_agent_init(monkeypatch, tmp_path: Path) -> None:
114114

115115
for agent in SUPPORTED_AGENTS:
116116
assert agents[agent]["init"] == []
117+
118+
119+
def test_default_config_exposes_agent_auto_pull(monkeypatch, tmp_path: Path) -> None:
120+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path))
121+
config = get_config()
122+
agents = config.get("agents", {})
123+
assert isinstance(agents, dict)
124+
125+
for agent in SUPPORTED_AGENTS:
126+
assert agents[agent]["auto_pull"] is None
127+
128+
129+
def test_per_agent_auto_pull_override(monkeypatch, tmp_path: Path) -> None:
130+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path))
131+
global_config = tmp_path / "config.yaml"
132+
global_config.write_text(
133+
"auto_pull: false\nagents:\n claude:\n auto_pull: true\n",
134+
encoding="utf-8",
135+
)
136+
config = get_config()
137+
assert config["auto_pull"] is False
138+
assert config["agents"]["claude"]["auto_pull"] is True
139+
# Other agents should still have None (unset)
140+
assert config["agents"]["gemini"]["auto_pull"] is None

tests/test_run.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,135 @@ def __init__(self) -> None:
262262
manager.resolve_launch_command("example/image:latest", None)
263263

264264

265+
class _StubDockerManager:
266+
"""Minimal DockerManager stub that records pull_image calls."""
267+
268+
def __init__(self) -> None:
269+
self.pulled: list[str] = []
270+
self._container = type(
271+
"_Container",
272+
(),
273+
{
274+
"name": "vibepod-claude-test",
275+
"id": "abc123",
276+
"status": "running",
277+
"attrs": {"NetworkSettings": {"Networks": {}}},
278+
"reload": lambda self: None,
279+
"labels": {},
280+
"logs": lambda self, **kw: b"",
281+
},
282+
)()
283+
284+
def ensure_network(self, name: str) -> None:
285+
pass
286+
287+
def pull_image(self, image: str) -> None:
288+
self.pulled.append(image)
289+
290+
def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def]
291+
pass
292+
293+
def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def]
294+
return self._container
295+
296+
def networks_with_running_containers(self) -> list[str]:
297+
return []
298+
299+
300+
def _make_config(
301+
global_auto_pull: bool = False,
302+
agent_auto_pull: bool | None = None,
303+
) -> dict:
304+
agent_cfg: dict = {"env": {}, "init": []}
305+
if agent_auto_pull is not None:
306+
agent_cfg["auto_pull"] = agent_auto_pull
307+
return {
308+
"default_agent": "claude",
309+
"auto_pull": global_auto_pull,
310+
"auto_remove": True,
311+
"network": "vibepod-network",
312+
"agents": {"claude": agent_cfg},
313+
"proxy": {"enabled": False},
314+
"logging": {"enabled": False},
315+
}
316+
317+
318+
def test_auto_pull_global_triggers_pull(monkeypatch, tmp_path: Path) -> None:
319+
"""Global auto_pull=true causes image pull on run."""
320+
stub = _StubDockerManager()
321+
monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config(global_auto_pull=True))
322+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
323+
324+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True)
325+
assert len(stub.pulled) == 1
326+
327+
328+
def test_auto_pull_global_false_skips_pull(monkeypatch, tmp_path: Path) -> None:
329+
"""Global auto_pull=false skips image pull."""
330+
stub = _StubDockerManager()
331+
monkeypatch.setattr(run_cmd, "get_config", lambda: _make_config(global_auto_pull=False))
332+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
333+
334+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True)
335+
assert stub.pulled == []
336+
337+
338+
def test_auto_pull_per_agent_true_overrides_global_false(monkeypatch, tmp_path: Path) -> None:
339+
"""Per-agent auto_pull=true overrides global auto_pull=false."""
340+
stub = _StubDockerManager()
341+
monkeypatch.setattr(
342+
run_cmd,
343+
"get_config",
344+
lambda: _make_config(global_auto_pull=False, agent_auto_pull=True),
345+
)
346+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
347+
348+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True)
349+
assert len(stub.pulled) == 1
350+
351+
352+
def test_auto_pull_per_agent_false_overrides_global_true(monkeypatch, tmp_path: Path) -> None:
353+
"""Per-agent auto_pull=false overrides global auto_pull=true."""
354+
stub = _StubDockerManager()
355+
monkeypatch.setattr(
356+
run_cmd,
357+
"get_config",
358+
lambda: _make_config(global_auto_pull=True, agent_auto_pull=False),
359+
)
360+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
361+
362+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True)
363+
assert stub.pulled == []
364+
365+
366+
def test_auto_pull_cli_flag_overrides_config(monkeypatch, tmp_path: Path) -> None:
367+
"""--pull flag forces pull even when config disables it."""
368+
stub = _StubDockerManager()
369+
monkeypatch.setattr(
370+
run_cmd,
371+
"get_config",
372+
lambda: _make_config(global_auto_pull=False, agent_auto_pull=False),
373+
)
374+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
375+
376+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True, pull=True)
377+
assert len(stub.pulled) == 1
378+
379+
380+
def test_auto_pull_per_agent_none_falls_back_to_global(monkeypatch, tmp_path: Path) -> None:
381+
"""Per-agent auto_pull=None (unset) falls back to global setting."""
382+
stub = _StubDockerManager()
383+
monkeypatch.setattr(
384+
run_cmd,
385+
"get_config",
386+
lambda: _make_config(global_auto_pull=True, agent_auto_pull=None),
387+
)
388+
monkeypatch.setattr(run_cmd, "DockerManager", lambda: stub)
389+
390+
run_cmd.run(agent="claude", workspace=tmp_path, detach=True)
391+
assert len(stub.pulled) == 1
392+
393+
265394
def test_run_accepts_short_agent_name(monkeypatch, tmp_path: Path) -> None:
266395
class _UnavailableDockerManager:
267396
def __init__(self) -> None:

0 commit comments

Comments
 (0)