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.
Files changed (86) hide show
  1. agent_containers/__init__.py +1 -0
  2. agent_containers/__main__.py +6 -0
  3. agent_containers/_assets/agent-images/adal/Dockerfile +103 -0
  4. agent_containers/_assets/agent-images/adal/examples/egress-allowlist.txt +10 -0
  5. agent_containers/_assets/agent-images/adal/packages-apt.txt +13 -0
  6. agent_containers/_assets/agent-images/adal/packages-npm.txt +2 -0
  7. agent_containers/_assets/agent-images/adal/packages-uv.txt +6 -0
  8. agent_containers/_assets/agent-images/adal/tools-uv.txt +4 -0
  9. agent_containers/_assets/agent-images/agent-gateway/Dockerfile +50 -0
  10. agent_containers/_assets/agent-images/agent-gateway/entrypoint.sh +28 -0
  11. agent_containers/_assets/agent-images/agent-gateway/sshd_config.agent-gateway +11 -0
  12. agent_containers/_assets/agent-images/aider/Dockerfile +114 -0
  13. agent_containers/_assets/agent-images/aider/examples/egress-allowlist.txt +16 -0
  14. agent_containers/_assets/agent-images/aider/packages-apt.txt +16 -0
  15. agent_containers/_assets/agent-images/aider/packages-npm.txt +7 -0
  16. agent_containers/_assets/agent-images/aider/packages-uv.txt +9 -0
  17. agent_containers/_assets/agent-images/aider/tools-uv.txt +9 -0
  18. agent_containers/_assets/agent-images/claude-code/Dockerfile +171 -0
  19. agent_containers/_assets/agent-images/claude-code/entrypoint.sh +22 -0
  20. agent_containers/_assets/agent-images/claude-code/examples/egress-allowlist.txt +14 -0
  21. agent_containers/_assets/agent-images/claude-code/examples/settings.local-model.json +7 -0
  22. agent_containers/_assets/agent-images/claude-code/packages-apt.txt +16 -0
  23. agent_containers/_assets/agent-images/claude-code/packages-npm.txt +7 -0
  24. agent_containers/_assets/agent-images/claude-code/packages-uv.txt +9 -0
  25. agent_containers/_assets/agent-images/claude-code/plugin-marketplaces.txt +23 -0
  26. agent_containers/_assets/agent-images/claude-code/plugins.txt +13 -0
  27. agent_containers/_assets/agent-images/claude-code/proxy-ca.d/.keep +1 -0
  28. agent_containers/_assets/agent-images/claude-code/tools-uv.txt +9 -0
  29. agent_containers/_assets/agent-images/codex/Dockerfile +147 -0
  30. agent_containers/_assets/agent-images/codex/codex-wrapper.sh +13 -0
  31. agent_containers/_assets/agent-images/codex/examples/egress-allowlist.txt +10 -0
  32. agent_containers/_assets/agent-images/codex/packages-apt.txt +13 -0
  33. agent_containers/_assets/agent-images/codex/packages-npm.txt +2 -0
  34. agent_containers/_assets/agent-images/codex/packages-uv.txt +6 -0
  35. agent_containers/_assets/agent-images/codex/plugin-marketplaces.txt +2 -0
  36. agent_containers/_assets/agent-images/codex/plugins.txt +2 -0
  37. agent_containers/_assets/agent-images/codex/proxy-ca.d/.keep +1 -0
  38. agent_containers/_assets/agent-images/codex/tools-uv.txt +4 -0
  39. agent_containers/_assets/agent-images/hermes/Dockerfile +122 -0
  40. agent_containers/_assets/agent-images/hermes/entrypoint.sh +49 -0
  41. agent_containers/_assets/agent-images/hermes/packages-apt.txt +16 -0
  42. agent_containers/_assets/agent-images/hermes/packages-npm.txt +7 -0
  43. agent_containers/_assets/agent-images/hermes/packages-uv.txt +9 -0
  44. agent_containers/_assets/agent-images/hermes/proxy-ca.d/.keep +1 -0
  45. agent_containers/_assets/agent-images/hermes/tools-uv.txt +9 -0
  46. agent_containers/_assets/agent-images/kilo-code/Dockerfile +101 -0
  47. agent_containers/_assets/agent-images/kilo-code/examples/egress-allowlist.txt +7 -0
  48. agent_containers/_assets/agent-images/kilo-code/packages-apt.txt +5 -0
  49. agent_containers/_assets/agent-images/kilo-code/packages-npm.txt +1 -0
  50. agent_containers/_assets/agent-images/kilo-code/packages-uv.txt +4 -0
  51. agent_containers/_assets/agent-images/kilo-code/tools-uv.txt +4 -0
  52. agent_containers/_assets/agent-images/opencode/Dockerfile +126 -0
  53. agent_containers/_assets/agent-images/opencode/examples/egress-allowlist.txt +6 -0
  54. agent_containers/_assets/agent-images/opencode/packages-apt.txt +13 -0
  55. agent_containers/_assets/agent-images/opencode/packages-npm.txt +2 -0
  56. agent_containers/_assets/agent-images/opencode/packages-uv.txt +6 -0
  57. agent_containers/_assets/agent-images/opencode/proxy-ca.d/.keep +1 -0
  58. agent_containers/_assets/agent-images/opencode/tools-uv.txt +4 -0
  59. agent_containers/_assets/agent-images/qwen-code/Dockerfile +104 -0
  60. agent_containers/_assets/agent-images/qwen-code/examples/egress-allowlist.txt +10 -0
  61. agent_containers/_assets/agent-images/qwen-code/packages-apt.txt +13 -0
  62. agent_containers/_assets/agent-images/qwen-code/packages-npm.txt +2 -0
  63. agent_containers/_assets/agent-images/qwen-code/packages-uv.txt +6 -0
  64. agent_containers/_assets/agent-images/qwen-code/tools-uv.txt +4 -0
  65. agent_containers/_assets/agent-images/shared/egress-allowlist.sh +138 -0
  66. agent_containers/_assets/agent-images/shared/install-additional-packages.sh +79 -0
  67. agent_containers/_assets/agent-images/shared/install-workload-base.sh +46 -0
  68. agent_containers/_assets/agent-images/shared/workload-entrypoint.sh +82 -0
  69. agent_containers/_assets/agent-images/volume-bridge/Dockerfile +37 -0
  70. agent_containers/_assets/agent-images/volume-bridge/entrypoint.sh +29 -0
  71. agent_containers/_assets/agent-images/volume-bridge/serve.sh +66 -0
  72. agent_containers/assets.py +38 -0
  73. agent_containers/build_context.py +116 -0
  74. agent_containers/cli.py +212 -0
  75. agent_containers/creation.py +177 -0
  76. agent_containers/docker.py +562 -0
  77. agent_containers/lifecycle.py +325 -0
  78. agent_containers/planner.py +99 -0
  79. agent_containers/profile.py +377 -0
  80. agent_containers/shortcuts.py +159 -0
  81. agent_containers/state.py +154 -0
  82. agent_containers-0.1.0.dev0.dist-info/METADATA +195 -0
  83. agent_containers-0.1.0.dev0.dist-info/RECORD +86 -0
  84. agent_containers-0.1.0.dev0.dist-info/WHEEL +4 -0
  85. agent_containers-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  86. 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)
@@ -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)