diff --git a/docs/configuration.md b/docs/configuration.md index 96bb05e..ee95bdf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -178,6 +178,10 @@ proxy: db_path: ~/.config/vibepod/proxy/proxy.db ca_dir: ~/.config/vibepod/proxy/mitmproxy ca_path: ~/.config/vibepod/proxy/mitmproxy/mitmproxy-ca-cert.pem + filter: + mode: open # open | allow | deny + allow: [] + deny: [] ``` ## Environment variables @@ -193,6 +197,7 @@ These variables override the corresponding config keys without editing any file: | `VP_NO_COLOR` | `no_color` | `VP_NO_COLOR=true` | | `VP_DATASETTE_PORT` | `logging.ui_port` | `VP_DATASETTE_PORT=9001` | | `VP_PROXY_ENABLED` | `proxy.enabled` | `VP_PROXY_ENABLED=false` | +| `VP_PROXY_FILTER_MODE` | `proxy.filter.mode` | `VP_PROXY_FILTER_MODE=allow` | | `VP_LLM_ENABLED` | `llm.enabled` | `VP_LLM_ENABLED=true` | | `VP_LLM_BASE_URL` | `llm.base_url` | `VP_LLM_BASE_URL=http://localhost:11434` | | `VP_LLM_API_KEY` | `llm.api_key` | `VP_LLM_API_KEY=ollama` | @@ -332,3 +337,66 @@ VP_PROXY_ENABLED=false vp run claude network. If you use Podman with the CNI backend, install the `dnsname` plugin (e.g. `podman-plugins` on Fedora, `golang-github-containernetworking-plugin-dnsname` on Debian/Ubuntu) and recreate the network. See [Quickstart — Using Podman](quickstart.md#using-podman-instead-of-docker) for full instructions. + +### Allow/deny filtering + +Filtering is opt-in; the default mode `open` passes and logs everything +(today's behavior). In `allow` mode only listed hosts pass; in `deny` mode +everything passes except listed hosts. Patterns: `example.com` matches that +host exactly, `*.example.com` matches subdomains (not the apex). + +```bash +vp proxy filter status +vp proxy filter mode allow +vp proxy filter allow add api.anthropic.com +vp proxy filter allow add "*.github.com" +vp proxy filter deny add example.com +vp proxy filter mode open # back to no filtering; lists are kept +``` + +One shared proxy evaluates a separate policy for every VibePod agent container. +Each launch receives an opaque policy identity and is also bound to its source +container after startup. This prevents a later launch from replacing the rules +of an already-running agent. Unidentified clients continue to use the global +filter. + +Profile changes apply immediately to every running container using that +profile—no proxy restart is needed. Project filter settings and +`VP_PROXY_FILTER_MODE` are captured when each container starts, so changing +either affects new launches only. The effective profile and live mode are shown +by `vp list` in the `PROFILE` and `PROXY MODE` columns and in its JSON rows as +`profile` and `proxy_mode`. + +Blocked requests return `403` (HTTPS tunnels are refused at `CONNECT`) and are +logged with `blocked = 1` in the proxy database. Invalid policy configuration +is rejected instead of silently falling back to `open`; an identified launch +whose policy files are missing or malformed fails closed. + +The `vp proxy filter` commands act on the **active profile**. For the +`default` profile they write the global config; for a named profile they write +`profiles//filter.yaml` (seeded from the global settings on first +write), so switching profiles also switches the filter mode and lists. Pass +`--profile ` to manage another profile's filter without switching. + +A project-level `.vibepod/config.yaml` filter section or +`VP_PROXY_FILTER_MODE` takes precedence over the global values (for a profile +with its own `filter.yaml`, only `VP_PROXY_FILTER_MODE` still overrides the +mode). Project and environment overrides are launch-specific and never replace +the shared global fallback. + +Custom proxy images must implement per-source policies and expose this exact +OCI image label: + +```dockerfile +LABEL io.vibepod.proxy.policy-schema="2" +``` + +VibePod checks the configured image—and an already-running proxy—before +launching a proxied agent. An image with a missing or different schema label is +rejected with an upgrade error. + +!!! warning "Filtering is not a network sandbox" + The filter controls requests that use the injected proxy. A container can + override its proxy environment or attempt a direct connection unless a + separate network-control layer prevents that. VibePod warns when explicit + `HTTP_PROXY` or `HTTPS_PROXY` values bypass its identified proxy URL. diff --git a/docs/profiles.md b/docs/profiles.md index 1906386..e6af573 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -4,11 +4,12 @@ Profiles let you keep multiple credential sets per agent and switch between them at run time — for example a Claude subscription login, a separate API-key setup, and an environment prepared for Ollama. -A profile only switches the **credential directories** that get mounted into the -agent container. Everything else (skills, allowed directories, proxy, logging) -stays shared. Environment variables such as `ANTHROPIC_API_KEY` are still -configured via `agents..env` or `-e` flags — combine them with a profile -via a project config (see below). +A profile switches the **credential directories** that get mounted into the +agent container and, when the profile has its own filter settings, the +**proxy allow/deny filter** (see below). Everything else (skills, allowed +directories, proxy, logging) stays shared. Environment variables such as +`ANTHROPIC_API_KEY` are still configured via `agents..env` or `-e` +flags — combine them with a profile via a project config (see below). ## Layout @@ -16,6 +17,7 @@ via a project config (see below). ~/.config/vibepod/ agents// # the built-in "default" profile profiles//agents// # named profiles + profiles//filter.yaml # optional per-profile proxy filter ``` Your existing credentials in `~/.config/vibepod/agents/` are the `default` @@ -68,3 +70,32 @@ agents: Referencing a profile that does not exist is a hard error — create it first with `vp profile create `. + +## Per-profile proxy filter + +Each named profile can carry its own proxy filter mode and allow/deny lists in +`profiles//filter.yaml`. The `vp proxy filter` commands act on the +active profile, or on an explicit one via `--profile`: + +```bash +vp proxy filter mode allow --profile work +vp proxy filter allow add api.anthropic.com --profile work +vp proxy filter status --profile work +``` + +The file is created on first write, seeded from the global filter settings. +A profile without `filter.yaml` inherits the global `proxy.filter` config and +then any project filter captured for that launch. An explicit profile file is +a complete replacement for the global/project base. In both cases, the +launch-time `VP_PROXY_FILTER_MODE` value wins last. + +The proxy keeps one materialized profile base and a small record for each +container. Editing a profile filter hot-reloads every running container that +uses it, while each container retains the project and environment overrides it +started with. This means two agents using different profiles—or different +projects with the same inherited profile—can safely share one proxy. + +`vp list` shows the selected profile and live effective proxy mode for each +running container. Removing a profile deletes its credentials, but VibePod +retains its materialized filter while any existing container (including a +stopped container) still references it. diff --git a/pyproject.toml b/pyproject.toml index a9f8a7d..811a43c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "rich>=13.0.0", "docker>=7.0.0", "pyyaml>=6.0.0", + "ruamel.yaml>=0.18.0", "platformdirs>=4.0.0", "tomli>=2.0.1; python_version < '3.11'", ] diff --git a/src/vibepod/commands/list_cmd.py b/src/vibepod/commands/list_cmd.py index 18aeebc..1e9ac18 100644 --- a/src/vibepod/commands/list_cmd.py +++ b/src/vibepod/commands/list_cmd.py @@ -14,6 +14,7 @@ from vibepod.core.config import get_config from vibepod.core.docker import DockerClientError, DockerManager from vibepod.core.launch import overlay_enabled +from vibepod.core.proxy_filter import resolve_container_policy from vibepod.utils.console import console, error @@ -30,7 +31,7 @@ def _configured_agent_rows() -> list[dict[str, str]]: return rows -def _running_rows(containers: list[Any]) -> list[dict[str, str]]: +def _running_rows(containers: list[Any], config: dict[str, Any]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for container in containers: labels = getattr(container, "labels", {}) or {} @@ -38,10 +39,22 @@ def _running_rows(containers: list[Any]) -> list[dict[str, str]]: status = getattr(container, "status", "-") if not agent or status != "running": continue + profile = labels.get("vibepod.profile", "-") + policy_id = labels.get("vibepod.proxy-policy") + proxy_mode = "-" + if policy_id: + try: + settings = resolve_container_policy(config, policy_id) + mode = settings.get("mode") + proxy_mode = mode if isinstance(mode, str) else "unavailable" + except (OSError, ValueError): + proxy_mode = "unavailable" rows.append( { "agent": agent, "container": getattr(container, "name", "-"), + "profile": profile, + "proxy_mode": proxy_mode, "context": labels.get("vibepod.workspace", "-"), }, ) @@ -124,7 +137,8 @@ def list_agents( manager = None containers = [] - running_rows = _running_rows(containers) + config = get_config() + running_rows = _running_rows(containers, config) configured_rows = _configured_agent_rows() overlay_rows = [] if running else _overlay_rows(manager) @@ -141,11 +155,19 @@ def list_agents( running_table = Table(title="Running Agents", title_justify="left") running_table.add_column("AGENT", style="cyan") running_table.add_column("CONTAINER", style="magenta") + running_table.add_column("PROFILE") + running_table.add_column("PROXY MODE") running_table.add_column("CONTEXT") if running_rows: for row in running_rows: - running_table.add_row(row["agent"], row["container"], row["context"]) + running_table.add_row( + row["agent"], + row["container"], + row["profile"], + row["proxy_mode"], + row["context"], + ) console.print(running_table) else: console.print("No running agents.") diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 03b96be..b187c8c 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -3,12 +3,14 @@ from __future__ import annotations import os -from typing import Annotated +from typing import Annotated, Any import typer from vibepod.constants import SUPPORTED_AGENTS from vibepod.core.config import get_config +from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.launch import managed_proxy_policy_ids from vibepod.core.profiles import ( DEFAULT_PROFILE, create_profile, @@ -19,6 +21,7 @@ resolve_profile, validate_profile_name, ) +from vibepod.core.proxy_filter import cleanup_orphan_policies from vibepod.utils.console import console, error, success, warning app = typer.Typer(help="Manage credential profiles (separate agent logins per environment)") @@ -44,6 +47,17 @@ def _active_profile() -> str | None: return None +def _cleanup_removed_profile_policy(config: dict[str, Any]) -> None: + """Sweep only when the complete managed-container set is available.""" + try: + manager = DockerManager() + except DockerClientError: + return + referenced = managed_proxy_policy_ids(manager) + if referenced is not None: + cleanup_orphan_policies(config, referenced) + + @app.command("list") def list_() -> None: """List profiles, the active one, and which agents have stored data.""" @@ -90,6 +104,7 @@ def remove( raise typer.Exit(code=1) if not yes: typer.confirm(f"Remove profile '{name}' and all credentials stored in it?", abort=True) + config = get_config() try: remove_profile(name) except ValueError as exc: @@ -104,4 +119,5 @@ def remove( raise typer.Exit(code=1) from exc if os.environ.get("VP_PROFILE") == name: console.print(f"Note: VP_PROFILE still points at removed profile '{name}'.") + _cleanup_removed_profile_policy(config) success(f"Removed profile '{name}'") diff --git a/src/vibepod/commands/proxy.py b/src/vibepod/commands/proxy.py index 894aaa9..22ff035 100644 --- a/src/vibepod/commands/proxy.py +++ b/src/vibepod/commands/proxy.py @@ -2,18 +2,247 @@ from __future__ import annotations +import contextlib +from collections.abc import Iterator from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.config import get_config -from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag +from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.launch import provision_proxy +from vibepod.core.profiles import DEFAULT_PROFILE, resolve_profile +from vibepod.core.proxy_filter import ( + VALID_MODES, + effective_filter_settings, + materialize_policy_bases, + normalize_pattern, + profile_filter_path, + update_profile_filter, +) from vibepod.utils.console import error, info, success, warning app = typer.Typer(help="Manage the HTTP(S) proxy") +filter_app = typer.Typer(help="Manage proxy allow/deny filtering") +allow_app = typer.Typer(help="Manage the allow list") +deny_app = typer.Typer(help="Manage the deny list") +filter_app.add_typer(allow_app, name="allow") +filter_app.add_typer(deny_app, name="deny") +app.add_typer(filter_app, name="filter") + + +_PROFILE_OPTION = typer.Option( + "--profile", + help="Profile whose filter to manage (default: the active profile)", +) + + +def _target_profile(flag: str | None) -> str: + try: + return resolve_profile(flag, get_config()) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + +@contextlib.contextmanager +def _clean_config_errors() -> Iterator[None]: + """Surface invalid config/env (e.g. VP_PROXY_FILTER_MODE) as a clean exit.""" + try: + yield + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + +def _sync_filter_file(profile: str | None = None) -> None: + """Rematerialize the global and selected-profile schema-2 bases.""" + config = get_config() + target = profile + if target is None: + try: + target = resolve_profile(None, config) + except ValueError: + target = DEFAULT_PROFILE + materialize_policy_bases(config, target) + + +def _uses_profile_file(profile: str) -> bool: + path = profile_filter_path(profile) + return path is not None and path.exists() + + +def _normalized(entry: object) -> str: + return str(entry).strip().lower().rstrip(".") + + +_OVERRIDE_HINT = "overridden by project config (.vibepod/config.yaml) or VP_PROXY_FILTER_MODE" + + +@filter_app.command("status") +def filter_status( + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Show filter mode, lists, and proxy state.""" + config = get_config() + target = _target_profile(profile) + with _clean_config_errors(): + settings = effective_filter_settings(config, target) + if _uses_profile_file(target): + info(f"Profile: {target} (profile-specific filter)") + else: + info(f"Profile: {target} (global filter settings)") + info(f"Mode: {settings['mode']}") + info(f"Allow list ({len(settings['allow'])}): {', '.join(settings['allow']) or '—'}") + info(f"Deny list ({len(settings['deny'])}): {', '.join(settings['deny']) or '—'}") + try: + manager = DockerManager() + except DockerClientError: + info("Proxy: unknown (Docker is not running)") + return + # find_proxy already returns a container listed with a fresh status, so an + # extra reload() only risks a NotFound race if the proxy is removed midway. + existing = manager.find_proxy() + if existing is None: + info("Proxy: not running") + else: + info(f"Proxy: {existing.name} ({existing.status})") + + +@filter_app.command("mode") +def filter_mode( + value: Annotated[str, typer.Argument(help="open, allow, or deny")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Set the filter mode (open = no filtering).""" + normalized = value.strip().lower() + if normalized not in VALID_MODES: + error(f"Unknown mode '{value}'. Valid modes: {', '.join(VALID_MODES)}") + raise typer.Exit(1) + target = _target_profile(profile) + with _clean_config_errors(): + update_profile_filter(target, lambda f: f.update(mode=normalized)) + _sync_filter_file(target) + effective = effective_filter_settings(get_config(), target) + success(f"Proxy filter mode set to '{normalized}' for profile '{target}'") + if effective["mode"] != normalized: + warning( + f"Effective mode stays '{effective['mode']}' — {_OVERRIDE_HINT}", + ) + + +def _list_add(list_name: str, host: str, profile: str | None) -> None: + try: + pattern = normalize_pattern(host) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + target = _target_profile(profile) + added = True + + def mutate(filter_cfg: dict[str, Any]) -> None: + nonlocal added + entries = filter_cfg.setdefault(list_name, []) + if not isinstance(entries, list): + entries = [] + filter_cfg[list_name] = entries + # Hand-authored config entries may be unnormalized; compare normalized. + if pattern in {_normalized(e) for e in entries}: + added = False + return + entries.append(pattern) + + with _clean_config_errors(): + update_profile_filter(target, mutate) + if not added: + warning(f"'{pattern}' is already in the {list_name} list") + return + with _clean_config_errors(): + _sync_filter_file(target) + effective_list = effective_filter_settings(get_config(), target)[list_name] + success(f"Added '{pattern}' to the {list_name} list of profile '{target}'") + if pattern not in effective_list: + warning( + f"'{pattern}' saved globally but absent from the effective " + f"{list_name} list — {_OVERRIDE_HINT}", + ) + + +def _list_remove(list_name: str, host: str, profile: str | None) -> None: + try: + pattern = normalize_pattern(host) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + target = _target_profile(profile) + removed = False + + def mutate(filter_cfg: dict[str, Any]) -> None: + nonlocal removed + entries = filter_cfg.setdefault(list_name, []) + if not isinstance(entries, list): + return + kept = [e for e in entries if _normalized(e) != pattern] + if len(kept) != len(entries): + filter_cfg[list_name] = kept + removed = True + + with _clean_config_errors(): + update_profile_filter(target, mutate) + if not removed: + warning(f"'{pattern}' is not in the {list_name} list") + return + with _clean_config_errors(): + _sync_filter_file(target) + effective_list = effective_filter_settings(get_config(), target)[list_name] + success(f"Removed '{pattern}' from the {list_name} list of profile '{target}'") + if pattern in effective_list: + warning( + f"'{pattern}' removed globally but still in the effective " + f"{list_name} list — {_OVERRIDE_HINT}", + ) + + +@allow_app.command("add") +def allow_add( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Add a host pattern to the allow list.""" + _list_add("allow", host, profile) + + +@allow_app.command("remove") +def allow_remove( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Remove a host pattern from the allow list.""" + _list_remove("allow", host, profile) + + +@deny_app.command("add") +def deny_add( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Add a host pattern to the deny list.""" + _list_add("deny", host, profile) + + +@deny_app.command("remove") +def deny_remove( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: + """Remove a host pattern from the deny list.""" + _list_remove("deny", host, profile) + @app.command("start") def proxy_start() -> None: @@ -42,28 +271,24 @@ def proxy_start() -> None: manager.ensure_network(network_name) - auto_clean = bool(config.get("auto_clean", True)) - updated = False - if _is_latest_tag(proxy_image): - info("Checking for proxy image updates…") - updated = manager.pull_if_newer(proxy_image) - if updated: - info("New image available — restarting proxy") - existing = manager.find_proxy() - if existing: - existing.remove(force=True) + # Materialize the shared global and active-profile policy bases first; they + # live on disk independent of the proxy container's lifecycle. + with _clean_config_errors(): + _sync_filter_file() info("Starting proxy") - manager.ensure_proxy( - image=proxy_image, - db_path=db_path, - ca_dir=ca_dir, - network=network_name, - ) - if auto_clean: - # Swept last: the replaced image is only removable once the proxy - # container that held it has been recreated on the new one. - manager.clean_untagged_images() + try: + provision_proxy( + manager, + image=proxy_image, + db_path=db_path, + ca_dir=ca_dir, + network=network_name, + auto_clean=bool(config.get("auto_clean", True)), + ) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc success("Proxy is running") diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 339d724..4b60cef 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -55,6 +55,9 @@ from vibepod.core.launch import ( apply_overlay_if_enabled, ) +from vibepod.core.launch import ( + apply_proxy_env as _apply_proxy_env, +) from vibepod.core.launch import ( get_container_ip as _get_container_ip, ) @@ -67,12 +70,18 @@ from vibepod.core.launch import ( init_entrypoint as _init_entrypoint, ) +from vibepod.core.launch import ( + materialize_launch_policy as _materialize_launch_policy, +) from vibepod.core.launch import ( parse_env_pairs as _parse_env_pairs, ) from vibepod.core.launch import ( prepare_x11_auth as _prepare_x11_auth, ) +from vibepod.core.launch import ( + provision_proxy as _provision_proxy, +) from vibepod.core.launch import ( publish_port_bindings as _publish_port_bindings, ) @@ -92,6 +101,7 @@ x11_volumes_and_env as _x11_volumes_and_env, ) from vibepod.core.profiles import resolve_profile +from vibepod.core.proxy_filter import remove_container_policy from vibepod.core.resume import show_resume_hint from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning @@ -591,6 +601,7 @@ def run( Path(proxy_ca_path_value).expanduser().resolve() if proxy_ca_path_value else None ) proxy_db_path: Path | None = None + proxy_policy_id: str | None = None extra_volumes = _agent_extra_volumes(selected_agent, config_dir) for host_path, _, _ in extra_volumes: @@ -639,15 +650,24 @@ def run( .resolve() ) - if _is_latest_tag(proxy_image): - manager.pull_if_newer(proxy_image, auto_clean=bool(config.get("auto_clean", True))) - - manager.ensure_proxy( - image=proxy_image, - db_path=proxy_db_path, - ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy", - network=network_name, - ) + try: + _provision_proxy( + manager, + image=proxy_image, + db_path=proxy_db_path, + ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy", + network=network_name, + auto_clean=bool(config.get("auto_clean", True)), + ) + proxy_policy_id = _materialize_launch_policy( + manager, + config, + profile=active_profile, + workspace=workspace_path, + ) + except (DockerClientError, ValueError) as exc: + error(str(exc)) + raise typer.Exit(1) from exc if proxy_ca_path: ca_ready = False @@ -660,15 +680,7 @@ def run( if not ca_ready: warning(f"Proxy CA not found yet at {proxy_ca_path}") - proxy_url = "http://vibepod-proxy:8080" - merged_env.setdefault("HTTP_PROXY", proxy_url) - merged_env.setdefault("HTTPS_PROXY", proxy_url) - merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") - _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" - merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) - merged_env.setdefault("REQUESTS_CA_BUNDLE", _ca) - merged_env.setdefault("SSL_CERT_FILE", _ca) - merged_env.setdefault("CURL_CA_BUNDLE", _ca) + _apply_proxy_env(merged_env, proxy_policy_id) if proxy_ca_dir: extra_volumes.append((str(proxy_ca_dir), "/etc/vibepod-proxy-ca", "ro")) @@ -677,26 +689,35 @@ def run( container_user = None if not rootless_podman and spec.run_as_host_user: container_user = _host_user() - container = manager.run_agent( - agent=selected_agent, - image=image, - workspace=workspace_path, - config_dir=config_dir, - config_mount_path=spec.config_mount_path, - env=merged_env, - command=command, - auto_remove=bool(config.get("auto_remove", True)), - name=name, - version=__version__, - network=network_name, - ports=agent_ports, - extra_volumes=extra_volumes, - platform=spec.platform, - user=container_user, - entrypoint=entrypoint, - userns_mode=agent_userns_mode, - extra_labels=herdr_labels, - ) + launch_labels = dict(herdr_labels) + launch_labels["vibepod.profile"] = active_profile + if proxy_policy_id is not None: + launch_labels["vibepod.proxy-policy"] = proxy_policy_id + try: + container = manager.run_agent( + agent=selected_agent, + image=image, + workspace=workspace_path, + config_dir=config_dir, + config_mount_path=spec.config_mount_path, + env=merged_env, + command=command, + auto_remove=bool(config.get("auto_remove", True)), + name=name, + version=__version__, + network=network_name, + ports=agent_ports, + extra_volumes=extra_volumes, + platform=spec.platform, + user=container_user, + entrypoint=entrypoint, + userns_mode=agent_userns_mode, + extra_labels=launch_labels, + ) + except Exception: + if proxy_policy_id is not None: + remove_container_policy(config, proxy_policy_id) + raise container.reload() if container.status != "running": @@ -735,6 +756,8 @@ def run( container.id, container.name, selected_agent, + policy_id=proxy_policy_id, + profile=active_profile, ) if not mapping_updated: warning( diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index fe426cf..677801b 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -35,16 +35,20 @@ agent_init_commands, agent_port_bindings, apply_overlay_if_enabled, + apply_proxy_env, get_container_ip, host_identity_env, host_user, init_entrypoint, + materialize_launch_policy, parse_env_pairs, + provision_proxy, read_claude_stored_token, terminal_env_defaults, update_container_mapping, ) from vibepod.core.profiles import resolve_profile +from vibepod.core.proxy_filter import remove_container_policy from vibepod.core.tasks import ( TASK_STATUS_CANCELLED, TASK_STATUS_COMPLETED, @@ -707,6 +711,7 @@ def task_create( Path(proxy_ca_path_value).expanduser().resolve() if proxy_ca_path_value else None ) proxy_db_path: Path | None = None + proxy_policy_id: str | None = None if proxy_enabled: proxy_image = str(proxy_cfg.get("image", "vibepod/proxy:latest")) @@ -716,16 +721,25 @@ def task_create( .resolve() ) - if _is_latest_tag(proxy_image): - manager.pull_if_newer(proxy_image, auto_clean=bool(config.get("auto_clean", True))) - actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" - manager.ensure_proxy( - image=proxy_image, - db_path=proxy_db_path, - ca_dir=actual_ca_dir, - network=network_name, - ) + try: + provision_proxy( + manager, + image=proxy_image, + db_path=proxy_db_path, + ca_dir=actual_ca_dir, + network=network_name, + auto_clean=bool(config.get("auto_clean", True)), + ) + proxy_policy_id = materialize_launch_policy( + manager, + config, + profile=active_profile, + workspace=workspace_path, + ) + except (DockerClientError, ValueError) as exc: + error(str(exc)) + raise typer.Exit(1) from exc if proxy_ca_path: deadline = time.time() + 10 @@ -734,15 +748,7 @@ def task_create( break time.sleep(0.25) - proxy_url = "http://vibepod-proxy:8080" - merged_env.setdefault("HTTP_PROXY", proxy_url) - merged_env.setdefault("HTTPS_PROXY", proxy_url) - merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") - _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" - merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) - merged_env.setdefault("REQUESTS_CA_BUNDLE", _ca) - merged_env.setdefault("SSL_CERT_FILE", _ca) - merged_env.setdefault("CURL_CA_BUNDLE", _ca) + apply_proxy_env(merged_env, proxy_policy_id) extra_volumes.append((str(actual_ca_dir), "/etc/vibepod-proxy-ca", "ro")) @@ -750,26 +756,35 @@ def task_create( container_user = None if not rootless_podman and spec.run_as_host_user: container_user = host_user() - container = manager.run_agent( - agent=selected, - image=image, - workspace=workspace_path, - config_dir=config_dir, - config_mount_path=spec.config_mount_path, - env=merged_env, - command=command, - auto_remove=False, # tasks keep the container so logs/exit survive - name=name, - version=__version__, - network=network_name, - ports=agent_ports, - extra_volumes=extra_volumes, - platform=spec.platform, - user=container_user, - entrypoint=entrypoint, - userns_mode=agent_userns_mode, - extra_labels=herdr_labels, - ) + launch_labels = dict(herdr_labels) + launch_labels["vibepod.profile"] = active_profile + if proxy_policy_id is not None: + launch_labels["vibepod.proxy-policy"] = proxy_policy_id + try: + container = manager.run_agent( + agent=selected, + image=image, + workspace=workspace_path, + config_dir=config_dir, + config_mount_path=spec.config_mount_path, + env=merged_env, + command=command, + auto_remove=False, # tasks keep the container so logs/exit survive + name=name, + version=__version__, + network=network_name, + ports=agent_ports, + extra_volumes=extra_volumes, + platform=spec.platform, + user=container_user, + entrypoint=entrypoint, + userns_mode=agent_userns_mode, + extra_labels=launch_labels, + ) + except Exception: + if proxy_policy_id is not None: + remove_container_policy(config, proxy_policy_id) + raise container.reload() if container.status not in {"running", "created"}: @@ -796,6 +811,8 @@ def task_create( container.id, container.name, selected, + policy_id=proxy_policy_id, + profile=active_profile, ) state = container.attrs.get("State", {}) or {} diff --git a/src/vibepod/core/config.py b/src/vibepod/core/config.py index e41feb1..82a64c7 100644 --- a/src/vibepod/core/config.py +++ b/src/vibepod/core/config.py @@ -179,6 +179,11 @@ def _default_config() -> dict[str, Any]: "db_path": str(config_root / "proxy" / "proxy.db"), "ca_dir": str(config_root / "proxy" / "mitmproxy"), "ca_path": str(config_root / "proxy" / "mitmproxy" / "mitmproxy-ca-cert.pem"), + "filter": { + "mode": "open", + "allow": [], + "deny": [], + }, }, "llm": { "enabled": False, @@ -228,6 +233,7 @@ def _apply_env(config: dict[str, Any]) -> dict[str, Any]: "VP_NO_COLOR": ("no_color", lambda x: x.lower() == "true"), "VP_DATASETTE_PORT": ("logging.ui_port", int), "VP_PROXY_ENABLED": ("proxy.enabled", lambda x: x.lower() == "true"), + "VP_PROXY_FILTER_MODE": ("proxy.filter.mode", str), "VP_LLM_ENABLED": ("llm.enabled", lambda x: x.lower() == "true"), "VP_LLM_BASE_URL": ("llm.base_url", str), "VP_LLM_API_KEY": ("llm.api_key", str), diff --git a/src/vibepod/core/docker.py b/src/vibepod/core/docker.py index 3973b93..9d16949 100644 --- a/src/vibepod/core/docker.py +++ b/src/vibepod/core/docker.py @@ -72,6 +72,10 @@ class DockerClientError(RuntimeError): # How much trailing container output attach_interactive keeps for post-exit # inspection (resume hints appear in the last few lines of a session). ATTACH_TAIL_LIMIT = 64 * 1024 +PROXY_POLICY_SCHEMA_LABEL = "io.vibepod.proxy.policy-schema" +# The per-source policy schema this CLI speaks; a proxy image must carry the +# matching PROXY_POLICY_SCHEMA_LABEL value. +PROXY_POLICY_SCHEMA = "2" def _run_podman(podman: str, args: list[str]) -> str | None: @@ -403,6 +407,27 @@ def image_id(self, image: str) -> str | None: # missing id as "no confirmed image", never as a failure to report. return None + def require_proxy_policy_schema(self, image: str | Any, required: str = "2") -> None: + """Require an exact per-source policy schema label on a proxy image.""" + image_name = image if isinstance(image, str) else "the running proxy image" + try: + inspected = self.client.images.get(image) if isinstance(image, str) else image + attrs = inspected.attrs + config = attrs.get("Config", {}) if isinstance(attrs, dict) else {} + labels = config.get("Labels", {}) if isinstance(config, dict) else {} + actual = labels.get(PROXY_POLICY_SCHEMA_LABEL) if isinstance(labels, dict) else None + except (AttributeError, DockerException) as exc: + raise DockerClientError( + f"Proxy image {image_name} could not be inspected for policy schema {required}", + ) from exc + if actual != required: + shown = actual if isinstance(actual, str) and actual else "missing" + raise DockerClientError( + f"Proxy image {image_name} is incompatible: required policy schema {required}, " + f"found {shown}. Use an updated VibePod proxy image or add " + f"the label {PROXY_POLICY_SCHEMA_LABEL}={required} to a compatible custom image.", + ) + def build_image( self, context_tar: Any, @@ -813,10 +838,19 @@ def find_proxy(self) -> Any | None: ) return containers[0] if containers else None - def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) -> Any: + def ensure_proxy( + self, + image: str, + db_path: Path, + ca_dir: Path, + network: str, + policy_schema: str | None = None, + ) -> Any: existing = self.find_proxy() if existing: if existing.status == "running": + if policy_schema is not None: + self.require_proxy_policy_schema(existing.image, policy_schema) return existing existing.remove(force=True) @@ -826,6 +860,9 @@ def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) -> except NotFound: self.pull_image(image) + if policy_schema is not None: + self.require_proxy_policy_schema(image, policy_schema) + db_path.parent.mkdir(parents=True, exist_ok=True) ca_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/vibepod/core/launch.py b/src/vibepod/core/launch.py index 0ab0ad3..2df25af 100644 --- a/src/vibepod/core/launch.py +++ b/src/vibepod/core/launch.py @@ -15,7 +15,12 @@ from vibepod.core import overlay from vibepod.core.config import load_project_config -from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.docker import ( + PROXY_POLICY_SCHEMA, + DockerClientError, + DockerManager, + _is_latest_tag, +) from vibepod.utils.console import error, warning CLAUDE_TOKEN_FILENAME = "oauth-token" @@ -347,12 +352,130 @@ def get_container_ip(container: Any, network: str) -> str | None: return None +def provision_proxy( + manager: Any, + *, + image: str, + db_path: Path, + ca_dir: Path, + network: str, + auto_clean: bool, + policy_schema: str = PROXY_POLICY_SCHEMA, +) -> Any: + """Bring up a schema-compatible proxy, refreshing a ``:latest`` image safely. + + A freshly pulled image is validated *before* any running proxy is torn + down, so an incompatible image never leaves the host with no proxy. Untagged + images are swept only after the replacement is running (never before the old + container is removed, or the in-use image cannot be reclaimed). + """ + if _is_latest_tag(image): + if manager.pull_if_newer(image, auto_clean=False): + manager.require_proxy_policy_schema(image, policy_schema) + existing = manager.find_proxy() + if existing: + existing.remove(force=True) + container = manager.ensure_proxy( + image=image, + db_path=db_path, + ca_dir=ca_dir, + network=network, + policy_schema=policy_schema, + ) + if auto_clean: + manager.clean_untagged_images() + return container + + +_PROXY_URL_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy") +_PROXY_CA_PATH = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" + + +def materialize_launch_policy( + manager: Any, + config: dict[str, Any], + *, + profile: str, + workspace: Path, +) -> str: + """Materialize the policy bases and this launch's record; return its id. + + Sweeps records orphaned by since-removed containers first (best effort), so + the policy dir does not grow without bound. + """ + from vibepod.core.proxy_filter import ( + cleanup_orphan_policies, + materialize_container_policy, + materialize_policy_bases, + ) + from vibepod.core.proxy_identity import new_policy_id + + materialize_policy_bases(config, profile) + referenced = managed_proxy_policy_ids(manager) + if referenced is not None: + cleanup_orphan_policies(config, referenced) + policy_id = new_policy_id() + materialize_container_policy( + config, + profile=profile, + workspace=workspace, + policy_id=policy_id, + ) + return policy_id + + +def apply_proxy_env(merged_env: dict[str, str], policy_id: str) -> None: + """Point the agent at the identified proxy and warn on manual overrides. + + Both cased forms of the proxy variables are inspected: ``http_proxy`` and + friends are honored by curl/git/requests, so an override there bypasses the + per-container filter just as ``HTTP_PROXY`` would. + """ + from vibepod.core.proxy_identity import identified_proxy_url + + proxy_url = identified_proxy_url(policy_id) + for key in ("HTTP_PROXY", "HTTPS_PROXY"): + merged_env.setdefault(key, proxy_url) + if any(merged_env.get(key, proxy_url) != proxy_url for key in _PROXY_URL_ENV_VARS): + warning( + "Explicit HTTP_PROXY/HTTPS_PROXY (either case) overrides the identified " + "VibePod proxy; this launch may bypass its per-container filter policy.", + ) + merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") + for key in ("NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"): + merged_env.setdefault(key, _PROXY_CA_PATH) + + +def managed_proxy_policy_ids(manager: Any) -> set[str] | None: + """Return policy IDs on all existing managed containers, or None if unknowable.""" + lister = getattr(manager, "list_managed", None) + if not callable(lister): + return None + try: + containers = lister(all_containers=True) + except DockerClientError: + return None + return { + policy_id + for container in containers + if isinstance( + policy_id := (getattr(container, "labels", {}) or {}).get( + "vibepod.proxy-policy", + ), + str, + ) + } + + def update_container_mapping( mapping_path: Path, ip: str, container_id: str, container_name: str, agent: str, + *, + policy_id: str | None = None, + profile: str | None = None, ) -> bool: """Merge a new IP→container entry into containers.json atomically.""" mapping: dict[str, dict[str, str]] = {} @@ -363,12 +486,17 @@ def update_container_mapping( except (json.JSONDecodeError, OSError): pass - mapping[ip] = { + entry = { "container_id": container_id, "container_name": container_name, "agent": agent, "started_at": datetime.now(timezone.utc).isoformat(), } + if policy_id is not None: + entry["policy_id"] = policy_id + if profile is not None: + entry["profile"] = profile + mapping[ip] = entry tmp_path = mapping_path.with_suffix(".tmp") tmp_path.write_text(json.dumps(mapping, indent=2)) diff --git a/src/vibepod/core/proxy_filter.py b/src/vibepod/core/proxy_filter.py new file mode 100644 index 0000000..55d5b3f --- /dev/null +++ b/src/vibepod/core/proxy_filter.py @@ -0,0 +1,436 @@ +"""Proxy allow/deny filter: validation, config mutation, materialization.""" + +from __future__ import annotations + +import io +import json +import os +import re +import tempfile +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from vibepod.core.config import ( + _default_config, + _load_yaml, + deep_merge, + get_global_config_path, + load_project_config, +) +from vibepod.core.profiles import DEFAULT_PROFILE, profiles_root, validate_profile_name +from vibepod.core.proxy_identity import validate_policy_id + +POLICY_SCHEMA = 2 +VALID_MODES = ("open", "allow", "deny") + +# A launch writes its container policy record before its container exists, so a +# concurrent cleanup must not delete a record younger than this window or it +# would strand the still-starting agent with no policy (failing closed). +_ORPHAN_GRACE_SECONDS = 60.0 + +_PATTERN_RE = re.compile( + r"^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", +) + + +def normalize_pattern(raw: str) -> str: + """Validate and normalize a host pattern; raise ValueError if invalid.""" + pattern = raw.strip().lower().rstrip(".") + if not _PATTERN_RE.match(pattern): + raise ValueError( + f"Invalid host pattern '{raw}'. Use a hostname like 'example.com' " + "or a subdomain wildcard like '*.example.com'.", + ) + return pattern + + +def get_filter_settings(config: dict[str, Any]) -> dict[str, Any]: + """Return normalized filter settings from an effective config.""" + proxy_cfg = config.get("proxy", {}) + if not isinstance(proxy_cfg, dict): + raise ValueError("Config key 'proxy' must be a mapping") + filter_cfg = proxy_cfg.get("filter", {}) + if not isinstance(filter_cfg, dict): + raise ValueError("Config key 'proxy.filter' must be a mapping") + + raw_mode = filter_cfg.get("mode", "open") + if not isinstance(raw_mode, str): + raise ValueError("Config key 'proxy.filter.mode' must be a string") + mode = raw_mode.strip().lower() + if mode not in VALID_MODES: + raise ValueError( + f"Invalid proxy.filter.mode '{raw_mode}'. Choose one of: {', '.join(VALID_MODES)}", + ) + + def _patterns(raw: Any, key: str) -> list[str]: + if not isinstance(raw, list): + raise ValueError(f"Config key 'proxy.filter.{key}' must be a list of strings") + if any(not isinstance(pattern, str) for pattern in raw): + raise ValueError(f"Config key 'proxy.filter.{key}' must contain only strings") + return [normalize_pattern(pattern) for pattern in raw] + + return { + "mode": mode, + "allow": _patterns(filter_cfg.get("allow", []), "allow"), + "deny": _patterns(filter_cfg.get("deny", []), "deny"), + } + + +def get_filter_file_path(config: dict[str, Any]) -> Path: + proxy_cfg = config.get("proxy", {}) + db_path = ( + Path(str(proxy_cfg.get("db_path", "~/.config/vibepod/proxy/proxy.db"))) + .expanduser() + .resolve() + ) + return db_path.parent / "filter.json" + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write via a sibling temp file + rename so readers never see partials.""" + # Follow symlinks: replacing the link itself would disconnect e.g. a + # dotfiles-managed config.yaml from its target. + path = path.resolve() + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + os.replace(tmp_name, path) + except BaseException: + os.unlink(tmp_name) + raise + + +def profile_filter_path(profile: str) -> Path | None: + """Per-profile filter file; None for the default profile (uses the global config).""" + if profile == DEFAULT_PROFILE: + return None + return profiles_root() / profile / "filter.yaml" + + +def _load_profile_filter(profile: str) -> dict[str, Any] | None: + """Return the profile's raw filter mapping, or None when it has no own filter.""" + path = profile_filter_path(profile) + if path is None or not path.exists(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Profile filter at {path} must be a YAML mapping") + return data + + +def effective_filter_settings( + config: dict[str, Any], + profile: str = DEFAULT_PROFILE, +) -> dict[str, Any]: + """Filter settings for *profile*: its filter.yaml when present, else the config. + + An explicit VP_PROXY_FILTER_MODE always wins, matching its precedence over + the config files. + """ + override = _load_profile_filter(profile) + if override is None: + return get_filter_settings(config) + settings = get_filter_settings({"proxy": {"filter": override}}) + env_mode = os.environ.get("VP_PROXY_FILTER_MODE") + if env_mode is not None: + settings["mode"] = _normalize_mode(env_mode, "VP_PROXY_FILTER_MODE") + return settings + + +def update_profile_filter( + profile: str, + mutate: Callable[[dict[str, Any]], None], +) -> dict[str, Any]: + """Apply *mutate* to the profile's filter settings and save them. + + The default profile keeps its settings in the global config.yaml; named + profiles get a filter.yaml in their profile dir, seeded from the global + settings on first write. + """ + path = profile_filter_path(profile) + if path is None: + return update_global_filter(mutate) + data: dict[str, Any] + if path.exists(): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is not None and not isinstance(raw, dict): + raise ValueError( + f"Profile filter at {path} is not a YAML mapping; refusing to rewrite it", + ) + data = raw if isinstance(raw, dict) else {} + else: + data = get_filter_settings(_load_yaml(get_global_config_path())) + mutate(data) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, yaml.safe_dump(data, sort_keys=False)) + return data + + +def _normalize_mode(raw: Any, source: str = "proxy.filter.mode") -> str: + if not isinstance(raw, str): + raise ValueError(f"{source} must be a string") + mode = raw.strip().lower() + if mode not in VALID_MODES: + raise ValueError(f"Invalid {source} '{raw}'. Choose one of: {', '.join(VALID_MODES)}") + return mode + + +def _normalize_filter_mapping(raw: Any) -> dict[str, Any]: + """Validate a partial filter mapping while retaining only its explicit keys.""" + if not isinstance(raw, dict): + raise ValueError("Config key 'proxy.filter' must be a mapping") + unsupported = set(raw) - {"mode", "allow", "deny"} + if unsupported: + names = ", ".join(sorted(str(key) for key in unsupported)) + raise ValueError(f"Unsupported proxy.filter key(s): {names}") + normalized = get_filter_settings({"proxy": {"filter": raw}}) + return {key: normalized[key] for key in ("mode", "allow", "deny") if key in raw} + + +def get_proxy_data_dir(config: dict[str, Any]) -> Path: + """Return the host directory mounted at ``/data`` in the proxy container.""" + return get_filter_file_path(config).parent + + +def materialized_profile_path(config: dict[str, Any], profile: str) -> Path: + validate_profile_name(profile) + return get_proxy_data_dir(config) / "policies" / "profiles" / f"{profile}.json" + + +def container_policy_path(config: dict[str, Any], policy_id: str) -> Path: + return ( + get_proxy_data_dir(config) + / "policies" + / "containers" + / f"{validate_policy_id(policy_id)}.json" + ) + + +def _global_filter_settings() -> dict[str, Any]: + global_config = deep_merge(_default_config(), _load_yaml(get_global_config_path())) + return get_filter_settings(global_config) + + +def materialize_policy_bases(config: dict[str, Any], profile: str) -> list[Path]: + """Atomically materialize the global and selected explicit-profile policy bases.""" + global_path = get_filter_file_path(config) + global_path.parent.mkdir(parents=True, exist_ok=True) + global_document = {"version": POLICY_SCHEMA, **_global_filter_settings()} + _atomic_write_text(global_path, json.dumps(global_document, indent=2) + "\n") + written = [global_path] + + if profile == DEFAULT_PROFILE: + return written + + target = materialized_profile_path(config, profile) + profile_filter = _load_profile_filter(profile) + if profile_filter is None: + # No source filter.yaml: leave any existing materialized base in place. + # A still-running container may reference it, and reference-aware + # deletion is cleanup_orphan_policies' job, not this hot path's. + return written + + target.parent.mkdir(parents=True, exist_ok=True) + profile_document = { + "version": POLICY_SCHEMA, + "profile": profile, + **get_filter_settings({"proxy": {"filter": profile_filter}}), + } + _atomic_write_text(target, json.dumps(profile_document, indent=2) + "\n") + written.append(target) + return written + + +def _project_filter(workspace: Path) -> dict[str, Any] | None: + project = load_project_config(workspace) + if "proxy" not in project: + return None + proxy = project["proxy"] + if not isinstance(proxy, dict): + raise ValueError("Project config key 'proxy' must be a mapping") + if "filter" not in proxy: + return None + return _normalize_filter_mapping(proxy["filter"]) + + +def materialize_container_policy( + config: dict[str, Any], + *, + profile: str, + workspace: Path, + policy_id: str, +) -> Path: + """Capture launch-specific project and environment policy inputs.""" + validated_id = validate_policy_id(policy_id) + env_value = os.environ.get("VP_PROXY_FILTER_MODE") + env_mode = _normalize_mode(env_value, "VP_PROXY_FILTER_MODE") if env_value is not None else None + document = { + "version": POLICY_SCHEMA, + "policy_id": validated_id, + "profile": profile, + "project_filter": _project_filter(workspace), + "env_mode": env_mode, + } + path = container_policy_path(config, validated_id) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, json.dumps(document, indent=2) + "\n") + return path + + +def _load_policy_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Unable to read proxy policy at {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Proxy policy at {path} must be a JSON object") + if data.get("version") != POLICY_SCHEMA: + raise ValueError(f"Proxy policy at {path} does not use schema {POLICY_SCHEMA}") + return data + + +def _settings_from_document(document: dict[str, Any]) -> dict[str, Any]: + raw = { + key: document.get(key, default) + for key, default in ( + ("mode", "open"), + ("allow", []), + ("deny", []), + ) + } + return get_filter_settings({"proxy": {"filter": raw}}) + + +def resolve_container_policy(config: dict[str, Any], policy_id: str) -> dict[str, Any]: + """Resolve the live effective settings for one materialized launch policy.""" + record = _load_policy_json(container_policy_path(config, policy_id)) + if record.get("policy_id") != policy_id: + raise ValueError("Container proxy policy id does not match its filename") + profile = record.get("profile") + if not isinstance(profile, str) or not profile: + raise ValueError("Container proxy policy profile must be a non-empty string") + validate_profile_name(profile) + + global_settings = _settings_from_document(_load_policy_json(get_filter_file_path(config))) + profile_path = materialized_profile_path(config, profile) + if profile != DEFAULT_PROFILE and profile_path.exists(): + settings = _settings_from_document(_load_policy_json(profile_path)) + else: + merged = dict(global_settings) + project_filter = record.get("project_filter") + if project_filter is not None: + merged.update(_normalize_filter_mapping(project_filter)) + settings = get_filter_settings({"proxy": {"filter": merged}}) + + env_mode = record.get("env_mode") + if env_mode is not None: + settings["mode"] = _normalize_mode(env_mode, "container env_mode") + return settings + + +def remove_container_policy(config: dict[str, Any], policy_id: str) -> None: + """Remove one launch policy record if it exists.""" + container_policy_path(config, policy_id).unlink(missing_ok=True) + + +def cleanup_orphan_policies( + config: dict[str, Any], + referenced_policy_ids: set[str], +) -> dict[str, int]: + """Remove records not referenced by any existing managed container.""" + root = get_proxy_data_dir(config) / "policies" + containers_dir = root / "containers" + profiles_dir = root / "profiles" + removed_containers = 0 + referenced_profiles: set[str] = set() + unknown_reference = False + now = time.time() + + if containers_dir.exists(): + for path in containers_dir.glob("*.json"): + policy_id = path.stem + keep = policy_id in referenced_policy_ids + if not keep: + try: + age = now - path.stat().st_mtime + except OSError: + continue + # A record younger than the grace window may belong to a + # concurrent launch whose container has not been created yet. + keep = age < _ORPHAN_GRACE_SECONDS + if not keep: + path.unlink(missing_ok=True) + removed_containers += 1 + continue + try: + record = _load_policy_json(path) + except ValueError: + unknown_reference = True + continue + profile = record.get("profile") + if isinstance(profile, str) and profile: + referenced_profiles.add(profile) + else: + unknown_reference = True + + removed_profiles = 0 + if profiles_dir.exists() and not unknown_reference: + for path in profiles_dir.glob("*.json"): + profile = path.stem + try: + validate_profile_name(profile) + except ValueError: + continue + source = profile_filter_path(profile) + if profile not in referenced_profiles and (source is None or not source.exists()): + path.unlink(missing_ok=True) + removed_profiles += 1 + + return {"containers": removed_containers, "profiles": removed_profiles} + + +def _roundtrip_yaml() -> YAML: + """A round-trip YAML handler that keeps comments, anchors, and quoting.""" + handler = YAML() + handler.preserve_quotes = True + return handler + + +def update_global_filter(mutate: Callable[[dict[str, Any]], None]) -> dict[str, Any]: + """Apply *mutate* to proxy.filter in the global config.yaml and save it. + + The file is edited via a round-trip loader so a user's comments, anchors, + and formatting survive a filter change. + """ + path = get_global_config_path() + handler = _roundtrip_yaml() + data: Any = None + if path.exists(): + text = path.read_text(encoding="utf-8") + if text.strip(): + data = handler.load(text) + if not isinstance(data, dict): + raise ValueError( + f"Global config at {path} is not a YAML mapping; refusing to rewrite it", + ) + if data is None: + data = CommentedMap() + proxy_cfg = data.setdefault("proxy", CommentedMap()) + if not isinstance(proxy_cfg, dict): + raise ValueError("Config key 'proxy' must be a mapping") + filter_cfg = proxy_cfg.setdefault("filter", CommentedMap()) + if not isinstance(filter_cfg, dict): + raise ValueError("Config key 'proxy.filter' must be a mapping") + mutate(filter_cfg) + path.parent.mkdir(parents=True, exist_ok=True) + buffer = io.StringIO() + handler.dump(data, buffer) + _atomic_write_text(path, buffer.getvalue()) + return filter_cfg diff --git a/src/vibepod/core/proxy_identity.py b/src/vibepod/core/proxy_identity.py new file mode 100644 index 0000000..938ba84 --- /dev/null +++ b/src/vibepod/core/proxy_identity.py @@ -0,0 +1,26 @@ +"""Per-launch proxy policy identity helpers.""" + +from __future__ import annotations + +import re +from uuid import uuid4 + +_POLICY_ID_RE = re.compile(r"^[0-9a-f]{32}$") + + +def validate_policy_id(policy_id: str) -> str: + """Return a valid policy identifier or raise ``ValueError``.""" + if not _POLICY_ID_RE.fullmatch(policy_id): + raise ValueError(f"Invalid proxy policy id '{policy_id}'") + return policy_id + + +def new_policy_id() -> str: + """Generate an opaque identifier for one agent launch policy.""" + return uuid4().hex + + +def identified_proxy_url(policy_id: str) -> str: + """Return the shared proxy URL carrying one launch policy identity.""" + validated = validate_policy_id(policy_id) + return f"http://vp-{validated}:vibepod@vibepod-proxy:8080" diff --git a/tests/test_docker.py b/tests/test_docker.py index d587c4e..b91696a 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -412,6 +412,53 @@ def test_ensure_proxy_pulls_image_when_missing(mock_docker, tmp_path: Path) -> N mock_client.containers.run.assert_called_once() +@pytest.mark.parametrize("label", [None, "", "two", "3"]) +@patch("vibepod.core.docker.docker") +def test_require_proxy_policy_schema_rejects_incompatible_image( + mock_docker, + label: str | None, +) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + labels = {} if label is None else {"io.vibepod.proxy.policy-schema": label} + mock_client.images.get.return_value.attrs = {"Config": {"Labels": labels}} + + manager = DockerManager() + with pytest.raises(DockerClientError, match="policy schema 2"): + manager.require_proxy_policy_schema("example/proxy:custom", "2") + + +@patch("vibepod.core.docker.docker") +def test_require_proxy_policy_schema_accepts_exact_label(mock_docker) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_client.images.get.return_value.attrs = { + "Config": {"Labels": {"io.vibepod.proxy.policy-schema": "2"}}, + } + + manager = DockerManager() + manager.require_proxy_policy_schema("example/proxy:custom", "2") + + +@patch("vibepod.core.docker.docker") +def test_ensure_proxy_rejects_incompatible_running_container(mock_docker, tmp_path: Path) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + existing = MagicMock(status="running") + existing.image.attrs = {"Config": {"Labels": {}}} + mock_client.containers.list.return_value = [existing] + + manager = DockerManager() + with pytest.raises(DockerClientError, match="policy schema 2"): + manager.ensure_proxy( + image="example/proxy:custom", + db_path=tmp_path / "proxy.db", + ca_dir=tmp_path / "ca", + network="vibepod-network", + policy_schema="2", + ) + + def test_discover_podman_socket_skipped_when_docker_host_set(monkeypatch) -> None: monkeypatch.setenv("DOCKER_HOST", "unix:///var/run/docker.sock") assert _discover_podman_socket() is None diff --git a/tests/test_list.py b/tests/test_list.py index 613938f..2992a70 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -79,7 +79,12 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 _FakeContainer( "vibepod-claude-1", "running", - {"vibepod.agent": "claude", "vibepod.workspace": "/workspace/a"}, + { + "vibepod.agent": "claude", + "vibepod.workspace": "/workspace/a", + "vibepod.profile": "work", + "vibepod.proxy-policy": "1" * 32, + }, ), _FakeContainer( "vibepod-claude-2", @@ -94,6 +99,11 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 ] monkeypatch.setattr(list_cmd, "DockerManager", _FakeDockerManager) + monkeypatch.setattr( + list_cmd, + "resolve_container_policy", + lambda config, policy_id: {"mode": "allow", "allow": [], "deny": []}, + ) result = runner.invoke(app, ["list", "--running", "--json"]) assert result.exit_code == 0 @@ -104,7 +114,44 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 assert len(rows) == 2 assert [row["container"] for row in rows] == ["vibepod-claude-1", "vibepod-claude-2"] assert {row["context"] for row in rows} == {"/workspace/a", "/workspace/b"} - assert all(set(row) == {"agent", "container", "context"} for row in rows) + assert rows[0]["profile"] == "work" + assert rows[0]["proxy_mode"] == "allow" + assert rows[1]["profile"] == "-" + assert rows[1]["proxy_mode"] == "-" + assert all( + set(row) == {"agent", "container", "profile", "proxy_mode", "context"} for row in rows + ) + + +def test_list_running_table_includes_profile_and_proxy_mode(monkeypatch) -> None: + class _Container: + name = "vibepod-claude-1" + status = "running" + labels = { + "vibepod.agent": "claude", + "vibepod.workspace": "/workspace/a", + "vibepod.profile": "work", + "vibepod.proxy-policy": "1" * 32, + } + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [_Container()] + + monkeypatch.setattr(list_cmd, "DockerManager", _Manager) + monkeypatch.setattr( + list_cmd, + "resolve_container_policy", + lambda config, policy_id: {"mode": "deny", "allow": [], "deny": []}, + ) + + result = runner.invoke(app, ["list", "--running"]) + + assert result.exit_code == 0 + assert "PROFILE" in result.stdout + assert "PROXY MODE" in result.stdout + assert "work" in result.stdout + assert "deny" in result.stdout def test_list_json_reports_project_overlay_image(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py index ae46716..a15fd57 100644 --- a/tests/test_profile_cmd.py +++ b/tests/test_profile_cmd.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json from pathlib import Path import pytest from typer.testing import CliRunner from vibepod.cli import app +from vibepod.commands import profile as profile_cmd from vibepod.core.agents import agent_config_dir runner = CliRunner() @@ -69,6 +71,71 @@ def test_profile_remove(config_root: Path) -> None: assert not (config_root / "profiles" / "work").exists() +def test_profile_remove_retains_materialized_policy_while_container_references_it( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner.invoke(app, ["profile", "create", "work"]) + (config_root / "config.yaml").write_text( + f"proxy:\n db_path: {config_root / 'proxy' / 'proxy.db'}\n", + ) + policy_id = "6" * 32 + materialized = config_root / "proxy" / "policies" / "profiles" / "work.json" + record = config_root / "proxy" / "policies" / "containers" / f"{policy_id}.json" + materialized.parent.mkdir(parents=True) + record.parent.mkdir(parents=True) + materialized.write_text("{}") + record.write_text( + json.dumps( + { + "version": 2, + "policy_id": policy_id, + "profile": "work", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + class _Container: + labels = {"vibepod.proxy-policy": policy_id} + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [_Container()] + + monkeypatch.setattr(profile_cmd, "DockerManager", _Manager) + + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + + assert result.exit_code == 0 + assert materialized.exists() + + +def test_profile_remove_cleans_unreferenced_materialized_policy( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner.invoke(app, ["profile", "create", "work"]) + (config_root / "config.yaml").write_text( + f"proxy:\n db_path: {config_root / 'proxy' / 'proxy.db'}\n", + ) + materialized = config_root / "proxy" / "policies" / "profiles" / "work.json" + materialized.parent.mkdir(parents=True) + materialized.write_text("{}") + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [] + + monkeypatch.setattr(profile_cmd, "DockerManager", _Manager) + + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + + assert result.exit_code == 0 + assert not materialized.exists() + + def test_profile_remove_refuses_default(config_root: Path) -> None: result = runner.invoke(app, ["profile", "remove", "default", "--yes"]) assert result.exit_code != 0 diff --git a/tests/test_provision_proxy.py b/tests/test_provision_proxy.py new file mode 100644 index 0000000..7311ec4 --- /dev/null +++ b/tests/test_provision_proxy.py @@ -0,0 +1,76 @@ +"""Tests for launch.provision_proxy: safe image refresh + proxy ensure.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from vibepod.core.docker import DockerClientError +from vibepod.core.launch import provision_proxy + + +class _FakeManager: + def __init__(self, *, pull_updates: bool = False, bad_new_image: bool = False) -> None: + self._pull_updates = pull_updates + self._bad_new_image = bad_new_image + self.pull_auto_clean: bool | None = None + self.ensured = False + self.swept = False + self.schema_checks: list[tuple[str, str]] = [] + self.running = MagicMock() + + def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: + self.pull_auto_clean = auto_clean + return self._pull_updates + + def require_proxy_policy_schema(self, image: str, required: str) -> None: + self.schema_checks.append((image, required)) + if self._bad_new_image: + raise DockerClientError("incompatible new image") + + def find_proxy(self) -> object: + return self.running + + def ensure_proxy(self, **kwargs: object) -> str: + self.ensured = True + return "proxy-container" + + def clean_untagged_images(self) -> None: + self.swept = True + + +def _provision(manager: _FakeManager, *, auto_clean: bool = True) -> object: + return provision_proxy( + manager, + image="vibepod/proxy:latest", + db_path=Path("/tmp/proxy.db"), + ca_dir=Path("/tmp/ca"), + network="vibepod-network", + auto_clean=auto_clean, + ) + + +def test_bad_new_image_does_not_tear_down_running_proxy() -> None: + manager = _FakeManager(pull_updates=True, bad_new_image=True) + with pytest.raises(DockerClientError): + _provision(manager) + manager.running.remove.assert_not_called() + assert manager.ensured is False + + +def test_pull_does_not_sweep_before_old_proxy_is_removed() -> None: + manager = _FakeManager(pull_updates=True) + _provision(manager) + assert manager.pull_auto_clean is False + manager.running.remove.assert_called_once() + assert manager.ensured is True + assert manager.swept is True + + +def test_no_auto_clean_skips_sweep() -> None: + manager = _FakeManager(pull_updates=True) + _provision(manager, auto_clean=False) + assert manager.swept is False + assert manager.ensured is True diff --git a/tests/test_proxy_cmd.py b/tests/test_proxy_cmd.py index 0e9d04d..ccbe563 100644 --- a/tests/test_proxy_cmd.py +++ b/tests/test_proxy_cmd.py @@ -27,6 +27,9 @@ def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: self._events.append(f"pull_if_newer(auto_clean={auto_clean})") return self._updated + def require_proxy_policy_schema(self, image: object, required: str = "2") -> None: + self._events.append("require_proxy_policy_schema") + def find_proxy(self): return self._container @@ -41,6 +44,11 @@ def ensure_proxy(self, **kwargs) -> None: def _patch_common(monkeypatch, events: list[str], config: dict, updated: bool = True) -> None: monkeypatch.setattr(proxy_cmd, "DockerManager", lambda: _FakeManager(events, updated)) monkeypatch.setattr(proxy_cmd, "get_config", lambda: config) + monkeypatch.setattr( + proxy_cmd, + "materialize_policy_bases", + lambda config, profile: events.append("materialize_policy_bases"), + ) def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: @@ -52,7 +60,9 @@ def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: assert events == [ "ensure_network", + "materialize_policy_bases", "pull_if_newer(auto_clean=False)", + "require_proxy_policy_schema", "container.remove", "ensure_proxy", "clean_untagged_images", @@ -67,13 +77,14 @@ def test_proxy_start_keeps_container_without_update(monkeypatch) -> None: assert events == [ "ensure_network", + "materialize_policy_bases", "pull_if_newer(auto_clean=False)", "ensure_proxy", "clean_untagged_images", ] -def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: +def test_proxy_start_validates_new_image_before_removing_running_proxy(monkeypatch) -> None: events: list[str] = [] _patch_common(monkeypatch, events, {"auto_clean": False}) @@ -81,7 +92,9 @@ def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: assert events == [ "ensure_network", + "materialize_policy_bases", "pull_if_newer(auto_clean=False)", + "require_proxy_policy_schema", "container.remove", "ensure_proxy", ] diff --git a/tests/test_proxy_filter.py b/tests/test_proxy_filter.py new file mode 100644 index 0000000..615492d --- /dev/null +++ b/tests/test_proxy_filter.py @@ -0,0 +1,425 @@ +"""Tests for proxy filter rule management and materialization.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +import pytest +import yaml + +from vibepod.core import proxy_filter as pf +from vibepod.core.config import get_config +from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id + + +def test_normalize_pattern_lowercases_and_strips() -> None: + assert pf.normalize_pattern(" Example.COM. ") == "example.com" + + +def test_normalize_pattern_accepts_wildcard() -> None: + assert pf.normalize_pattern("*.GitHub.com") == "*.github.com" + + +@pytest.mark.parametrize( + "raw", + ["", "https://example.com", "example.com/path", "*example.com", "a b.com", "*."], +) +def test_normalize_pattern_rejects_garbage(raw: str) -> None: + with pytest.raises(ValueError): + pf.normalize_pattern(raw) + + +def test_get_filter_settings_defaults_open() -> None: + assert pf.get_filter_settings({}) == {"mode": "open", "allow": [], "deny": []} + + +def test_get_filter_settings_rejects_invalid_mode() -> None: + config = {"proxy": {"filter": {"mode": "strict", "allow": ["a.com"], "deny": []}}} + with pytest.raises(ValueError, match="strict"): + pf.get_filter_settings(config) + + +def test_get_filter_settings_rejects_non_string_pattern() -> None: + config = {"proxy": {"filter": {"mode": "deny", "allow": [], "deny": [123]}}} + with pytest.raises(ValueError, match="strings"): + pf.get_filter_settings(config) + + +def test_get_filter_settings_passes_valid_config() -> None: + config = {"proxy": {"filter": {"mode": "allow", "allow": ["a.com"], "deny": ["b.com"]}}} + assert pf.get_filter_settings(config) == { + "mode": "allow", + "allow": ["a.com"], + "deny": ["b.com"], + } + + +def test_update_global_filter_writes_config_yaml(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + pf.update_global_filter(lambda f: f.update(mode="allow")) + data = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert data["proxy"]["filter"]["mode"] == "allow" + + +def test_update_global_filter_preserves_other_keys(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text("default_agent: gemini\nproxy:\n enabled: true\n") + pf.update_global_filter(lambda f: f.setdefault("allow", []).append("a.com")) + data = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert data["default_agent"] == "gemini" + assert data["proxy"]["enabled"] is True + assert data["proxy"]["filter"]["allow"] == ["a.com"] + + +def test_update_global_filter_preserves_comments(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "# top-level note kept\n" + "default_agent: gemini # inline note kept\n" + "proxy:\n" + " filter:\n" + " mode: open\n", + ) + pf.update_global_filter(lambda f: f.update(mode="allow")) + text = (tmp_path / "config.yaml").read_text() + assert "# top-level note kept" in text + assert "# inline note kept" in text + assert yaml.safe_load(text)["proxy"]["filter"]["mode"] == "allow" + + +def test_default_config_has_open_filter(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + config = get_config() + assert config["proxy"]["filter"] == {"mode": "open", "allow": [], "deny": []} + + +def test_env_overrides_filter_mode(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + config = get_config() + assert config["proxy"]["filter"]["mode"] == "deny" + + +def test_materialized_global_base_is_atomic(monkeypatch, tmp_path: Path) -> None: + """No truncate-then-write: replace so hot-reload readers never see partials.""" + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "proxy:\n filter:\n mode: deny\n deny: [example.com]\n", + ) + config = {"proxy": {"db_path": str(tmp_path / "proxy" / "proxy.db")}} + pf.materialize_policy_bases(config, "default") + + calls: list[tuple[Path, Path]] = [] + real_replace = pf.os.replace + + def spy_replace(src, dst): + calls.append((Path(src), Path(dst))) + real_replace(src, dst) + + monkeypatch.setattr(pf.os, "replace", spy_replace) + pf.materialize_policy_bases(config, "default") + path = pf.get_filter_file_path(config).resolve() + assert calls and calls[-1][1] == path + assert json.loads(path.read_text())["mode"] == "deny" + assert list(path.parent.glob(f".{path.name}.*")) == [] + + +def test_update_global_filter_refuses_non_mapping_config(monkeypatch, tmp_path: Path) -> None: + """A list-root config.yaml must not be silently replaced (data loss).""" + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text("- just\n- a\n- list\n") + with pytest.raises(ValueError): + pf.update_global_filter(lambda f: f.update(mode="allow")) + assert yaml.safe_load((tmp_path / "config.yaml").read_text()) == ["just", "a", "list"] + + +def test_atomic_write_preserves_symlinked_config(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + target = tmp_path / "dotfiles" / "vibepod.yaml" + target.parent.mkdir() + target.write_text("default_agent: gemini\n") + (tmp_path / "config.yaml").symlink_to(target) + + pf.update_global_filter(lambda f: f.update(mode="allow")) + + assert (tmp_path / "config.yaml").is_symlink() + data = yaml.safe_load(target.read_text()) + assert data["default_agent"] == "gemini" + assert data["proxy"]["filter"]["mode"] == "allow" + + +# --- per-profile filter settings --- + + +@pytest.fixture() +def profile_env(monkeypatch, tmp_path: Path) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("VP_PROXY_FILTER_MODE", raising=False) + (tmp_path / "profiles" / "work" / "agents").mkdir(parents=True) + return tmp_path + + +def test_profile_filter_path_default_is_none(profile_env: Path) -> None: + assert pf.profile_filter_path("default") is None + + +def test_profile_filter_path_named_profile(profile_env: Path) -> None: + assert pf.profile_filter_path("work") == profile_env / "profiles" / "work" / "filter.yaml" + + +def test_effective_settings_fall_back_to_global_without_profile_file(profile_env: Path) -> None: + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + settings = pf.effective_filter_settings(config, "work") + assert settings == {"mode": "deny", "allow": [], "deny": ["example.com"]} + + +def test_effective_settings_use_profile_file_when_present(profile_env: Path) -> None: + (profile_env / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}), + ) + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + settings = pf.effective_filter_settings(config, "work") + assert settings == {"mode": "allow", "allow": ["api.anthropic.com"], "deny": []} + + +def test_effective_settings_default_profile_ignores_profile_files(profile_env: Path) -> None: + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + assert pf.effective_filter_settings(config, "default") == { + "mode": "deny", + "allow": [], + "deny": ["example.com"], + } + + +def test_env_mode_override_beats_profile_file(profile_env: Path, monkeypatch) -> None: + (profile_env / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"]}), + ) + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "open") + assert pf.effective_filter_settings({}, "work")["mode"] == "open" + + +def test_update_profile_filter_seeds_from_global_config(profile_env: Path) -> None: + (profile_env / "config.yaml").write_text( + yaml.safe_dump({"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}}), + ) + pf.update_profile_filter("work", lambda f: f.update(mode="allow")) + data = yaml.safe_load((profile_env / "profiles" / "work" / "filter.yaml").read_text()) + assert data["mode"] == "allow" + assert data["deny"] == ["example.com"] + + +def test_update_profile_filter_mutates_existing_file(profile_env: Path) -> None: + path = profile_env / "profiles" / "work" / "filter.yaml" + path.write_text(yaml.safe_dump({"mode": "allow", "allow": ["a.com"], "deny": []})) + pf.update_profile_filter("work", lambda f: f["allow"].append("b.com")) + data = yaml.safe_load(path.read_text()) + assert data["allow"] == ["a.com", "b.com"] + + +def test_update_profile_filter_default_updates_global(profile_env: Path) -> None: + pf.update_profile_filter("default", lambda f: f.update(mode="deny")) + data = yaml.safe_load((profile_env / "config.yaml").read_text()) + assert data["proxy"]["filter"]["mode"] == "deny" + + +def test_policy_identity_is_random_hex_and_builds_proxy_url() -> None: + first = new_policy_id() + second = new_policy_id() + assert first != second + assert re.fullmatch(r"[0-9a-f]{32}", first) + assert identified_proxy_url(first) == f"http://vp-{first}:vibepod@vibepod-proxy:8080" + + +def test_materialize_container_policy_captures_project_and_environment( + profile_env: Path, + monkeypatch, +) -> None: + workspace = profile_env / "project" + project_config = workspace / ".vibepod" / "config.yaml" + project_config.parent.mkdir(parents=True) + project_config.write_text("proxy:\n filter:\n mode: deny\n deny: [example.com]\n") + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "allow") + config = { + "proxy": { + "db_path": str(profile_env / "proxy" / "proxy.db"), + "filter": {"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}, + }, + } + policy_id = "1" * 32 + + path = pf.materialize_container_policy( + config, + profile="work", + workspace=workspace, + policy_id=policy_id, + ) + + assert json.loads(path.read_text()) == { + "version": 2, + "policy_id": policy_id, + "profile": "work", + "project_filter": {"mode": "deny", "deny": ["example.com"]}, + "env_mode": "allow", + } + + +def test_materialized_profile_hot_reload_preserves_container_env_override( + profile_env: Path, + monkeypatch, +) -> None: + profile_path = profile_env / "profiles" / "work" / "filter.yaml" + profile_path.write_text("mode: allow\nallow: [api.anthropic.com]\ndeny: []\n") + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + config = { + "proxy": { + "db_path": str(profile_env / "proxy" / "proxy.db"), + "filter": {"mode": "open", "allow": [], "deny": []}, + }, + } + policy_id = "2" * 32 + pf.materialize_policy_bases(config, "work") + pf.materialize_container_policy( + config, + profile="work", + workspace=profile_env, + policy_id=policy_id, + ) + assert pf.resolve_container_policy(config, policy_id)["mode"] == "deny" + + profile_path.write_text("mode: open\nallow: []\ndeny: []\n") + pf.materialize_policy_bases(config, "work") + + assert pf.resolve_container_policy(config, policy_id) == { + "mode": "deny", + "allow": [], + "deny": [], + } + + +def test_cleanup_orphan_policies_keeps_existing_container_and_referenced_profile( + profile_env: Path, +) -> None: + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + kept_id = "4" * 32 + orphan_id = "5" * 32 + containers_dir = profile_env / "proxy" / "policies" / "containers" + profiles_dir = profile_env / "proxy" / "policies" / "profiles" + containers_dir.mkdir(parents=True) + profiles_dir.mkdir(parents=True) + (containers_dir / f"{kept_id}.json").write_text( + json.dumps( + { + "version": 2, + "policy_id": kept_id, + "profile": "removed-work", + "project_filter": None, + "env_mode": None, + }, + ), + ) + orphan_path = containers_dir / f"{orphan_id}.json" + orphan_path.write_text("{}") + # A real orphan predates the grace window that protects in-flight launches. + _age(orphan_path) + (profiles_dir / "removed-work.json").write_text("{}") + (profiles_dir / "unused.json").write_text("{}") + + removed = pf.cleanup_orphan_policies(config, {kept_id}) + + assert removed == {"containers": 1, "profiles": 1} + assert (containers_dir / f"{kept_id}.json").exists() + assert not (containers_dir / f"{orphan_id}.json").exists() + assert (profiles_dir / "removed-work.json").exists() + assert not (profiles_dir / "unused.json").exists() + + +def _age(path: Path, seconds: float = 3600.0) -> None: + """Backdate a file's mtime so grace-window logic treats it as settled.""" + stat = path.stat() + os.utime(path, (stat.st_atime - seconds, stat.st_mtime - seconds)) + + +def test_update_profile_filter_refuses_non_mapping_file(profile_env: Path) -> None: + path = profile_env / "profiles" / "work" / "filter.yaml" + path.write_text("- a.com\n- b.com\n") + with pytest.raises(ValueError, match="mapping"): + pf.update_profile_filter("work", lambda f: f.update(mode="allow")) + assert path.read_text() == "- a.com\n- b.com\n" + + +def test_materialize_policy_bases_retains_base_without_source_file( + profile_env: Path, +) -> None: + """A removed profile's materialized base survives while a container references it.""" + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + base = pf.materialized_profile_path(config, "work") + base.parent.mkdir(parents=True) + base.write_text( + json.dumps( + { + "version": 2, + "profile": "work", + "mode": "allow", + "allow": ["api.anthropic.com"], + "deny": [], + }, + ), + ) + # 'work' has no filter.yaml (it was removed); re-materializing must not wipe it. + pf.materialize_policy_bases(config, "work") + assert base.exists() + + +def test_cleanup_orphan_policies_retains_recent_unreferenced_record( + profile_env: Path, +) -> None: + """A just-written record for a launch whose container isn't listed yet is kept.""" + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + fresh_id = "8" * 32 + containers_dir = profile_env / "proxy" / "policies" / "containers" + containers_dir.mkdir(parents=True) + fresh_path = containers_dir / f"{fresh_id}.json" + fresh_path.write_text( + json.dumps( + { + "version": 2, + "policy_id": fresh_id, + "profile": "default", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + removed = pf.cleanup_orphan_policies(config, set()) + + assert removed["containers"] == 0 + assert fresh_path.exists() + + +def test_resolver_rejects_profile_path_traversal(profile_env: Path) -> None: + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + policy_id = "7" * 32 + pf.materialize_policy_bases(config, "default") + path = pf.container_policy_path(config, policy_id) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "version": 2, + "policy_id": policy_id, + "profile": "../../outside", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + with pytest.raises(ValueError, match="Invalid profile name"): + pf.resolve_container_policy(config, policy_id) diff --git a/tests/test_proxy_filter_cmd.py b/tests/test_proxy_filter_cmd.py new file mode 100644 index 0000000..fccbd5a --- /dev/null +++ b/tests/test_proxy_filter_cmd.py @@ -0,0 +1,223 @@ +"""Tests for vp proxy filter commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from vibepod.cli import app + +runner = CliRunner() + + +@pytest.fixture() +def config_dir(monkeypatch, tmp_path: Path) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("VP_PROXY_ENABLED", "true") + monkeypatch.delenv("VP_PROXY_FILTER_MODE", raising=False) + monkeypatch.chdir(tmp_path) + # Point the proxy db under the temp dir so filter.json lands there too. + (tmp_path / "config.yaml").write_text( + f"proxy:\n db_path: {tmp_path / 'proxy' / 'proxy.db'}\n", + ) + return tmp_path + + +def _filter_json(config_dir: Path) -> dict: + return json.loads((config_dir / "proxy" / "filter.json").read_text()) + + +def _config_yaml(config_dir: Path) -> dict: + return yaml.safe_load((config_dir / "config.yaml").read_text()) + + +def test_filter_status_shows_defaults(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "status"]) + assert result.exit_code == 0 + assert "open" in result.stdout + + +def test_filter_mode_switch_updates_config_and_file(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["mode"] == "allow" + assert _filter_json(config_dir)["mode"] == "allow" + + +def test_filter_mode_rejects_unknown(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "strict"]) + assert result.exit_code == 1 + + +def test_allow_add_and_remove(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "API.Anthropic.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == ["api.anthropic.com"] + assert _filter_json(config_dir)["allow"] == ["api.anthropic.com"] + + result = runner.invoke(app, ["proxy", "filter", "allow", "remove", "api.anthropic.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == [] + assert _filter_json(config_dir)["allow"] == [] + + +def test_deny_add_wildcard(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "deny", "add", "*.example.com"]) + assert result.exit_code == 0 + assert _filter_json(config_dir)["deny"] == ["*.example.com"] + + +def test_add_rejects_invalid_pattern(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "https://example.com"]) + assert result.exit_code == 1 + + +def test_add_duplicate_is_noop(config_dir: Path) -> None: + runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == ["a.com"] + + +def test_remove_absent_is_noop(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "remove", "missing.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"].get("allow", []) == [] + + +def test_mode_switch_preserves_lists(config_dir: Path) -> None: + runner.invoke(app, ["proxy", "filter", "deny", "add", "example.com"]) + runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + data = _config_yaml(config_dir)["proxy"]["filter"] + assert data["mode"] == "allow" + assert data["deny"] == ["example.com"] + + +def test_remove_matches_hand_authored_unnormalized_entry(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n" + " filter:\n mode: deny\n deny:\n - Example.COM.\n", + ) + result = runner.invoke(app, ["proxy", "filter", "deny", "remove", "example.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["deny"] == [] + + +def test_add_does_not_duplicate_unnormalized_entry(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n" + " filter:\n mode: deny\n deny:\n - Example.COM.\n", + ) + result = runner.invoke(app, ["proxy", "filter", "deny", "add", "example.com"]) + assert result.exit_code == 0 + assert len(_config_yaml(config_dir)["proxy"]["filter"]["deny"]) == 1 + + +def test_mode_warns_when_project_config_overrides(config_dir: Path) -> None: + project = config_dir / ".vibepod" + project.mkdir() + (project / "config.yaml").write_text("proxy:\n filter:\n mode: deny\n") + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["mode"] == "allow" + + +def test_mode_warns_when_env_overrides(config_dir: Path, monkeypatch) -> None: + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["mode"] == "allow" + + +def test_add_warns_when_project_config_overrides(config_dir: Path) -> None: + project = config_dir / ".vibepod" + project.mkdir() + (project / "config.yaml").write_text("proxy:\n filter:\n allow: []\n") + + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["allow"] == ["a.com"] + + +def test_status_rejects_invalid_configured_mode(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n filter:\n mode: strict\n", + ) + result = runner.invoke(app, ["proxy", "filter", "status"]) + assert result.exit_code == 1 + # Invalid config is surfaced as a clean error, not a raw traceback. + assert not isinstance(result.exception, ValueError) + assert "strict" in result.output + + +# --- per-profile filter commands --- + + +@pytest.fixture() +def work_profile(config_dir: Path) -> Path: + (config_dir / "profiles" / "work" / "agents").mkdir(parents=True) + return config_dir + + +def _work_filter(config_dir: Path) -> dict: + return yaml.safe_load((config_dir / "profiles" / "work" / "filter.yaml").read_text()) + + +def test_filter_mode_with_profile_writes_profile_file(work_profile: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow", "--profile", "work"]) + assert result.exit_code == 0 + assert _work_filter(work_profile)["mode"] == "allow" + # Global config and the materialized file of the active (default) profile stay put. + assert "filter" not in _config_yaml(work_profile).get("proxy", {}) + assert _filter_json(work_profile)["mode"] == "open" + + +def test_filter_mode_targets_active_profile(work_profile: Path) -> None: + config = _config_yaml(work_profile) + config["profile"] = "work" + (work_profile / "config.yaml").write_text(yaml.safe_dump(config)) + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + assert result.exit_code == 0 + assert _work_filter(work_profile)["mode"] == "allow" + assert _filter_json(work_profile)["mode"] == "open" + materialized = json.loads( + (work_profile / "proxy" / "policies" / "profiles" / "work.json").read_text(), + ) + assert materialized["mode"] == "allow" + + +def test_allow_add_with_profile(work_profile: Path) -> None: + result = runner.invoke( + app, + ["proxy", "filter", "allow", "add", "api.anthropic.com", "--profile", "work"], + ) + assert result.exit_code == 0 + assert _work_filter(work_profile)["allow"] == ["api.anthropic.com"] + + +def test_filter_status_with_profile_shows_profile_settings(work_profile: Path) -> None: + (work_profile / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "deny", "allow": [], "deny": ["example.com"]}), + ) + result = runner.invoke(app, ["proxy", "filter", "status", "--profile", "work"]) + assert result.exit_code == 0 + assert "deny" in result.output + assert "example.com" in result.output + + +def test_filter_with_unknown_profile_errors(work_profile: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow", "--profile", "nope"]) + assert result.exit_code == 1 diff --git a/tests/test_proxy_permissions.py b/tests/test_proxy_permissions.py index b6cf26b..7bda914 100644 --- a/tests/test_proxy_permissions.py +++ b/tests/test_proxy_permissions.py @@ -20,6 +20,8 @@ def test_update_container_mapping_success(tmp_path: Path) -> None: "abc123", "vibepod-claude-test", "claude", + policy_id="1" * 32, + profile="work", ) assert updated is True @@ -27,6 +29,8 @@ def test_update_container_mapping_success(tmp_path: Path) -> None: assert data["172.18.0.3"]["container_id"] == "abc123" assert data["172.18.0.3"]["container_name"] == "vibepod-claude-test" assert data["172.18.0.3"]["agent"] == "claude" + assert data["172.18.0.3"]["policy_id"] == "1" * 32 + assert data["172.18.0.3"]["profile"] == "work" def test_update_container_mapping_permission_error_returns_false( diff --git a/tests/test_run.py b/tests/test_run.py index fbbbaa2..ebe5330 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2596,3 +2596,134 @@ def test_run_dsh_prints_preview_warning_and_web_ui_url(monkeypatch, tmp_path: Pa assert any("developer preview" in msg for msg in warnings) assert any("http://127.0.0.1:3080" in msg for msg in successes) + + +def test_run_materializes_source_policy_and_wires_identity(monkeypatch, tmp_path: Path) -> None: + """A proxied run receives a launch policy, identity, labels, and source binding.""" + captured: dict[str, dict] = {} + + class _ProxyDockerManager: + def ensure_network(self, name: str) -> None: + pass + + def networks_with_running_containers(self) -> list[str]: + return [] + + def pull_image(self, image: str, auto_clean: bool = False) -> None: + pass + + def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] + captured["proxy"] = kwargs + + def clean_untagged_images(self) -> int: + return 0 + + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + captured["run"] = kwargs + return type( + "_Container", + (), + { + "name": "vibepod-claude-test", + "id": "abc123", + "status": "running", + "attrs": { + "NetworkSettings": { + "Networks": {"vibepod-network": {"IPAddress": "172.18.0.3"}}, + }, + }, + "reload": lambda self: None, + "labels": {}, + "logs": lambda self, **kw: b"", + }, + )() + + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:0.1", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(run_cmd, "get_config", lambda: config) + monkeypatch.setattr(run_cmd, "DockerManager", _ProxyDockerManager) + monkeypatch.setattr("vibepod.core.proxy_identity.new_policy_id", lambda: "1" * 32) + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + + assert captured["proxy"]["policy_schema"] == "2" + assert captured["run"]["env"]["HTTP_PROXY"].startswith(f"http://vp-{'1' * 32}:") + assert captured["run"]["env"]["HTTPS_PROXY"] == captured["run"]["env"]["HTTP_PROXY"] + assert captured["run"]["extra_labels"]["vibepod.profile"] == "default" + assert captured["run"]["extra_labels"]["vibepod.proxy-policy"] == "1" * 32 + record = json.loads( + (tmp_path / "proxy" / "policies" / "containers" / f"{'1' * 32}.json").read_text(), + ) + assert record["profile"] == "default" + mapping = json.loads((tmp_path / "proxy" / "containers.json").read_text()) + assert mapping["172.18.0.3"]["policy_id"] == "1" * 32 + assert mapping["172.18.0.3"]["profile"] == "default" + + +def test_run_recreates_proxy_when_image_updated(monkeypatch, tmp_path: Path) -> None: + """A pulled newer proxy image must replace the running pre-update container.""" + events: list[str] = [] + + class _OldProxyContainer: + def remove(self, force: bool = False) -> None: + events.append("proxy.remove") + + class _UpdatingDockerManager: + def ensure_network(self, name: str) -> None: + pass + + def networks_with_running_containers(self) -> list[str]: + return [] + + def pull_image(self, image: str, auto_clean: bool = False) -> None: + pass + + def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: + events.append("pull_if_newer") + return True + + def require_proxy_policy_schema(self, image: object, required: str = "2") -> None: + pass + + def clean_untagged_images(self) -> int: + return 0 + + def find_proxy(self) -> object: + return _OldProxyContainer() + + def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] + events.append("ensure_proxy") + + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + return type( + "_Container", + (), + { + "name": "vibepod-claude-test", + "id": "abc123", + "status": "running", + "attrs": {"NetworkSettings": {"Networks": {}}}, + "reload": lambda self: None, + "labels": {}, + "logs": lambda self, **kw: b"", + }, + )() + + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:latest", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(run_cmd, "get_config", lambda: config) + monkeypatch.setattr(run_cmd, "DockerManager", _UpdatingDockerManager) + monkeypatch.setattr(run_cmd, "_materialize_launch_policy", lambda *args, **kwargs: "3" * 32) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + + assert events == ["pull_if_newer", "proxy.remove", "ensure_proxy"] diff --git a/tests/test_task_cmd.py b/tests/test_task_cmd.py index d20e1c0..3f7eb24 100644 --- a/tests/test_task_cmd.py +++ b/tests/test_task_cmd.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -80,6 +81,9 @@ def pull_image(self, image: str, auto_clean: bool = False) -> None: def ensure_proxy(self, **kwargs) -> None: pass + def clean_untagged_images(self) -> int: + return 0 + def resolve_launch_command(self, image: str, command: list[str] | None) -> list[str]: # Tests that exercise init commands or a None command can mock this; # the happy-path tests never hit it. @@ -1203,3 +1207,36 @@ def test_task_create_dsh_keeps_user_extra_ports(monkeypatch, tmp_path, tmp_task_ assert ports is not None assert all(key.split("/", 1)[0] != "3081" for key in ports) assert any(key.split("/", 1)[0] == "9999" for key in ports) + + +def test_task_create_materializes_source_policy_and_wires_identity( + monkeypatch, + tmp_path, + tmp_task_store, +) -> None: + """A proxied task receives a launch policy, identity, labels, and schema check.""" + stub = _CapturingDockerManager() + proxy_calls: list[dict] = [] + stub.ensure_proxy = lambda **kwargs: proxy_calls.append(kwargs) # type: ignore[method-assign] + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:0.1", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(task_cmd, "get_config", lambda: config) + monkeypatch.setattr(task_cmd, "DockerManager", lambda: stub) + monkeypatch.setattr("vibepod.core.proxy_identity.new_policy_id", lambda: "2" * 32) + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) + + task_cmd.task_create(agent="claude", prompt="hi", workspace=tmp_path) + + assert proxy_calls[0]["policy_schema"] == "2" + assert stub.run_kwargs is not None + assert stub.run_kwargs["env"]["HTTP_PROXY"].startswith(f"http://vp-{'2' * 32}:") + assert stub.run_kwargs["extra_labels"]["vibepod.profile"] == "default" + assert stub.run_kwargs["extra_labels"]["vibepod.proxy-policy"] == "2" * 32 + record = json.loads( + (tmp_path / "proxy" / "policies" / "containers" / f"{'2' * 32}.json").read_text(), + ) + assert record["profile"] == "default"