agent-containers 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_containers/__init__.py +1 -0
- agent_containers/__main__.py +6 -0
- agent_containers/_assets/agent-images/adal/Dockerfile +103 -0
- agent_containers/_assets/agent-images/adal/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/adal/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/adal/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/adal/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/adal/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/agent-gateway/Dockerfile +50 -0
- agent_containers/_assets/agent-images/agent-gateway/entrypoint.sh +28 -0
- agent_containers/_assets/agent-images/agent-gateway/sshd_config.agent-gateway +11 -0
- agent_containers/_assets/agent-images/aider/Dockerfile +114 -0
- agent_containers/_assets/agent-images/aider/examples/egress-allowlist.txt +16 -0
- agent_containers/_assets/agent-images/aider/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/aider/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/aider/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/aider/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/claude-code/Dockerfile +171 -0
- agent_containers/_assets/agent-images/claude-code/entrypoint.sh +22 -0
- agent_containers/_assets/agent-images/claude-code/examples/egress-allowlist.txt +14 -0
- agent_containers/_assets/agent-images/claude-code/examples/settings.local-model.json +7 -0
- agent_containers/_assets/agent-images/claude-code/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/claude-code/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/claude-code/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/claude-code/plugin-marketplaces.txt +23 -0
- agent_containers/_assets/agent-images/claude-code/plugins.txt +13 -0
- agent_containers/_assets/agent-images/claude-code/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/claude-code/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/codex/Dockerfile +147 -0
- agent_containers/_assets/agent-images/codex/codex-wrapper.sh +13 -0
- agent_containers/_assets/agent-images/codex/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/codex/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/codex/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/codex/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/codex/plugin-marketplaces.txt +2 -0
- agent_containers/_assets/agent-images/codex/plugins.txt +2 -0
- agent_containers/_assets/agent-images/codex/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/codex/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/hermes/Dockerfile +122 -0
- agent_containers/_assets/agent-images/hermes/entrypoint.sh +49 -0
- agent_containers/_assets/agent-images/hermes/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/hermes/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/hermes/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/hermes/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/hermes/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/kilo-code/Dockerfile +101 -0
- agent_containers/_assets/agent-images/kilo-code/examples/egress-allowlist.txt +7 -0
- agent_containers/_assets/agent-images/kilo-code/packages-apt.txt +5 -0
- agent_containers/_assets/agent-images/kilo-code/packages-npm.txt +1 -0
- agent_containers/_assets/agent-images/kilo-code/packages-uv.txt +4 -0
- agent_containers/_assets/agent-images/kilo-code/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/opencode/Dockerfile +126 -0
- agent_containers/_assets/agent-images/opencode/examples/egress-allowlist.txt +6 -0
- agent_containers/_assets/agent-images/opencode/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/opencode/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/opencode/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/opencode/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/opencode/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/qwen-code/Dockerfile +104 -0
- agent_containers/_assets/agent-images/qwen-code/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/qwen-code/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/qwen-code/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/qwen-code/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/qwen-code/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/shared/egress-allowlist.sh +138 -0
- agent_containers/_assets/agent-images/shared/install-additional-packages.sh +79 -0
- agent_containers/_assets/agent-images/shared/install-workload-base.sh +46 -0
- agent_containers/_assets/agent-images/shared/workload-entrypoint.sh +82 -0
- agent_containers/_assets/agent-images/volume-bridge/Dockerfile +37 -0
- agent_containers/_assets/agent-images/volume-bridge/entrypoint.sh +29 -0
- agent_containers/_assets/agent-images/volume-bridge/serve.sh +66 -0
- agent_containers/assets.py +38 -0
- agent_containers/build_context.py +116 -0
- agent_containers/cli.py +212 -0
- agent_containers/creation.py +177 -0
- agent_containers/docker.py +562 -0
- agent_containers/lifecycle.py +325 -0
- agent_containers/planner.py +99 -0
- agent_containers/profile.py +377 -0
- agent_containers/shortcuts.py +159 -0
- agent_containers/state.py +154 -0
- agent_containers-0.1.0.dev0.dist-info/METADATA +195 -0
- agent_containers-0.1.0.dev0.dist-info/RECORD +86 -0
- agent_containers-0.1.0.dev0.dist-info/WHEEL +4 -0
- agent_containers-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- agent_containers-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared runtime entrypoint for first-party workloads that use Python and
|
|
3
|
+
# Node.js. The Dockerfile supplies the fixed default executable and
|
|
4
|
+
# unprivileged user as the first two positional arguments, rather than mutable
|
|
5
|
+
# environment variables, so a runtime -e option cannot alter the privilege
|
|
6
|
+
# drop target.
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
agent_command="$1"
|
|
11
|
+
agent_user="$2"
|
|
12
|
+
shift 2
|
|
13
|
+
|
|
14
|
+
if [ -n "${AGENT_LANGFUSE_PUBLIC_KEY_ENV:-}" ]; then
|
|
15
|
+
langfuse_public_name="$AGENT_LANGFUSE_PUBLIC_KEY_ENV"
|
|
16
|
+
if [ -n "${!langfuse_public_name+x}" ]; then
|
|
17
|
+
export LANGFUSE_PUBLIC_KEY="${!langfuse_public_name}"
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
if [ -n "${AGENT_LANGFUSE_SECRET_KEY_ENV:-}" ]; then
|
|
21
|
+
langfuse_secret_name="$AGENT_LANGFUSE_SECRET_KEY_ENV"
|
|
22
|
+
if [ -n "${!langfuse_secret_name+x}" ]; then
|
|
23
|
+
export LANGFUSE_SECRET_KEY="${!langfuse_secret_name}"
|
|
24
|
+
fi
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
if [ "$agent_command" = "opencode" ] && [ -n "${AGENT_OPENCODE_CONFIG_JSON:-}" ]; then
|
|
28
|
+
printf '%s\n' "$AGENT_OPENCODE_CONFIG_JSON" > /tmp/agent-containers-opencode.json
|
|
29
|
+
chmod 0644 /tmp/agent-containers-opencode.json
|
|
30
|
+
export OPENCODE_CONFIG=/tmp/agent-containers-opencode.json
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
if [ -n "${AGENT_GATEWAY_HOST:-}" ]; then
|
|
34
|
+
echo "[entrypoint] AGENT_GATEWAY_HOST=$AGENT_GATEWAY_HOST — tunneling all egress through the gateway." >&2
|
|
35
|
+
if [ -n "${AGENT_ALLOWED_EGRESS:-}" ] || [ -f /etc/agent/egress-allowlist.txt ]; then
|
|
36
|
+
echo "[entrypoint] AGENT_ALLOWED_EGRESS/egress-allowlist.txt are ignored in gateway mode — set the allowlist on the gateway container instead." >&2
|
|
37
|
+
fi
|
|
38
|
+
if [ -n "${AGENT_GATEWAY_BOOTSTRAP_ALLOW:-}" ]; then
|
|
39
|
+
echo "[entrypoint] Seeding a bootstrap allow rule for ${AGENT_GATEWAY_BOOTSTRAP_ALLOW} until the tunnel is up." >&2
|
|
40
|
+
# -m addrtype --dst-type LOCAL, not -o lo: sshuttle's own REDIRECT
|
|
41
|
+
# rules retarget outbound connections to 127.0.0.1:<its local proxy
|
|
42
|
+
# port> before this chain runs, but that retargeted packet's
|
|
43
|
+
# out-interface isn't reliably "lo" by the time OUTPUT filtering
|
|
44
|
+
# sees it. Matching the resolved destination instead reliably covers
|
|
45
|
+
# both loopback and this redirect-to-local-port case.
|
|
46
|
+
iptables -P OUTPUT DROP
|
|
47
|
+
iptables -A OUTPUT -m addrtype --dst-type LOCAL -j ACCEPT
|
|
48
|
+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
49
|
+
ip6tables -P OUTPUT DROP
|
|
50
|
+
ip6tables -A OUTPUT -m addrtype --dst-type LOCAL -j ACCEPT
|
|
51
|
+
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
52
|
+
IFS=',' read -ra BOOTSTRAP_ALLOW <<< "$AGENT_GATEWAY_BOOTSTRAP_ALLOW"
|
|
53
|
+
for addr in "${BOOTSTRAP_ALLOW[@]}"; do
|
|
54
|
+
if [[ "$addr" == *:* ]]; then
|
|
55
|
+
ip6tables -A OUTPUT -d "$addr" -j ACCEPT
|
|
56
|
+
else
|
|
57
|
+
iptables -A OUTPUT -d "$addr" -j ACCEPT
|
|
58
|
+
fi
|
|
59
|
+
done
|
|
60
|
+
fi
|
|
61
|
+
install -m 600 /etc/agent/gateway-key /tmp/gateway-key
|
|
62
|
+
SSH_CMD="ssh -i /tmp/gateway-key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/agent/gateway-known-hosts"
|
|
63
|
+
if [ -n "${AGENT_GATEWAY_ACCESS_HOSTNAME:-}" ]; then
|
|
64
|
+
echo "[entrypoint] Reaching the gateway via Cloudflare Access hostname ${AGENT_GATEWAY_ACCESS_HOSTNAME}." >&2
|
|
65
|
+
SSH_CMD="$SSH_CMD -o ProxyCommand='cloudflared access ssh --hostname ${AGENT_GATEWAY_ACCESS_HOSTNAME}'"
|
|
66
|
+
fi
|
|
67
|
+
sshuttle -r "${AGENT_GATEWAY_USER:-tunnel}@${AGENT_GATEWAY_HOST}:${AGENT_GATEWAY_PORT:-2222}" \
|
|
68
|
+
0.0.0.0/0 ::/0 --dns --daemon --pidfile=/tmp/sshuttle.pid \
|
|
69
|
+
-e "$SSH_CMD"
|
|
70
|
+
else
|
|
71
|
+
source /usr/local/lib/agent/egress-allowlist.sh
|
|
72
|
+
configure_egress_allowlist
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# `docker run <image> <args>` replaces CMD entirely rather than appending to
|
|
76
|
+
# it, so a flag-only invocation must prepend the image's default executable.
|
|
77
|
+
case "${1:-}" in
|
|
78
|
+
-*) set -- "$agent_command" "$@" ;;
|
|
79
|
+
"") set -- "$agent_command" ;;
|
|
80
|
+
esac
|
|
81
|
+
|
|
82
|
+
exec gosu "$agent_user" "$@"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
FROM debian:trixie-slim
|
|
2
|
+
|
|
3
|
+
ARG UID=1000
|
|
4
|
+
ARG GID=1000
|
|
5
|
+
|
|
6
|
+
# rclone's WebDAV server is a userspace server, so it can expose only its
|
|
7
|
+
# configured local backend without CAP_SYS_CHROOT or a root-owned source
|
|
8
|
+
# volume. apache2-utils creates the persistent bcrypt password verifier.
|
|
9
|
+
# iptables installs this image's own fixed deny-by-default INPUT/OUTPUT
|
|
10
|
+
# policy (see entrypoint.sh); gosu drops from root to the unprivileged bridge
|
|
11
|
+
# user once that policy is in place.
|
|
12
|
+
RUN apt-get update \
|
|
13
|
+
&& apt-get upgrade -y \
|
|
14
|
+
&& apt-get install -y --no-install-recommends \
|
|
15
|
+
apache2-utils \
|
|
16
|
+
rclone \
|
|
17
|
+
iptables \
|
|
18
|
+
gosu \
|
|
19
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
20
|
+
&& groupadd -g "$GID" bridge \
|
|
21
|
+
&& useradd -u "$UID" -g "$GID" --no-create-home --shell /usr/sbin/nologin bridge \
|
|
22
|
+
&& mkdir -p /exports /state \
|
|
23
|
+
&& touch /state/.keep \
|
|
24
|
+
&& chown -R bridge:bridge /state \
|
|
25
|
+
&& find / -xdev -perm /6000 -type f -exec chmod a-s {} + || true
|
|
26
|
+
|
|
27
|
+
COPY serve.sh /usr/local/lib/agent/volume-bridge-serve.sh
|
|
28
|
+
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
29
|
+
RUN chmod 755 /usr/local/bin/entrypoint.sh /usr/local/lib/agent/volume-bridge-serve.sh
|
|
30
|
+
|
|
31
|
+
# The image starts as root: entrypoint.sh installs the fixed deny-by-default
|
|
32
|
+
# network policy (needs NET_ADMIN/NET_RAW), then gosu drops to bridge before
|
|
33
|
+
# rclone ever runs. This image no longer sets USER at build time.
|
|
34
|
+
ENV HOME=/tmp
|
|
35
|
+
|
|
36
|
+
EXPOSE 16080
|
|
37
|
+
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# volume-bridge only ever serves inbound WebDAV reads from a local backend —
|
|
5
|
+
# it has no legitimate reason to make outbound connections, and the only
|
|
6
|
+
# thing that should ever be reachable inbound is that one WebDAV port — so
|
|
7
|
+
# this is a small, fixed, non-configurable deny-by-default policy rather than
|
|
8
|
+
# the shared agent-images/shared/egress-allowlist.sh: that script exists to
|
|
9
|
+
# support opening exceptions, and this image should never have any to open.
|
|
10
|
+
readonly webdav_port="${VOLUME_BRIDGE_LISTEN_ADDR:-0.0.0.0:16080}"
|
|
11
|
+
readonly webdav_tcp_port="${webdav_port##*:}"
|
|
12
|
+
|
|
13
|
+
iptables -P OUTPUT DROP
|
|
14
|
+
iptables -A OUTPUT -o lo -j ACCEPT
|
|
15
|
+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
16
|
+
ip6tables -P OUTPUT DROP
|
|
17
|
+
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
18
|
+
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
19
|
+
|
|
20
|
+
iptables -P INPUT DROP
|
|
21
|
+
iptables -A INPUT -i lo -j ACCEPT
|
|
22
|
+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
23
|
+
iptables -A INPUT -p tcp --dport "$webdav_tcp_port" -m state --state NEW -j ACCEPT
|
|
24
|
+
ip6tables -P INPUT DROP
|
|
25
|
+
ip6tables -A INPUT -i lo -j ACCEPT
|
|
26
|
+
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
27
|
+
ip6tables -A INPUT -p tcp --dport "$webdav_tcp_port" -m state --state NEW -j ACCEPT
|
|
28
|
+
|
|
29
|
+
exec gosu bridge /usr/local/lib/agent/volume-bridge-serve.sh
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
readonly state_dir=/state
|
|
5
|
+
readonly htpasswd_file="$state_dir/htpasswd"
|
|
6
|
+
readonly username="${VOLUME_BRIDGE_USERNAME:-bridge}"
|
|
7
|
+
|
|
8
|
+
if [ ! -w "$state_dir" ]; then
|
|
9
|
+
echo '[volume-bridge] /state must be an empty volume first mounted by this image, or be writable by the bridge UID/GID.' >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
if [ -n "${VOLUME_BRIDGE_PASSWORD:-}" ] && [ -n "${VOLUME_BRIDGE_PASSWORD_FILE:-}" ]; then
|
|
14
|
+
echo '[volume-bridge] Set only one of VOLUME_BRIDGE_PASSWORD or VOLUME_BRIDGE_PASSWORD_FILE.' >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
password=""
|
|
19
|
+
if [ -n "${VOLUME_BRIDGE_PASSWORD_FILE:-}" ]; then
|
|
20
|
+
password="$(cat "$VOLUME_BRIDGE_PASSWORD_FILE")"
|
|
21
|
+
elif [ -n "${VOLUME_BRIDGE_PASSWORD:-}" ]; then
|
|
22
|
+
password="$VOLUME_BRIDGE_PASSWORD"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# The password is accepted only to initialise or rotate a bcrypt verifier in
|
|
26
|
+
# the state volume. VOLUME_BRIDGE_PASSWORD_FILE is read once, right here, and
|
|
27
|
+
# never lands in the daemon-persisted container config that
|
|
28
|
+
# VOLUME_BRIDGE_PASSWORD leaves behind for `docker inspect` to read for the
|
|
29
|
+
# container's whole lifetime — prefer it when the password can be
|
|
30
|
+
# bind-mounted as a file. Do not put a reader private key or plaintext
|
|
31
|
+
# credential in the image or state volume. A Docker administrator can already
|
|
32
|
+
# read the source volumes directly, so they are the trusted provisioning
|
|
33
|
+
# boundary for this input either way.
|
|
34
|
+
if [ -n "$password" ]; then
|
|
35
|
+
case "$username" in
|
|
36
|
+
*:* | *'
|
|
37
|
+
'*)
|
|
38
|
+
echo '[volume-bridge] VOLUME_BRIDGE_USERNAME must not contain a colon or newline.' >&2
|
|
39
|
+
exit 1
|
|
40
|
+
;;
|
|
41
|
+
esac
|
|
42
|
+
umask 077
|
|
43
|
+
printf '%s\n' "$password" \
|
|
44
|
+
| htpasswd -B -C 12 -c -i "$htpasswd_file.new" "$username"
|
|
45
|
+
mv "$htpasswd_file.new" "$htpasswd_file"
|
|
46
|
+
fi
|
|
47
|
+
unset password
|
|
48
|
+
|
|
49
|
+
if [ ! -s "$htpasswd_file" ]; then
|
|
50
|
+
echo '[volume-bridge] VOLUME_BRIDGE_PASSWORD or VOLUME_BRIDGE_PASSWORD_FILE is required on first start.' >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
chmod 600 "$htpasswd_file"
|
|
54
|
+
|
|
55
|
+
# The source is the deliberately narrow /exports tree. rclone's WebDAV server
|
|
56
|
+
# authenticates against the bcrypt verifier and exposes this backend as its
|
|
57
|
+
# virtual root. The service listens on all container interfaces because Docker's
|
|
58
|
+
# host-loopback port publishing forwards to the container address, not its
|
|
59
|
+
# loopback address.
|
|
60
|
+
exec rclone --config /dev/null --cache-dir /tmp/rclone-cache serve webdav \
|
|
61
|
+
--addr "${VOLUME_BRIDGE_LISTEN_ADDR:-0.0.0.0:16080}" \
|
|
62
|
+
--htpasswd "$htpasswd_file" \
|
|
63
|
+
--realm volume-bridge \
|
|
64
|
+
--read-only \
|
|
65
|
+
--dir-cache-time "${VOLUME_BRIDGE_DIR_CACHE_TIME:-1s}" \
|
|
66
|
+
:local:/exports
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Access bundled canonical image build contexts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from importlib.resources.abc import Traversable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bundled_image_root() -> Traversable:
|
|
12
|
+
"""Return the packaged image-context directory."""
|
|
13
|
+
return files("agent_containers").joinpath("_assets", "agent-images")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def available_image_contexts() -> tuple[str, ...]:
|
|
17
|
+
"""List bundled image directories in stable order."""
|
|
18
|
+
return tuple(sorted(item.name for item in bundled_image_root().iterdir() if item.is_dir()))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def materialize_image_context(name: str, destination: Path) -> Path:
|
|
22
|
+
"""Copy one bundled context to a filesystem directory for Docker."""
|
|
23
|
+
if name not in available_image_contexts():
|
|
24
|
+
raise ValueError(f"image context is not bundled: {name}")
|
|
25
|
+
_copy_tree(bundled_image_root().joinpath(name), destination)
|
|
26
|
+
return destination
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _copy_tree(source: Traversable, destination: Path) -> None:
|
|
30
|
+
"""Copy a Traversable tree without relying on filesystem-only metadata."""
|
|
31
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
for item in source.iterdir():
|
|
33
|
+
target = destination / item.name
|
|
34
|
+
if item.is_dir():
|
|
35
|
+
_copy_tree(item, target)
|
|
36
|
+
else:
|
|
37
|
+
with item.open("rb") as source_file, target.open("wb") as target_file:
|
|
38
|
+
shutil.copyfileobj(source_file, target_file)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Prepare authoritative, profile-specific Docker build contexts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from agent_containers.assets import materialize_image_context
|
|
10
|
+
from agent_containers.profile import Profile
|
|
11
|
+
|
|
12
|
+
_PACKAGE_FILES = {
|
|
13
|
+
"apt": "packages-apt.txt",
|
|
14
|
+
"npm": "packages-npm.txt",
|
|
15
|
+
"uv_tools": "tools-uv.txt",
|
|
16
|
+
"uv_libraries": "packages-uv.txt",
|
|
17
|
+
}
|
|
18
|
+
_LANGFUSE_OPENCODE_PLUGIN = "@langfuse/opencode-observability-plugin"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BuildContexts:
|
|
23
|
+
"""Filesystem paths passed to one isolated Docker build."""
|
|
24
|
+
|
|
25
|
+
image: Path
|
|
26
|
+
shared: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def prepare_build_contexts(profile: Profile, destination: Path, profile_path: Path | None = None) -> BuildContexts:
|
|
30
|
+
"""Materialize recipes and replace optional package lists from a profile.
|
|
31
|
+
|
|
32
|
+
``destination`` must not exist. This prevents an apply operation from
|
|
33
|
+
silently merging generated files into user-maintained build contexts.
|
|
34
|
+
"""
|
|
35
|
+
destination = destination.expanduser().resolve()
|
|
36
|
+
if destination.exists():
|
|
37
|
+
raise ValueError(f"build-context destination already exists: {destination}")
|
|
38
|
+
image = materialize_image_context(profile.agent.value, destination / "image")
|
|
39
|
+
shared = materialize_image_context("shared", destination / "shared")
|
|
40
|
+
for field, filename in _PACKAGE_FILES.items():
|
|
41
|
+
packages = _packages_for_profile(profile, field)
|
|
42
|
+
(image / filename).write_text(_package_file_content(packages), encoding="utf-8")
|
|
43
|
+
_materialize_agent_plugins(profile, image)
|
|
44
|
+
_materialize_proxy_cas(profile, image / "proxy-ca.d", profile_path)
|
|
45
|
+
return BuildContexts(image=image, shared=shared)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _packages_for_profile(profile: Profile, field: str) -> list[str]:
|
|
49
|
+
"""Add only the package required by an explicitly enabled integration."""
|
|
50
|
+
packages = list(getattr(profile.packages, field))
|
|
51
|
+
required: str | None = None
|
|
52
|
+
if profile.langfuse.enabled and profile.agent.value == "opencode" and field == "npm":
|
|
53
|
+
required = _LANGFUSE_OPENCODE_PLUGIN
|
|
54
|
+
if required is not None and required not in packages:
|
|
55
|
+
packages.append(required)
|
|
56
|
+
return packages
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _materialize_agent_plugins(profile: Profile, image: Path) -> None:
|
|
60
|
+
"""Materialize marketplace/plugin selections for profile-enabled adapters."""
|
|
61
|
+
if profile.agent.value == "claude-code" and profile.langfuse.enabled:
|
|
62
|
+
_append_line(image / "plugin-marketplaces.txt", "langfuse/Claude-Observability-Plugin")
|
|
63
|
+
_append_line(image / "plugins.txt", "langfuse-observability@langfuse-observability")
|
|
64
|
+
if profile.agent.value == "codex" and profile.langfuse.enabled:
|
|
65
|
+
_append_line(image / "plugin-marketplaces.txt", "langfuse/codex-observability-plugin")
|
|
66
|
+
_append_line(image / "plugins.txt", "tracing@codex-observability-plugin")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _append_line(path: Path, value: str) -> None:
|
|
70
|
+
"""Append an integration entry once while preserving canonical comments."""
|
|
71
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
72
|
+
if value not in existing.splitlines():
|
|
73
|
+
path.write_text(existing.rstrip() + ("\n" if existing.strip() else "") + value + "\n", encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _package_file_content(packages: list[str]) -> str:
|
|
77
|
+
"""Render one package per line, preserving empty-list semantics."""
|
|
78
|
+
return "" if not packages else "\n".join(packages) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _materialize_proxy_cas(profile: Profile, destination: Path, profile_path: Path | None) -> None:
|
|
82
|
+
"""Copy profile CA inputs into the isolated image context for system trust."""
|
|
83
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
for item in destination.iterdir():
|
|
85
|
+
if item.name != ".keep":
|
|
86
|
+
if item.is_dir():
|
|
87
|
+
shutil.rmtree(item)
|
|
88
|
+
else:
|
|
89
|
+
item.unlink()
|
|
90
|
+
if profile.proxy is None or (profile.proxy.ca_file is None and profile.proxy.ca_dir is None):
|
|
91
|
+
return
|
|
92
|
+
if profile_path is None:
|
|
93
|
+
raise ValueError("profile_path is required when a proxy CA input is configured")
|
|
94
|
+
base = profile_path.expanduser().resolve().parent
|
|
95
|
+
if profile.proxy.ca_file is not None:
|
|
96
|
+
source = (base / profile.proxy.ca_file).resolve()
|
|
97
|
+
if not source.is_file():
|
|
98
|
+
raise ValueError(f"proxy CA file does not exist: {source}")
|
|
99
|
+
_copy_certificate(source, destination / "profile-ca.pem")
|
|
100
|
+
if profile.proxy.ca_dir is not None:
|
|
101
|
+
source = (base / profile.proxy.ca_dir).resolve()
|
|
102
|
+
if not source.is_dir():
|
|
103
|
+
raise ValueError(f"proxy CA directory does not exist: {source}")
|
|
104
|
+
files = sorted(item for item in source.iterdir() if item.is_file())
|
|
105
|
+
if not files:
|
|
106
|
+
raise ValueError(f"proxy CA directory is empty: {source}")
|
|
107
|
+
for index, item in enumerate(files):
|
|
108
|
+
_copy_certificate(item, destination / f"profile-ca-{index}-{item.name}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _copy_certificate(source: Path, destination: Path) -> None:
|
|
112
|
+
"""Copy one non-secret certificate input while rejecting private keys."""
|
|
113
|
+
contents = source.read_bytes()
|
|
114
|
+
if b"PRIVATE KEY" in contents:
|
|
115
|
+
raise ValueError(f"proxy CA input must not contain a private key: {source}")
|
|
116
|
+
destination.write_bytes(contents)
|
agent_containers/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""CLI entry point. Runtime operations are added as verified components mature."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tomllib
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
from typer.core import TyperGroup
|
|
13
|
+
|
|
14
|
+
from agent_containers.creation import ProfileCreationError, prompt_profile, write_profile
|
|
15
|
+
from agent_containers.docker import DockerCommandError
|
|
16
|
+
from agent_containers.lifecycle import (
|
|
17
|
+
DoctorReport,
|
|
18
|
+
LifecycleError,
|
|
19
|
+
apply_profile,
|
|
20
|
+
doctor_profile,
|
|
21
|
+
rollback_preview,
|
|
22
|
+
rollback_profile,
|
|
23
|
+
)
|
|
24
|
+
from agent_containers.planner import build_plan
|
|
25
|
+
from agent_containers.profile import load_profile
|
|
26
|
+
from agent_containers.shortcuts import ShortcutError, default_shortcuts_path
|
|
27
|
+
from agent_containers.state import load_state
|
|
28
|
+
|
|
29
|
+
_LOGO = """░█▀█░█▀▀░█▀▀░█▀█░▀█▀░░░█▀▀░█▀█░█▀█░▀█▀░█▀█░▀█▀░█▀█░█▀▀░█▀▄░█▀▀
|
|
30
|
+
░█▀█░█░█░█▀▀░█░█░░█░░░░█░░░█░█░█░█░░█░░█▀█░░█░░█░█░█▀▀░█▀▄░▀▀█
|
|
31
|
+
░▀░▀░▀▀▀░▀▀▀░▀░▀░░▀░░░░▀▀▀░▀▀▀░▀░▀░░▀░░▀░▀░▀▀▀░▀░▀░▀▀▀░▀░▀░▀▀▀"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogoGroup(TyperGroup):
|
|
35
|
+
"""Render the required banner before Click dispatches any CLI invocation."""
|
|
36
|
+
|
|
37
|
+
def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
|
|
38
|
+
"""Print once for normal commands, help, version and parser errors."""
|
|
39
|
+
typer.echo(_LOGO)
|
|
40
|
+
return super().parse_args(ctx, args)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
help=(
|
|
45
|
+
"Onboarding and maintenance for hardened agent containers. "
|
|
46
|
+
"Profiles can be validated, planned, and applied with direct Docker commands."
|
|
47
|
+
),
|
|
48
|
+
add_completion=False,
|
|
49
|
+
pretty_exceptions_enable=False,
|
|
50
|
+
cls=LogoGroup,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def show_version(value: bool) -> None:
|
|
55
|
+
"""Read installed distribution metadata, avoiding a second version constant."""
|
|
56
|
+
if value:
|
|
57
|
+
typer.echo(f"agent-containers {version('agent-containers')}")
|
|
58
|
+
raise typer.Exit()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.callback(invoke_without_command=True)
|
|
62
|
+
def main(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
version_flag: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option("--version", callback=show_version, is_eager=True, help="Show version and exit."),
|
|
67
|
+
] = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Display help without probing Docker or changing user configuration."""
|
|
70
|
+
if ctx.invoked_subcommand is None:
|
|
71
|
+
typer.echo(ctx.get_help())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def validate(
|
|
76
|
+
profile: Annotated[
|
|
77
|
+
Path,
|
|
78
|
+
typer.Argument(exists=True, dir_okay=False, readable=True, help="TOML profile to validate."),
|
|
79
|
+
],
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Validate a TOML profile without contacting Docker or changing state."""
|
|
82
|
+
try:
|
|
83
|
+
document = load_profile(profile)
|
|
84
|
+
except (OSError, tomllib.TOMLDecodeError, ValidationError) as exc:
|
|
85
|
+
raise typer.BadParameter(str(exc), param_hint="profile") from exc
|
|
86
|
+
typer.echo(f"Valid profile: {document.name} ({document.agent.value})")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def create(
|
|
91
|
+
profile: Annotated[
|
|
92
|
+
Path,
|
|
93
|
+
typer.Argument(dir_okay=False, help="New TOML profile path to create interactively."),
|
|
94
|
+
],
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Interactively create a validated TOML profile without contacting Docker."""
|
|
97
|
+
try:
|
|
98
|
+
document = prompt_profile(profile)
|
|
99
|
+
write_profile(profile, document)
|
|
100
|
+
except (OSError, ProfileCreationError, ValidationError, ValueError) as exc:
|
|
101
|
+
raise typer.BadParameter(str(exc), param_hint="profile") from exc
|
|
102
|
+
typer.echo(f"Created profile: {profile.expanduser().resolve()} ({document.name}/{document.agent.value})")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def plan(
|
|
107
|
+
profile: Annotated[
|
|
108
|
+
Path,
|
|
109
|
+
typer.Argument(exists=True, dir_okay=False, readable=True, help="TOML profile to plan."),
|
|
110
|
+
],
|
|
111
|
+
state: Annotated[
|
|
112
|
+
Path | None,
|
|
113
|
+
typer.Option("--state", exists=True, dir_okay=False, readable=True, help="Recorded deployment JSON."),
|
|
114
|
+
] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Show an offline plan without contacting Docker or changing state."""
|
|
117
|
+
try:
|
|
118
|
+
document = load_profile(profile)
|
|
119
|
+
recorded = load_state(state) if state is not None else None
|
|
120
|
+
except (OSError, tomllib.TOMLDecodeError, ValidationError, ValueError) as exc:
|
|
121
|
+
raise typer.BadParameter(str(exc), param_hint="profile/state") from exc
|
|
122
|
+
for line in build_plan(document, recorded, profile).summary_lines():
|
|
123
|
+
typer.echo(line)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def apply(
|
|
128
|
+
profile: Annotated[
|
|
129
|
+
Path,
|
|
130
|
+
typer.Argument(exists=True, dir_okay=False, readable=True, help="TOML profile to build and select."),
|
|
131
|
+
],
|
|
132
|
+
state: Annotated[
|
|
133
|
+
Path | None,
|
|
134
|
+
typer.Option("--state", dir_okay=False, help="Override the XDG deployment-state JSON path."),
|
|
135
|
+
] = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Build, safely seed, and select a profile deployment without launching it."""
|
|
138
|
+
try:
|
|
139
|
+
document = load_profile(profile)
|
|
140
|
+
if sys.platform == "darwin":
|
|
141
|
+
typer.echo(
|
|
142
|
+
"Note: macOS Docker Desktop builds retain the host UID and use image GID 1000 to avoid collisions."
|
|
143
|
+
)
|
|
144
|
+
record = apply_profile(document, profile, state, default_shortcuts_path())
|
|
145
|
+
except (
|
|
146
|
+
DockerCommandError,
|
|
147
|
+
LifecycleError,
|
|
148
|
+
ShortcutError,
|
|
149
|
+
OSError,
|
|
150
|
+
subprocess.CalledProcessError,
|
|
151
|
+
tomllib.TOMLDecodeError,
|
|
152
|
+
ValidationError,
|
|
153
|
+
ValueError,
|
|
154
|
+
) as exc:
|
|
155
|
+
raise typer.BadParameter(str(exc), param_hint="profile/state") from exc
|
|
156
|
+
typer.echo(f"Selected deployment: {record.deployment_id} ({record.image})")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def rollback(
|
|
161
|
+
profile: Annotated[
|
|
162
|
+
Path,
|
|
163
|
+
typer.Argument(
|
|
164
|
+
exists=True, dir_okay=False, readable=True, help="TOML profile whose selected deployment is restored."
|
|
165
|
+
),
|
|
166
|
+
],
|
|
167
|
+
state: Annotated[
|
|
168
|
+
Path | None,
|
|
169
|
+
typer.Option(
|
|
170
|
+
"--state", exists=True, dir_okay=False, readable=True, help="Override the XDG deployment-state JSON path."
|
|
171
|
+
),
|
|
172
|
+
] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Restore the immediately previous retained image and managed launch record."""
|
|
175
|
+
try:
|
|
176
|
+
document = load_profile(profile)
|
|
177
|
+
record = rollback_profile(document, state, default_shortcuts_path(), profile)
|
|
178
|
+
except (
|
|
179
|
+
LifecycleError,
|
|
180
|
+
ShortcutError,
|
|
181
|
+
OSError,
|
|
182
|
+
subprocess.CalledProcessError,
|
|
183
|
+
tomllib.TOMLDecodeError,
|
|
184
|
+
ValidationError,
|
|
185
|
+
ValueError,
|
|
186
|
+
) as exc:
|
|
187
|
+
raise typer.BadParameter(str(exc), param_hint="profile/state") from exc
|
|
188
|
+
for line in rollback_preview(record):
|
|
189
|
+
typer.echo(line)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def doctor(
|
|
194
|
+
profile: Annotated[
|
|
195
|
+
Path,
|
|
196
|
+
typer.Argument(exists=True, dir_okay=False, readable=True, help="TOML profile whose deployment is inspected."),
|
|
197
|
+
],
|
|
198
|
+
state: Annotated[
|
|
199
|
+
Path | None,
|
|
200
|
+
typer.Option("--state", dir_okay=False, help="Override the XDG deployment-state JSON path."),
|
|
201
|
+
] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Check Docker, recorded deployment state, and the selected image without mutating them."""
|
|
204
|
+
try:
|
|
205
|
+
document = load_profile(profile)
|
|
206
|
+
report: DoctorReport = doctor_profile(document, state)
|
|
207
|
+
except (LifecycleError, OSError, tomllib.TOMLDecodeError, ValidationError, ValueError) as exc:
|
|
208
|
+
raise typer.BadParameter(str(exc), param_hint="profile/state") from exc
|
|
209
|
+
for line in report.lines:
|
|
210
|
+
typer.echo(line)
|
|
211
|
+
if not report.healthy:
|
|
212
|
+
raise typer.Exit(1)
|