Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -772,8 +772,12 @@ The user decides, for example to run the tool from their own terminal.

### Notes

- The gateway's `docker`-backend discovery on macOS reads the current `docker context`.
- The gateway's `docker`-backend discovery on macOS reads the current `docker context` from the CLI's own files (`~/.docker/config.json` and the context store), so it works with a `docker` CLI from Homebrew or Docker Desktop, neither of which is on the hook's fixed `PATH`.
Colima's socket lives under `~/.colima/<profile>/docker.sock`, and a Colima context already set as active is picked up the same way as Docker Desktop's, with no Colima-specific configuration.
A socket file is used only when something answers on it: a Docker Desktop socket left behind after Desktop stopped is skipped rather than served.
Discovery runs once, when the gateway starts, so start Docker Desktop or Colima before the session; a runtime started later is not picked up until the gateway is restarted, and until then the log says `no podman or docker backend found; nothing to serve`.
With `DOCKER_CONFIG` set, the CLI's files are read from that directory instead of `~/.docker`, as the CLI does.
If the gateway log names a backend you did not expect, check `docker context ls` from your own terminal and restart the gateway after switching.
- The gateway's `podman`-backend discovery on Linux reads `$XDG_RUNTIME_DIR/podman/podman.sock` directly, which is rootless Podman's default socket location, again with no separate configuration.
- Do **not** widen `allowRead` to `~/.docker/**`.
The directory holds auth tokens and saved contexts, and the whole point of the framework's `Read(~/.docker/**)` denial is to keep those out of the agent's reach.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:b935cabfec544dd6"
FRAMEWORK_FINGERPRINT = "sha256:2c920f02308a2f20"
2 changes: 1 addition & 1 deletion tools/container-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The contract (what / why) is in [`tool.md`](tool.md); this file is the how-to.

- **Runtime:** Python 3.11+ stdlib only; run with `python3 -m container_gateway` from `src/`, or `uv run --directory tools/container-gateway container-gateway`.
- **CLIs:** `podman` and/or `docker` on the host (each optional); on macOS a running Podman machine or Docker Desktop.
- **Credentials / auth:** None. The gateway never reads the machine ssh identity, `~/.docker/config.json` or `~/.config/containers/auth.json`; pulls are anonymous.
- **Credentials / auth:** None. The gateway never reads the machine ssh identity or `~/.config/containers/auth.json`, and takes only the `currentContext` name from `~/.docker/config.json` (never `auths`, `credsStore` or `credHelpers`); pulls are anonymous.
- **Network:** None of its own. Connects only to the local daemon unix socket and, once at start, probes the egress gateway on loopback.
- **Optional:** the `dev` dependency group (pytest, ruff, mypy); a running egress gateway for the proxy-injection mode.

Expand Down
54 changes: 51 additions & 3 deletions tools/container-gateway/src/container_gateway/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

from __future__ import annotations

