Skip to content

Commit ace32f6

Browse files
committed
Apply fixes for permissions and proxy
1 parent 019cb23 commit ace32f6

13 files changed

Lines changed: 465 additions & 28 deletions

File tree

docs/configuration.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ default_agent: claude
2222
# See docs/podman.md for setup instructions
2323
container_runtime: auto
2424

25+
# Container user namespace mode passed to new containers (default: null)
26+
# For Podman, "keep-id" preserves host UID/GID on compatible images
27+
container_userns_mode: null
28+
2529
# Pull the latest image before every run (default: true)
2630
# Can be overridden per agent with agents.<agent>.auto_pull
2731
auto_pull: true
@@ -110,6 +114,7 @@ These variables override the corresponding config keys without editing any file:
110114
| Variable | Config key | Example |
111115
|---|---|---|
112116
| `VP_CONTAINER_RUNTIME` | `container_runtime` | `VP_CONTAINER_RUNTIME=podman` |
117+
| `VP_CONTAINER_USERNS_MODE` | `container_userns_mode` | `VP_CONTAINER_USERNS_MODE=keep-id` |
113118
| `VP_DEFAULT_AGENT` | `default_agent` | `VP_DEFAULT_AGENT=gemini` |
114119
| `VP_AUTO_PULL` | `auto_pull` | `VP_AUTO_PULL=true` |
115120
| `VP_LOG_LEVEL` | `log_level` | `VP_LOG_LEVEL=debug` |
@@ -189,7 +194,7 @@ Commit this file to share project defaults with your team.
189194

190195
VibePod starts a `vibepod-proxy` container alongside every agent. It acts as an HTTP(S) MITM proxy and logs all outbound requests to a SQLite database viewable in the Datasette UI (`vp logs start`).
191196

192-
The proxy is reachable inside the Docker network as `http://vibepod-proxy:8080`. It is not published on a host port.
197+
VibePod injects the proxy endpoint into agent containers automatically over the internal runtime network. It is not published on a host port.
193198

194199
To disable the proxy globally:
195200

