Architecture¶
The system consists of three components running as containers or processes:
-
llama-server(host process): Runsllama.cpp'sllama-servernatively on the host. Provides OpenAI-compatible API endpoints for one or more local LLM models. Each model is configured via<project>/.pi-container/agent/models.json(seeded per-project from thepi-coding-agent/default/template on first run). Unlike the proxy, a llama-server is a shared host resource: it is keyed by provider name + a fingerprint of itsserverCustomParameters, so projects with an identically-configured provider share one process (no double-loading a model), while a same-named provider with different parameters gets its own server (never a silent wrong-model attach). -
pi-coding-agent-proxy(container): A transparent proxy container based on Debian with mitmproxy. It intercepts the pi container's HTTP/HTTPS/DNS traffic; a self-signed CA certificate is installed into the pi container image so HTTPS can be decrypted. Each workspace gets its own proxy (and its own isolated network), named by a hash of the project path; its mitmweb web UI is published on an auto-assigned host port, logged at startup. Two addons run on the intercepted traffic: an allowlist (blocks non-allowlisted hosts) and a token_replacer (redacts API keys, bearer tokens, cookies, JWTs). Non-HTTP protocols are denied by default — the agent cannot reach the internet except through the proxy, and only over protocols that are either inspected (HTTP/HTTPS/DNS) or explicitly opted in (see Proxy egress policy). -
pi-coding-agent(container): The main agent container. It runs on a multi-stage build fromnode:26.3.1-trixie-slim, with Python 3.14.6 compiled from source anduvfor dependency management. The agent connects only to an internalisolated-netnetwork, with its default route and DNS pointed at the proxy so all traffic is forced through it. How it reaches the hostllama-serverdepends on the runtime: with Applecontainer(which shares an L2 bridge with the host) a host-sidesocatre-exposes llama-server on the bridge; withpodman/docker(which run in a VM on macOS) the proxy reaches the host loopback viahost.containers.internal— no socat needed.
Network topology¶
flowchart TB
agent_eth["<b>pi-coding-agent</b><br/><br/>eth0<br/>isolated-net"]
subgraph net["isolated-net<br/>(--internal, no gateway)"]
direction TB
end
style net fill:none,text-align:left
subgraph proxy["<b>pi-coding-agent-proxy</b>"]
direction TB
mitm["mitmproxy<br/>transparent :8080<br/>allowlist + token_replacer"]
dns["mitmproxy<br/>DNS :5353<br/>resolves 'llama' → eth1 IP"]
end
style proxy fill:none,text-align:left
subgraph host["<b>Host</b><br/>llama-server (host process)"]
direction TB
end
style host fill:none,text-align:left
eth1["eth1<br/>isolated-net"]
eth0["eth0<br/>upstream network<br/>internet + MASQUERADE"]
%% L3 routing: agent → isolated-net → proxy eth1
agent_eth -.->|L3 routed via proxy eth1 IP| eth1
agent_eth --- net
net --- eth1
%% Proxy internal: L4 interception & forwarding
eth1 -->|REDIRECT<br/>80/443 → :8080| mitm
eth1 -->|REDIRECT<br/>53 → :5353| dns
eth1 -->|DNAT llama:<cp> → llama_net| llama_net
mitm -->|egress| eth0
dns -->|egress| eth0
eth1 -.->|opt-in FORWARD → eth0 → MASQUERADE| eth0
eth0 -->|internet| Out["Internet"]
subgraph llama_net["llama-server reachability(per-runtime network component)"]
direction TB
socat["Apple container<br/>host bridge + socat"]
podman_net["podman<br/>host.containers.internal<br/>(gvproxy)"]
docker_net["docker<br/>host.docker.internal<br/>(gvproxy)"]
end
style llama_net fill:none,text-align:left
llama_net --> host
The proxy's iptables rules enforce a default-deny forward policy — HTTP, HTTPS, and DNS from the agent are intercepted by mitmproxy (via REDIRECT to ports 8080/5353, bypassing the FORWARD chain entirely). Every other protocol is denied by default; opt-in forwarding (per-project .pi-container/config.yaml under egress.allow — ssh/smtp/git/ntp + custom TCP/UDP ports) uses plain NAT and is not inspected by mitmproxy. The isolated-net is created with --internal (no external gateway), so the agent has no route to the internet except the default route and DNS pointed at the proxy.
By default the whole stack is IPv4-only: the isolated network has no IPv6 subnet and both containers disable IPv6 (via sysctl) so no agent traffic can escape the transparent-proxy REDIRECT over v6. Set network.ipv6: true in the project's .pi-container/config.yaml to create the network with an IPv6 subnet (--ipv6 for podman/docker, --subnet-v6 for Apple container), mirror the proxy's REDIRECT/NAT/FORWARD rules in ip6tables, and give the agent an IPv6 default route through the proxy. This requires the container runtime and host to actually have IPv6 egress. Apple container's vmnet networking NATs IPv4 only — a container gets no global IPv6 address or v6 route (verified) — so enabling it there logs a warning and v6 connections still fail. It is intended for Linux hosts running podman/docker with working host IPv6; leave it false on macOS.
Proxy egress policy¶
Only HTTP, HTTPS and DNS are intercepted by mitmproxy (and subject to the
allowlist / token redaction). Every other protocol is denied by default —
the proxy's iptables FORWARD chain policy is DROP. The llama-server API
is permitted explicitly. The egress policy is per-project: to let the agent
use another protocol (uninspected, plain NAT), opt it in under egress.allow in
this workspace's .pi-container/config.yaml (seeded deny-all on first run):
# .pi-container/config.yaml
egress:
allow:
ssh: false # TCP 22 (e.g. git over SSH)
smtp: false # TCP 25, 465, 587
git: false # TCP 9418 (git://)
ntp: false # UDP 123
tcp_ports: [] # arbitrary extra TCP ports, e.g. [2222, 8443]
udp_ports: [] # arbitrary extra UDP ports, e.g. [51820]
run.py reads this file and passes the corresponding PROXY_ALLOW_* values into
that project's proxy container, whose entrypoint opens the matching FORWARD rules.
Note: protocols opted in here are forwarded uninspected — mitmproxy and the allowlist do not see them.
Project Structure¶
.
├── build.sh # Build script (delegates to src/build.py)
├── run.sh # Run script (delegates to src/run.py)
├── .env.example # Example environment configuration
├── pyproject.toml # uv project: dependencies, dependency-groups, ruff/pytest config
├── uv.lock # Pinned dependency lockfile (committed)
├── .gitignore
│
├── src/ # Python source for build and run utilities
│ ├── build.py # Builds proxy and agent container images
│ ├── run.py # Orchestration entrypoint: validation + main() lifecycle
│ ├── config.py # Shared constants (paths, env, logging) — no side effects
│ ├── runtimes.py # ContainerRuntime classes (Apple container / podman / docker)
│ ├── models.py # Model + ServerConfig/ModelConfig (download, checksum)
│ ├── server.py # Server: llama-server lifecycle (refcount, socat bridge)
│ ├── network.py # ContainerNetworkManager: proxy + isolated network lifecycle
│ ├── util.py # Shared utilities (env loading, validation, signals)
│ └── tests/ # Pytest test suite
│ ├── conftest.py
│ ├── test_build.py
│ ├── test_models.py
│ ├── test_network.py
│ ├── test_runtimes.py
│ ├── test_server.py
│ └── test_util.py
│
├── pi-coding-agent/ # Main agent container
│ ├── Containerfile # Multi-stage build (builder + runner)
│ ├── entrypoint.sh # Container entrypoint (default route via proxy, git config, uv venv)
│ └── default/ # Template for <project>/.pi-container (seeded on first run)
│ ├── agent/ # → .pi-container/agent
│ │ ├── models.json # LLM provider/server configuration
│ │ ├── AGENTS.md # Agent instructions
│ │ ├── config.json
│ │ ├── settings.json
│ │ └── .pi_ignore
│ ├── chat-templates/ # Jinja chat templates loaded by llama-server
│ ├── config.yaml # Orchestration: resources, tmpfs, flow_export, egress
│ ├── allowlist.yaml # Generic hostname allowlist (pypi/npm/github/apt)
│ └── token_replacer.yaml # Generic token-redaction config
│
├── pi-coding-agent-proxy/ # Transparent proxy container
│ ├── Containerfile # mitmproxy + addon scripts/configs + pyyaml
│ ├── entrypoint.sh # iptables (redirect/DNAT, default-deny FORWARD) + mitmweb with addons
│ └── addons/
│ ├── allowlist/ # Hostname/IP allowlist addon (active)
│ └── token_replacer/ # Token redaction addon (API keys, Bearer tokens, cookies, JWTs) (active)
│
├── llama-server/ # LLM server components
│ ├── models/ # Downloaded GGUF model files (gitignored)
│ ├── chat-templates/ # Jinja chat templates for models
│ ├── logs/ # llama-server log files (gitignored)
│ └── .locks/ # Process lock files (gitignored)
│ └── local-gemma/ # Per-model lock directory
│ ├── .llama_server.pid
│ └── .llama_server_refcount
│
├── pi-coding-agent/setups/ # Model-specific setup directories
│ └── gemma-4-26b-a4b-it-qat-GGUF/ # Notes and config for specific model setups
│
└── .pi-container/ # Per-project config (this repo's own; each workspace gets its own)
├── agent/ # Agent launch config (models.json, sessions, …)
├── chat-templates/ # Jinja chat templates loaded by llama-server (per model)
├── config.yaml # Orchestration: resource limits, tmpfs, flow-export, egress
├── token_replacer.yaml # Token redaction rules (mounted into this project's proxy)
├── allowlist.yaml # Hostname allowlist (mounted into this project's proxy)
└── exports/ # Captured flow history for this project (gitignored)