import hashlib
import json
import socket
import stat
import subprocess
from collections.abc import Callable, Mapping
from dataclasses import dataclass
Expand Down Expand Up @@ -71,19 +75,63 @@ def _podman_socket(platform: str, env: Mapping[str, str], run: Runner) -> list[P
return [Path(runtime_dir, "podman", "podman.sock")] if runtime_dir else []


def _docker_context_socket(home: str, docker_config: str | None = None) -> Path | None:
"""The Unix socket of the docker CLI's current context, read from the CLI's own files.

Not by running `docker context inspect`: the hook pins PATH to the system
directories, where neither a Homebrew nor a Docker Desktop CLI lives, and
DOCKER_HOST in a session's environment names the gateway's own socket.
The files live in `$DOCKER_CONFIG` when it is set, as for the CLI, else `~/.docker`.
The context store keeps each context's metadata under the SHA-256 of its
name. The "default" context has no metadata: it is DOCKER_HOST.
"""
config_dir = Path(docker_config) if docker_config else Path(home, ".docker")
try:
name = json.loads((config_dir / "config.json").read_text(encoding="utf-8")).get("currentContext")
except (OSError, ValueError, AttributeError):
return None
if not isinstance(name, str) or not name or name == "default":
return None
meta = config_dir / "contexts" / "meta" / hashlib.sha256(name.encode()).hexdigest() / "meta.json"
try:
host = json.loads(meta.read_text(encoding="utf-8"))["Endpoints"]["docker"]["Host"]
except (OSError, ValueError, KeyError, TypeError):
return None
if isinstance(host, str) and host.startswith("unix://"):
return Path(host[len("unix://") :])
return None


def _docker_socket(platform: str, env: Mapping[str, str], run: Runner) -> list[Path]:
if platform == "Darwin":
candidates = []
out = run(["docker", "context", "inspect", "--format", '{{(index .Endpoints "docker").Host}}'])
if out and out.startswith("unix://"):
candidates.append(Path(out[len("unix://") :]))
home = env.get("HOME")
if home:
context = _docker_context_socket(home, env.get("DOCKER_CONFIG"))
if context is not None:
candidates.append(context)
candidates.append(Path(home, ".docker", "run", "docker.sock"))
return candidates
return [Path("/var/run/docker.sock")]


def socket_answers(path: Path, timeout: float = 1.0) -> bool:
"""Whether something accepts connections on the Unix socket at `path`.

A daemon that stopped can leave its socket file behind: it exists, but
every connection is refused, and serving it would fail every request.
"""
try:
if not stat.S_ISSOCK(path.stat().st_mode):
return False
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(timeout)
probe.connect(str(path))
except OSError:
return False
return True


def discover(
platform: str,
env: Mapping[str, str],
Expand Down
2 changes: 1 addition & 1 deletion tools/container-gateway/src/container_gateway/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ async def run(

try:
found = discover_fn(
platform, os.environ, _backends.default_runner, lambda x: x.exists(), frozenset(cfg.backends)
platform, os.environ, _backends.default_runner, _backends.socket_answers, frozenset(cfg.backends)
)
if not found:
log.info("no podman or docker backend found; nothing to serve")
Expand Down
117 changes: 104 additions & 13 deletions tools/container-gateway/tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@

from __future__ import annotations

import hashlib
import json
import socket
import tempfile
from pathlib import Path

from container_gateway.backends import Backend, discover, egress_proxy_env, host_alias
import pytest

from container_gateway.backends import Backend, discover, egress_proxy_env, host_alias, socket_answers

ALL = frozenset({"podman", "docker"})

Expand Down Expand Up @@ -67,18 +73,103 @@ def test_rootless_podman_on_linux() -> None:
assert found == [Backend("podman", sock, "10.88.0.1")]


def test_docker_desktop_context_then_fallback() -> None:
ctx_sock = Path("/Users/a/.docker/run/docker.sock")

def run(argv: list[str]) -> str | None:
if argv[:3] == ["docker", "context", "inspect"]:
return f"unix://{ctx_sock}"
return None

found = discover("Darwin", {"HOME": "/Users/a"}, run, lambda p: p == ctx_sock, ALL)
assert found == [Backend("docker", ctx_sock, "host.docker.internal")]
fallback = discover("Darwin", {"HOME": "/Users/a"}, lambda a: None, lambda p: p == ctx_sock, ALL)
assert fallback == [Backend("docker", ctx_sock, "host.docker.internal")]
def _docker_context(home: Path, current: str | None, contexts: dict[str, str]) -> None:
"""Lay out the docker CLI's config and context store the way the CLI writes them."""
config_dir = home / ".docker"
config_dir.mkdir(parents=True, exist_ok=True)
config = {} if current is None else {"currentContext": current}
(config_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
for name, host in contexts.items():
meta = config_dir / "contexts" / "meta" / hashlib.sha256(name.encode()).hexdigest()
meta.mkdir(parents=True)
body = {"Name": name, "Metadata": {}, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": False}}}
(meta / "meta.json").write_text(json.dumps(body), encoding="utf-8")


def _no_docker_cli(argv: list[str]) -> str | None:
assert argv[0] != "docker", "discovery must not depend on a docker CLI on PATH"
return None


def test_docker_current_context_read_from_the_context_store(tmp_path: Path) -> None:
# Colima, or any non-default context: its socket is not Docker Desktop's.
colima = Path("/Users/a/.colima/default/docker.sock")
_docker_context(
tmp_path,
"colima",
{"colima": f"unix://{colima}", "desktop-linux": f"unix://{tmp_path}/.docker/run/docker.sock"},
)
found = discover("Darwin", {"HOME": str(tmp_path)}, _no_docker_cli, lambda p: p == colima, ALL)
assert found == [Backend("docker", colima, "host.docker.internal")]


def test_docker_current_context_read_from_docker_config(tmp_path: Path) -> None:
# DOCKER_CONFIG moves the CLI's config.json and context store out of ~/.docker.
colima = Path("/Users/a/.colima/default/docker.sock")
_docker_context(tmp_path / "elsewhere", "colima", {"colima": f"unix://{colima}"})
env = {"HOME": str(tmp_path), "DOCKER_CONFIG": str(tmp_path / "elsewhere" / ".docker")}
found = discover("Darwin", env, _no_docker_cli, lambda p: p == colima, ALL)
assert found == [Backend("docker", colima, "host.docker.internal")]


def test_docker_context_socket_that_does_not_answer_falls_back(tmp_path: Path) -> None:
desktop = tmp_path / ".docker" / "run" / "docker.sock"
_docker_context(tmp_path, "colima", {"colima": "unix:///Users/a/.colima/default/docker.sock"})
found = discover("Darwin", {"HOME": str(tmp_path)}, _no_docker_cli, lambda p: p == desktop, ALL)
assert found == [Backend("docker", desktop, "host.docker.internal")]


@pytest.mark.parametrize(
"current, contexts",
[
("default", {}), # the default context is DOCKER_HOST, which names the gateway in a session
(None, {}), # no currentContext key
("gone", {}), # current context without metadata
("remote", {"remote": "tcp://10.0.0.1:2376"}), # not a Unix socket
],
)
def test_docker_without_a_usable_context_uses_the_desktop_socket(
tmp_path: Path, current: str | None, contexts: dict[str, str]
) -> None:
desktop = tmp_path / ".docker" / "run" / "docker.sock"
_docker_context(tmp_path, current, contexts)
found = discover("Darwin", {"HOME": str(tmp_path)}, _no_docker_cli, lambda p: p == desktop, ALL)
assert found == [Backend("docker", desktop, "host.docker.internal")]


def test_docker_config_that_is_not_json_uses_the_desktop_socket(tmp_path: Path) -> None:
desktop = tmp_path / ".docker" / "run" / "docker.sock"
(tmp_path / ".docker").mkdir()
(tmp_path / ".docker" / "config.json").write_text("{not json", encoding="utf-8")
found = discover("Darwin", {"HOME": str(tmp_path)}, _no_docker_cli, lambda p: p == desktop, ALL)
assert found == [Backend("docker", desktop, "host.docker.internal")]


def test_socket_answers_rejects_what_is_not_a_socket(tmp_path: Path) -> None:
regular = tmp_path / "file.sock"
regular.write_text("", encoding="utf-8")
assert not socket_answers(regular)
assert not socket_answers(tmp_path / "missing.sock")


def test_socket_answers_only_while_something_listens() -> None:
# A short directory: pytest's tmp_path overflows the AF_UNIX path limit on macOS.
with tempfile.TemporaryDirectory(prefix="cg") as d:
live = Path(d, "live.sock")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
try:
server.bind(str(live))
except PermissionError:
pytest.skip("sandbox denies unix bind; runs in CI")
# Unreachable: pytest.skip() raises. The bare re-raise tells a
# static analyser the except branch never falls through.
raise
server.listen(1)
assert socket_answers(live)

# The listener is gone but its socket file stays, as after a daemon stops.
assert live.exists()
assert not socket_answers(live)


def test_dockerd_on_linux_and_wanted_filter() -> None:
Expand Down
9 changes: 5 additions & 4 deletions tools/spec-loop/specs/container-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Discovery, in order, all optional:
|---|---|---|
| Podman machine (macOS) | `podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}'` of the default machine, run by the gateway outside the sandbox | podman.sock (libpod + compat) and docker.sock (compat) when no docker backend exists |
| Rootless podman (Linux) | `$XDG_RUNTIME_DIR/podman/podman.sock`; when absent and `podman` is installed, `podman system service --time=0` is started as a child | same as above |
| Docker Desktop (macOS) | `docker context inspect --format '{{(index .Endpoints "docker").Host}}'`, else `~/.docker/run/docker.sock` | docker.sock (compat) |
| Docker Desktop, Colima or another docker context (macOS) | the current context's Unix endpoint, read from `~/.docker/config.json` and the context store (`~/.docker/contexts/meta/<sha256 of the name>/meta.json`) rather than by running `docker`, which is not on the hook's PATH; else `~/.docker/run/docker.sock` | docker.sock (compat) |
| dockerd (Linux) | `/var/run/docker.sock` | docker.sock (compat) |

The podman CLI needs the libpod API and therefore only ever talks to a
Expand All @@ -294,9 +294,10 @@ gateway logs one line and exits 0; nothing else in the setup depends on
it running.

The gateway never reads the Podman machine's ssh identity, never uses
the `ssh://` connection, and never reads `~/.docker/config.json` or
`~/.config/containers/auth.json`: registry credentials stay outside the
sandbox and pulls are anonymous.
the `ssh://` connection, and never reads `~/.config/containers/auth.json`.
From `~/.docker/config.json` it takes the `currentContext` name and
nothing else, never `auths`, `credsStore` or `credHelpers`: registry
credentials stay outside the sandbox and pulls are anonymous.

### Request policy

Expand Down
Loading