docs/podman.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ VibePod uses the standard [Docker SDK for Python](https://docker-py.readthedocs.
6666

6767
The rootless Podman socket is discovered via `$XDG_RUNTIME_DIR/podman/podman.sock` (falling back to `/run/user/<uid>/podman/podman.sock`). The rootful socket at `/run/podman/podman.sock` is only used when running as root.
6868

69+
VibePod allows up to 10 seconds for runtime detection probes. If your Podman socket is slower on a particular host, set `VP_RUNTIME_PROBE_TIMEOUT` to a larger value in seconds before running `vp`.
70+
6971
## Known limitations
7072

7173
### Interactive attach
@@ -77,6 +79,26 @@ vp run claude -d
7779
podman attach vibepod-claude-<id>
7880
```
7981

82+
### User namespace mapping
83+
84+
If you want Podman to preserve your host UID/GID for compatible containers, set a user namespace mode such as `keep-id`:
85+
86+
```bash
87+
vp run claude --runtime podman --userns keep-id
88+
```
89+
90+
You can also set it globally:
91+
92+
```bash
93+
export VP_CONTAINER_USERNS_MODE=keep-id
94+
```
95+
96+
```yaml
97+
container_userns_mode: keep-id
98+
```
99+
100+
This works best for images that run as your host UID. Images that switch to a different in-container user may still produce remapped ownership on bind mounts.
101+
80102
### Volume permissions
81103

82104
Rootless Podman uses user-namespace remapping: your host UID is mapped to root inside the container, while other UIDs are mapped to subordinate ranges. Container images that drop privileges via `su` or `gosu` may encounter permission errors on bind-mounted files.

src/vibepod/cli.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ def _alias(bound_agent: str = agent_name) -> None:
3434

3535

3636
@app.command("ui", hidden=True)
37-
def alias_ui() -> None:
37+
def alias_ui(
38+
port: int | None = None,
39+
no_open: bool = False,
40+
runtime: str | None = None,
41+
userns: str | None = None,
42+
) -> None:
3843
"""Alias for `vp logs start`."""
39-
logs.logs_start()
44+
logs.logs_start(port=port, no_open=no_open, runtime=runtime, userns=userns)
4045

4146

4247
for shortcut, agent in AGENT_SHORTCUTS.items():

src/vibepod/commands/logs.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import typer
1010

1111
from vibepod.constants import EXIT_DOCKER_NOT_RUNNING
12-
from vibepod.core.config import get_config
12+
from vibepod.core.config import get_config, get_container_userns_mode
1313
from vibepod.core.docker import DockerClientError, get_manager
1414
from vibepod.utils.console import error, info, success, warning
1515

@@ -24,9 +24,14 @@ def logs_start(
2424
str | None,
2525
typer.Option("--runtime", help="Container runtime to use (docker or podman)"),
2626
] = None,
27+
userns: Annotated[
28+
str | None,
29+
typer.Option("--userns", help="Container user namespace mode (for example keep-id)"),
30+
] = None,
2731
) -> None:
2832
"""Start or reuse Datasette for session and proxy logs."""
2933
config = get_config()
34+
container_userns_mode = get_container_userns_mode(config, override=userns)
3035
log_cfg = config.get("logging", {})
3136
proxy_cfg = config.get("proxy", {})
3237

@@ -49,6 +54,7 @@ def logs_start(
4954
logs_db_path=logs_db_path,
5055
proxy_db_path=proxy_db_path,
5156
port=datasette_port,
57+
userns_mode=container_userns_mode,
5258
)
5359
success("Datasette is ready")
5460

@@ -111,6 +117,10 @@ def logs_ui(
111117
str | None,
112118
typer.Option("--runtime", help="Container runtime to use (docker or podman)"),
113119
] = None,
120+
userns: Annotated[
121+
str | None,
122+
typer.Option("--userns", help="Container user namespace mode (for example keep-id)"),
123+
] = None,
114124
) -> None:
115125
"""Alias for `vp logs start`."""
116-
logs_start(port=port, no_open=no_open, runtime=runtime)
126+
logs_start(port=port, no_open=no_open, runtime=runtime, userns=userns)

src/vibepod/commands/proxy.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import typer
99

1010
from vibepod.constants import EXIT_DOCKER_NOT_RUNNING
11-
from vibepod.core.config import get_config
11+
from vibepod.core.config import get_config, get_container_userns_mode
1212
from vibepod.core.docker import DockerClientError, get_manager
1313
from vibepod.utils.console import error, info, success, warning
1414

@@ -21,9 +21,14 @@ def proxy_start(
2121
str | None,
2222
typer.Option("--runtime", help="Container runtime to use (docker or podman)"),
2323
] = None,
24+
userns: Annotated[
25+
str | None,
26+
typer.Option("--userns", help="Container user namespace mode (for example keep-id)"),
27+
] = None,
2428
) -> None:
2529
"""Start the proxy container."""
2630
config = get_config()
31+
container_userns_mode = get_container_userns_mode(config, override=userns)
2732
proxy_cfg = config.get("proxy", {})
2833

2934
proxy_image = str(proxy_cfg.get("image", "vibepod/proxy:latest"))
@@ -53,6 +58,7 @@ def proxy_start(
5358
db_path=db_path,
5459
ca_dir=ca_dir,
5560
network=network_name,
61+
userns_mode=container_userns_mode,
5662
)
5763
success("Proxy is running")
5864

src/vibepod/commands/run.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
get_agent_spec,
2323
resolve_agent_name,
2424
)
25-
from vibepod.core.config import get_config
25+
from vibepod.core.config import get_config, get_container_userns_mode
2626
from vibepod.core.docker import DockerClientError, DockerManager, get_manager
2727
from vibepod.core.session_logger import SessionLogger
2828
from vibepod.utils.console import error, info, success, warning
@@ -218,9 +218,14 @@ def run(
218218
str | None,
219219
typer.Option("--runtime", help="Container runtime to use (docker or podman)"),
220220
] = None,
221+
userns: Annotated[
222+
str | None,
223+
typer.Option("--userns", help="Container user namespace mode (for example keep-id)"),
224+
] = None,
221225
) -> None:
222226
"""Start an agent container."""
223227
config = get_config()
228+
container_userns_mode = get_container_userns_mode(config, override=userns)
224229
selected_agent_input = agent or str(config.get("default_agent", "claude"))
225230
selected_agent = resolve_agent_name(selected_agent_input)
226231
if selected_agent is None:
@@ -320,11 +325,12 @@ def run(
320325
.resolve()
321326
)
322327

323-
manager.ensure_proxy(
328+
proxy_container = manager.ensure_proxy(
324329
image=proxy_image,
325330
db_path=proxy_db_path,
326331
ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy",
327332
network=network_name,
333+
userns_mode=container_userns_mode,
328334
)
329335

330336
if proxy_ca_path:
@@ -338,7 +344,8 @@ def run(
338344
if not ca_ready:
339345
warning(f"Proxy CA not found yet at {proxy_ca_path}")
340346

341-
proxy_url = "http://vibepod-proxy:8080"
347+
proxy_host = _get_container_ip(proxy_container, network_name) or "vibepod-proxy"
348+
proxy_url = f"http://{proxy_host}:8080"
342349
merged_env.setdefault("HTTP_PROXY", proxy_url)
343350
merged_env.setdefault("HTTPS_PROXY", proxy_url)
344351
merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1")
@@ -368,6 +375,7 @@ def run(
368375
extra_volumes=extra_volumes,
369376
platform=spec.platform,
370377
user=container_user,
378+
userns_mode=container_userns_mode,
371379
entrypoint=entrypoint,
372380
)
373381

src/vibepod/core/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def _default_config() -> dict[str, Any]:
3232
"default_agent": "claude",
3333
"auto_pull": True,
3434
"container_runtime": RUNTIME_AUTO,
35+
"container_userns_mode": None,
3536
"auto_remove": True,
3637
"network": "vibepod-network",
3738
"log_level": "info",
@@ -144,6 +145,7 @@ def _apply_env(config: dict[str, Any]) -> dict[str, Any]:
144145
mappings: dict[str, tuple[str, Any]] = {
145146
"VP_DEFAULT_AGENT": ("default_agent", str),
146147
"VP_CONTAINER_RUNTIME": ("container_runtime", str),
148+
"VP_CONTAINER_USERNS_MODE": ("container_userns_mode", lambda x: x.strip() or None),
147149
"VP_AUTO_PULL": ("auto_pull", lambda x: x.lower() == "true"),
148150
"VP_LOG_LEVEL": ("log_level", str),
149151
"VP_NO_COLOR": ("no_color", lambda x: x.lower() == "true"),
@@ -189,6 +191,18 @@ def get_config() -> dict[str, Any]:
189191
return config
190192

191193

194+
def get_container_userns_mode(
195+
config: dict[str, Any],
196+
override: str | None = None,
197+
) -> str | None:
198+
"""Return the configured container user namespace mode, if any."""
199+
raw: Any = override if override is not None else config.get("container_userns_mode")
200+
if raw is None:
201+
return None
202+
value = str(raw).strip()
203+
return value or None
204+
205+
192206
def get_config_value(key: str, default: Any = None) -> Any:
193207
"""Read a config value by dot notation."""
194208
value: Any = get_config()

src/vibepod/core/docker.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ def _prepare_volume_dir(self, path: Path) -> None:
9797
a non-root process started via ``su`` / ``gosu`` in the entrypoint
9898
gets a subordinate UID that cannot read host files with standard
9999
permissions. Making VibePod-managed directories world-accessible
100-
(``0o777``) avoids "Permission denied" errors without needing
101-
``userns_mode=keep-id`` (which breaks entrypoints that call ``su``).
100+
(``0o777``) avoids "Permission denied" errors in the default Podman
101+
setup. Users can opt into ``userns_mode=keep-id`` for compatible
102+
images, but entrypoints that switch users may still need this fallback.
102103
103104
This is only applied to directories VibePod itself creates — never
104105
to the user's workspace.
@@ -194,6 +195,7 @@ def run_agent(
194195
extra_volumes: list[tuple[str, str, str]] | None = None,
195196
platform: str | None = None,
196197
user: str | None = None,
198+
userns_mode: str | None = None,
197199
entrypoint: list[str] | None = None,
198200
) -> Any:
199201
container_name = name or f"vibepod-{agent}-{uuid4().hex[:8]}"
@@ -241,6 +243,8 @@ def run_agent(
241243
run_kwargs["entrypoint"] = entrypoint
242244
if user:
243245
run_kwargs["user"] = user
246+
if userns_mode:
247+
run_kwargs["userns_mode"] = userns_mode
244248

245249
return self._run_container(**run_kwargs)
246250
except APIError as exc:
@@ -273,7 +277,12 @@ def find_datasette(self) -> Any | None:
273277
return containers[0] if containers else None
274278

275279
def ensure_datasette(
276-
self, image: str, logs_db_path: Path, proxy_db_path: Path, port: int
280+
self,
281+
image: str,
282+
logs_db_path: Path,
283+
proxy_db_path: Path,
284+
port: int,
285+
userns_mode: str | None = None,
277286
) -> Any:
278287
existing = self.find_datasette()
279288
if existing:
@@ -319,6 +328,8 @@ def ensure_datasette(
319328
"volumes": volumes,
320329
"ports": {"8001/tcp": port},
321330
}
331+
if userns_mode:
332+
run_kwargs["userns_mode"] = userns_mode
322333

323334
return self._run_container(**run_kwargs)
324335

@@ -328,10 +339,22 @@ def find_proxy(self) -> Any | None:
328339
)
329340
return containers[0] if containers else None
330341

331-
def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) -> Any:
342+
def ensure_proxy(
343+
self,
344+
image: str,
345+
db_path: Path,
346+
ca_dir: Path,
347+
network: str,
348+
userns_mode: str | None = None,
349+
) -> Any:
332350
existing = self.find_proxy()
333351
if existing:
352+
existing.reload()
334353
if existing.status == "running":
354+
attached = existing.attrs.get("NetworkSettings", {}).get("Networks", {}) or {}
355+
if network not in attached:
356+
self.connect_network(existing, network)
357+
existing.reload()
335358
return existing
336359
existing.remove(force=True)
337360

@@ -364,8 +387,15 @@ def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) ->
364387
getgid = getattr(os, "getgid", None)
365388
if callable(getuid) and callable(getgid):
366389
run_kwargs["user"] = f"{getuid()}:{getgid()}"
390+
if userns_mode:
391+
run_kwargs["userns_mode"] = userns_mode
367392

368-
return self._run_container(**run_kwargs)
393+
container = self._run_container(**run_kwargs)
394+
try:
395+
container.reload()
396+
except Exception:
397+
pass
398+
return container
369399

370400
def attach_interactive(self, container: Any, logger: Any = None) -> None:
371401
"""Attach local stdin/stdout to a running container TTY."""

0 commit comments

Comments
 (0)