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,377 @@
|
|
|
1
|
+
"""Versioned TOML profile model and validation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Self
|
|
11
|
+
|
|
12
|
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
_SCHEMA_VERSION = 1
|
|
15
|
+
_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
|
|
16
|
+
_ENV_PATTERN = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
17
|
+
_VOLUME_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentName(StrEnum):
|
|
21
|
+
"""Supported workload image adapters."""
|
|
22
|
+
|
|
23
|
+
CLAUDE_CODE = "claude-code"
|
|
24
|
+
OPENCODE = "opencode"
|
|
25
|
+
CODEX = "codex"
|
|
26
|
+
HERMES = "hermes"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MountType(StrEnum):
|
|
30
|
+
"""Ways a profile can provide host content to a workload."""
|
|
31
|
+
|
|
32
|
+
BIND = "bind"
|
|
33
|
+
DIRECTORY = "directory"
|
|
34
|
+
SEED = "seed"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PackageSet(BaseModel):
|
|
38
|
+
"""Optional build-time packages separated by installation ecosystem."""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
apt: list[str] = Field(default_factory=list)
|
|
43
|
+
npm: list[str] = Field(default_factory=list)
|
|
44
|
+
uv_tools: list[str] = Field(default_factory=list)
|
|
45
|
+
uv_libraries: list[str] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@field_validator("apt", "npm", "uv_tools", "uv_libraries")
|
|
48
|
+
@classmethod
|
|
49
|
+
def package_names_are_nonempty(cls, values: list[str]) -> list[str]:
|
|
50
|
+
"""Reject blank or multiline package-list entries while preserving order."""
|
|
51
|
+
cleaned = [value.strip() for value in values]
|
|
52
|
+
if any(not value for value in cleaned):
|
|
53
|
+
raise ValueError("package names must not be blank")
|
|
54
|
+
if any("\n" in value or "\r" in value or "\x00" in value for value in cleaned):
|
|
55
|
+
raise ValueError("package names must be single-line text")
|
|
56
|
+
return cleaned
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProviderConfig(BaseModel):
|
|
60
|
+
"""Provider connection details needed to construct a launch."""
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(extra="forbid")
|
|
63
|
+
|
|
64
|
+
kind: str = Field(min_length=1)
|
|
65
|
+
endpoint: AnyHttpUrl | None = None
|
|
66
|
+
model: str | None = Field(default=None, min_length=1)
|
|
67
|
+
api_key_env: str | None = None
|
|
68
|
+
|
|
69
|
+
@field_validator("kind")
|
|
70
|
+
@classmethod
|
|
71
|
+
def kind_is_normalized(cls, value: str) -> str:
|
|
72
|
+
"""Keep provider identifiers stable in deployment records."""
|
|
73
|
+
return value.strip().lower()
|
|
74
|
+
|
|
75
|
+
@field_validator("api_key_env")
|
|
76
|
+
@classmethod
|
|
77
|
+
def environment_name_is_safe(cls, value: str | None) -> str | None:
|
|
78
|
+
"""Allow secret references but never literal secret values."""
|
|
79
|
+
if value is not None and not _ENV_PATTERN.fullmatch(value):
|
|
80
|
+
raise ValueError("api_key_env must be an uppercase environment variable name")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProxyConfig(BaseModel):
|
|
85
|
+
"""HTTP(S) proxy, bypass and runtime trust inputs."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
http: AnyHttpUrl | None = None
|
|
90
|
+
https: AnyHttpUrl | None = None
|
|
91
|
+
no_proxy: list[str] = Field(default_factory=list)
|
|
92
|
+
ca_file: str | None = Field(default=None, min_length=1)
|
|
93
|
+
ca_dir: str | None = Field(default=None, min_length=1)
|
|
94
|
+
|
|
95
|
+
@field_validator("no_proxy")
|
|
96
|
+
@classmethod
|
|
97
|
+
def no_proxy_entries_are_nonempty(cls, values: list[str]) -> list[str]:
|
|
98
|
+
"""Reject accidental blank proxy bypass entries."""
|
|
99
|
+
cleaned = [value.strip() for value in values]
|
|
100
|
+
if any(not value for value in cleaned):
|
|
101
|
+
raise ValueError("no_proxy entries must not be blank")
|
|
102
|
+
return cleaned
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="after")
|
|
105
|
+
def ca_inputs_are_exclusive(self) -> Self:
|
|
106
|
+
"""Require one CA input shape at most: a file or a certificate directory."""
|
|
107
|
+
if self.ca_file is not None and self.ca_dir is not None:
|
|
108
|
+
raise ValueError("proxy ca_file and ca_dir are mutually exclusive")
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class EgressConfig(BaseModel):
|
|
113
|
+
"""Runtime egress policy and optional external gateway contract."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="forbid")
|
|
116
|
+
|
|
117
|
+
mode: str = "allowlist"
|
|
118
|
+
hosts: list[str] = Field(default_factory=list)
|
|
119
|
+
gateway_host: str | None = Field(default=None, min_length=1)
|
|
120
|
+
gateway_port: Annotated[int | None, Field(default=None, ge=1, le=65535)] = None
|
|
121
|
+
gateway_user: str | None = Field(default=None, min_length=1)
|
|
122
|
+
gateway_access_hostname: str | None = Field(default=None, min_length=1)
|
|
123
|
+
gateway_bootstrap_allow: list[str] = Field(default_factory=list)
|
|
124
|
+
gateway_key_file: str | None = Field(default=None, min_length=1)
|
|
125
|
+
gateway_known_hosts_file: str | None = Field(default=None, min_length=1)
|
|
126
|
+
|
|
127
|
+
@field_validator("mode")
|
|
128
|
+
@classmethod
|
|
129
|
+
def mode_is_supported(cls, value: str) -> str:
|
|
130
|
+
"""Accept only policy modes understood by the planner."""
|
|
131
|
+
normalized = value.strip().lower()
|
|
132
|
+
if normalized not in {"deny", "allowlist", "unrestricted"}:
|
|
133
|
+
raise ValueError("network mode must be deny, allowlist, or unrestricted")
|
|
134
|
+
return normalized
|
|
135
|
+
|
|
136
|
+
@field_validator("hosts")
|
|
137
|
+
@classmethod
|
|
138
|
+
def hosts_are_nonempty(cls, values: list[str]) -> list[str]:
|
|
139
|
+
"""Reject accidental blank allowlist entries."""
|
|
140
|
+
cleaned = [value.strip() for value in values]
|
|
141
|
+
if any(not value for value in cleaned):
|
|
142
|
+
raise ValueError("egress hosts must not be blank")
|
|
143
|
+
return cleaned
|
|
144
|
+
|
|
145
|
+
@field_validator("gateway_bootstrap_allow")
|
|
146
|
+
@classmethod
|
|
147
|
+
def gateway_bootstrap_entries_are_nonempty(cls, values: list[str]) -> list[str]:
|
|
148
|
+
"""Reject accidental blank gateway bootstrap entries."""
|
|
149
|
+
cleaned = [value.strip() for value in values]
|
|
150
|
+
if any(not value for value in cleaned):
|
|
151
|
+
raise ValueError("gateway bootstrap entries must not be blank")
|
|
152
|
+
for value in cleaned:
|
|
153
|
+
try:
|
|
154
|
+
ipaddress.ip_network(value, strict=False)
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
raise ValueError("gateway bootstrap entries must be IP addresses or CIDRs") from exc
|
|
157
|
+
return cleaned
|
|
158
|
+
|
|
159
|
+
@model_validator(mode="after")
|
|
160
|
+
def gateway_requires_port(self) -> Self:
|
|
161
|
+
"""Require a complete gateway address when either part is supplied."""
|
|
162
|
+
if (self.gateway_host is None) != (self.gateway_port is None):
|
|
163
|
+
raise ValueError("gateway_host and gateway_port must be supplied together")
|
|
164
|
+
options = (
|
|
165
|
+
self.gateway_user,
|
|
166
|
+
self.gateway_access_hostname,
|
|
167
|
+
self.gateway_key_file,
|
|
168
|
+
self.gateway_known_hosts_file,
|
|
169
|
+
)
|
|
170
|
+
if self.gateway_host is None and (
|
|
171
|
+
any(option is not None for option in options) or self.gateway_bootstrap_allow
|
|
172
|
+
):
|
|
173
|
+
raise ValueError("gateway options require gateway_host")
|
|
174
|
+
if (self.gateway_key_file is None) != (self.gateway_known_hosts_file is None):
|
|
175
|
+
raise ValueError("gateway_key_file and gateway_known_hosts_file must be supplied together")
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DecantConfig(BaseModel):
|
|
180
|
+
"""Optional Decant session aggregation inputs."""
|
|
181
|
+
|
|
182
|
+
model_config = ConfigDict(extra="forbid")
|
|
183
|
+
|
|
184
|
+
enabled: bool = False
|
|
185
|
+
source_profiles: list[str] = Field(default_factory=list)
|
|
186
|
+
image: str | None = Field(default=None, min_length=1)
|
|
187
|
+
data_volume: str | None = Field(default=None, min_length=1)
|
|
188
|
+
bind_address: str = "127.0.0.1"
|
|
189
|
+
port: Annotated[int, Field(default=8787, ge=1, le=65535)] = 8787
|
|
190
|
+
|
|
191
|
+
@field_validator("source_profiles")
|
|
192
|
+
@classmethod
|
|
193
|
+
def source_names_are_nonempty(cls, values: list[str]) -> list[str]:
|
|
194
|
+
"""Reject blank profile references."""
|
|
195
|
+
cleaned = [value.strip() for value in values]
|
|
196
|
+
if any(not value for value in cleaned):
|
|
197
|
+
raise ValueError("Decant source profile names must not be blank")
|
|
198
|
+
if any(not _NAME_PATTERN.fullmatch(value) for value in cleaned):
|
|
199
|
+
raise ValueError("Decant source profile names must be valid profile names")
|
|
200
|
+
return cleaned
|
|
201
|
+
|
|
202
|
+
@field_validator("image")
|
|
203
|
+
@classmethod
|
|
204
|
+
def image_is_single_line(cls, value: str | None) -> str | None:
|
|
205
|
+
"""Keep the derived Decant image reference safe for argv rendering."""
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
cleaned = value.strip()
|
|
209
|
+
if not cleaned or any(character.isspace() or ord(character) < 32 for character in cleaned):
|
|
210
|
+
raise ValueError("Decant image must be a single-line image reference")
|
|
211
|
+
return cleaned
|
|
212
|
+
|
|
213
|
+
@field_validator("data_volume")
|
|
214
|
+
@classmethod
|
|
215
|
+
def data_volume_is_safe(cls, value: str | None) -> str | None:
|
|
216
|
+
"""Allow an optional reusable Docker named volume for Decant state."""
|
|
217
|
+
if value is None:
|
|
218
|
+
return None
|
|
219
|
+
cleaned = value.strip()
|
|
220
|
+
if not _VOLUME_NAME_PATTERN.fullmatch(cleaned):
|
|
221
|
+
raise ValueError("Decant data_volume must be a Docker named-volume name")
|
|
222
|
+
return cleaned
|
|
223
|
+
|
|
224
|
+
@field_validator("bind_address")
|
|
225
|
+
@classmethod
|
|
226
|
+
def bind_address_is_single_line(cls, value: str) -> str:
|
|
227
|
+
"""Reject shell-like bind-address values before Docker sees them."""
|
|
228
|
+
cleaned = value.strip()
|
|
229
|
+
if not cleaned or any(character.isspace() or ord(character) < 32 for character in cleaned):
|
|
230
|
+
raise ValueError("Decant bind_address must be a single-line host address")
|
|
231
|
+
return cleaned
|
|
232
|
+
|
|
233
|
+
@model_validator(mode="after")
|
|
234
|
+
def enabled_requires_sources(self) -> Self:
|
|
235
|
+
"""Require explicit source profiles when Decant is opted in."""
|
|
236
|
+
if self.enabled and not self.source_profiles:
|
|
237
|
+
raise ValueError("Decant support requires source_profiles when enabled")
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class LangfuseConfig(BaseModel):
|
|
242
|
+
"""Experimental, opt-in Langfuse observability settings."""
|
|
243
|
+
|
|
244
|
+
model_config = ConfigDict(extra="forbid")
|
|
245
|
+
|
|
246
|
+
enabled: bool = False
|
|
247
|
+
base_url: AnyHttpUrl | None = None
|
|
248
|
+
public_key_env: str = "LANGFUSE_PUBLIC_KEY"
|
|
249
|
+
secret_key_env: str = "LANGFUSE_SECRET_KEY"
|
|
250
|
+
environment: str | None = Field(default=None, min_length=1)
|
|
251
|
+
user_id: str | None = Field(default=None, min_length=1)
|
|
252
|
+
|
|
253
|
+
@field_validator("public_key_env", "secret_key_env")
|
|
254
|
+
@classmethod
|
|
255
|
+
def credential_environment_name_is_safe(cls, value: str) -> str:
|
|
256
|
+
"""Allow only environment-variable references, never credential values."""
|
|
257
|
+
if not _ENV_PATTERN.fullmatch(value):
|
|
258
|
+
raise ValueError("Langfuse credential fields must be uppercase environment variable names")
|
|
259
|
+
return value
|
|
260
|
+
|
|
261
|
+
@model_validator(mode="after")
|
|
262
|
+
def enabled_requires_endpoint(self) -> Self:
|
|
263
|
+
"""Require an explicit endpoint when the experimental integration is enabled."""
|
|
264
|
+
if self.enabled and self.base_url is None:
|
|
265
|
+
raise ValueError("Langfuse base_url is required when Langfuse is enabled")
|
|
266
|
+
if self.public_key_env == self.secret_key_env:
|
|
267
|
+
raise ValueError("Langfuse public_key_env and secret_key_env must differ")
|
|
268
|
+
return self
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class MountConfig(BaseModel):
|
|
272
|
+
"""A custom file/directory input or copy-once seed."""
|
|
273
|
+
|
|
274
|
+
model_config = ConfigDict(extra="forbid")
|
|
275
|
+
|
|
276
|
+
type: MountType = MountType.BIND
|
|
277
|
+
source: str = Field(min_length=1)
|
|
278
|
+
target: str = Field(min_length=1)
|
|
279
|
+
read_only: bool = True
|
|
280
|
+
|
|
281
|
+
@field_validator("source", "target")
|
|
282
|
+
@classmethod
|
|
283
|
+
def paths_are_trimmed(cls, value: str) -> str:
|
|
284
|
+
"""Reject whitespace-only paths."""
|
|
285
|
+
cleaned = value.strip()
|
|
286
|
+
if not cleaned:
|
|
287
|
+
raise ValueError("mount paths must not be blank")
|
|
288
|
+
return cleaned
|
|
289
|
+
|
|
290
|
+
@field_validator("target")
|
|
291
|
+
@classmethod
|
|
292
|
+
def target_is_absolute(cls, value: str) -> str:
|
|
293
|
+
"""Container targets must be absolute."""
|
|
294
|
+
if not value.startswith("/") or value == "/":
|
|
295
|
+
raise ValueError("mount target must be an absolute path other than /")
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class Profile(BaseModel):
|
|
300
|
+
"""Complete desired configuration for one named agent deployment."""
|
|
301
|
+
|
|
302
|
+
model_config = ConfigDict(extra="forbid")
|
|
303
|
+
|
|
304
|
+
schema_version: Annotated[int, Field(default=_SCHEMA_VERSION, ge=1)]
|
|
305
|
+
name: str
|
|
306
|
+
agent: AgentName
|
|
307
|
+
home_volume: str | None = Field(default=None, min_length=1)
|
|
308
|
+
packages: PackageSet = Field(default_factory=PackageSet)
|
|
309
|
+
provider: ProviderConfig | None = None
|
|
310
|
+
proxy: ProxyConfig | None = None
|
|
311
|
+
egress: EgressConfig = Field(default_factory=EgressConfig)
|
|
312
|
+
decant: DecantConfig = Field(default_factory=DecantConfig)
|
|
313
|
+
langfuse: LangfuseConfig = Field(default_factory=LangfuseConfig)
|
|
314
|
+
mounts: list[MountConfig] = Field(default_factory=list)
|
|
315
|
+
|
|
316
|
+
@field_validator("schema_version")
|
|
317
|
+
@classmethod
|
|
318
|
+
def schema_version_is_current(cls, value: int) -> int:
|
|
319
|
+
"""Reject profiles newer than this CLI can interpret."""
|
|
320
|
+
if value != _SCHEMA_VERSION:
|
|
321
|
+
raise ValueError(f"unsupported profile schema_version {value}; expected {_SCHEMA_VERSION}")
|
|
322
|
+
return value
|
|
323
|
+
|
|
324
|
+
@field_validator("name")
|
|
325
|
+
@classmethod
|
|
326
|
+
def name_is_safe(cls, value: str) -> str:
|
|
327
|
+
"""Constrain names used in Docker resource identifiers."""
|
|
328
|
+
cleaned = value.strip()
|
|
329
|
+
if not _NAME_PATTERN.fullmatch(cleaned):
|
|
330
|
+
raise ValueError("name must start with a lowercase letter and contain only a-z, 0-9, _ or -")
|
|
331
|
+
return cleaned
|
|
332
|
+
|
|
333
|
+
@field_validator("home_volume")
|
|
334
|
+
@classmethod
|
|
335
|
+
def home_volume_is_safe(cls, value: str | None) -> str | None:
|
|
336
|
+
"""Allow explicit Docker volume names without shell/path syntax."""
|
|
337
|
+
if value is None:
|
|
338
|
+
return None
|
|
339
|
+
cleaned = value.strip()
|
|
340
|
+
if not _VOLUME_NAME_PATTERN.fullmatch(cleaned):
|
|
341
|
+
raise ValueError("home_volume must be a Docker named-volume name")
|
|
342
|
+
return cleaned
|
|
343
|
+
|
|
344
|
+
@model_validator(mode="after")
|
|
345
|
+
def mounts_do_not_conflict(self) -> Self:
|
|
346
|
+
"""Reject duplicate or nested targets Docker would resolve ambiguously."""
|
|
347
|
+
targets = sorted(mount.target.rstrip("/") for mount in self.mounts)
|
|
348
|
+
for index, target in enumerate(targets):
|
|
349
|
+
if index and (target == targets[index - 1] or target.startswith(f"{targets[index - 1]}/")):
|
|
350
|
+
raise ValueError(f"mount targets overlap: {targets[index - 1]} and {target}")
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
@model_validator(mode="after")
|
|
354
|
+
def experimental_integrations_are_supported(self) -> Self:
|
|
355
|
+
"""Reject integrations for agent adapters that cannot consume them."""
|
|
356
|
+
if self.decant.enabled and self.agent not in {AgentName.CLAUDE_CODE, AgentName.CODEX}:
|
|
357
|
+
raise ValueError("experimental Decant support is limited to Claude Code and Codex")
|
|
358
|
+
if self.langfuse.enabled and self.agent not in {
|
|
359
|
+
AgentName.CLAUDE_CODE,
|
|
360
|
+
AgentName.CODEX,
|
|
361
|
+
AgentName.OPENCODE,
|
|
362
|
+
}:
|
|
363
|
+
raise ValueError("experimental Langfuse support is limited to Claude Code, Codex, and OpenCode")
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def load_profile(path: Path) -> Profile:
|
|
368
|
+
"""Load and validate a TOML profile from disk."""
|
|
369
|
+
with path.open("rb") as profile_file:
|
|
370
|
+
return Profile.model_validate(tomllib.load(profile_file))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def resolve_mount_source(profile: Profile, mount: MountConfig, profile_path: Path) -> Path:
|
|
374
|
+
"""Resolve a relative mount source against the profile file directory."""
|
|
375
|
+
del profile
|
|
376
|
+
source = Path(mount.source).expanduser()
|
|
377
|
+
return source if source.is_absolute() else (profile_path.parent / source).resolve()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Generate namespaced shell shortcuts for selected profile deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agent_containers.docker import (
|
|
12
|
+
DockerCommandError,
|
|
13
|
+
build_decant_run_argv,
|
|
14
|
+
build_run_argv,
|
|
15
|
+
legacy_home_volume,
|
|
16
|
+
)
|
|
17
|
+
from agent_containers.profile import AgentName, Profile
|
|
18
|
+
from agent_containers.state import DeploymentRecord
|
|
19
|
+
|
|
20
|
+
_WORKSPACE_MOUNT = object()
|
|
21
|
+
_WORKSPACE_TARGET = object()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShortcutError(ValueError):
|
|
25
|
+
"""Raised when a profile cannot be rendered as a safe shell shortcut."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_shortcuts_path() -> Path:
|
|
29
|
+
"""Return the CLI-managed profile shortcut path under the XDG config home."""
|
|
30
|
+
config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
31
|
+
root = Path(config_home).expanduser() if config_home else Path.home() / ".config"
|
|
32
|
+
return (root / "agent-containers" / "profiles.sh").resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shortcut_function_name(profile: Profile) -> str:
|
|
36
|
+
"""Return the reserved, namespaced shell function name for a profile."""
|
|
37
|
+
return f"agent_containers_{profile.name.replace('-', '_')}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decant_shortcut_function_name(profile: Profile) -> str:
|
|
41
|
+
"""Return the reserved function name for an experimental Decant launch."""
|
|
42
|
+
return f"agent_containers_decant_{profile.name.replace('-', '_')}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_shortcut(profile: Profile, record: DeploymentRecord, profile_path: Path) -> str:
|
|
46
|
+
"""Render one profile function without embedding secrets or shell interpolation."""
|
|
47
|
+
workspace = profile_path.expanduser().resolve().parent
|
|
48
|
+
try:
|
|
49
|
+
argv = build_run_argv(
|
|
50
|
+
profile,
|
|
51
|
+
workspace,
|
|
52
|
+
profile_path,
|
|
53
|
+
image=record.image,
|
|
54
|
+
home_volume=record.home_volume or legacy_home_volume(profile),
|
|
55
|
+
)
|
|
56
|
+
except DockerCommandError as exc:
|
|
57
|
+
raise ShortcutError(str(exc)) from exc
|
|
58
|
+
workspace_mount = f"{workspace}:/workspace/{workspace.name}"
|
|
59
|
+
tokens: list[str | object] = [
|
|
60
|
+
_WORKSPACE_MOUNT
|
|
61
|
+
if token == workspace_mount
|
|
62
|
+
else _WORKSPACE_TARGET
|
|
63
|
+
if token == f"/workspace/{workspace.name}"
|
|
64
|
+
else token
|
|
65
|
+
for token in argv
|
|
66
|
+
]
|
|
67
|
+
rendered = " \\\n ".join(_render_token(token) for token in tokens)
|
|
68
|
+
name = shortcut_function_name(profile)
|
|
69
|
+
return f'# BEGIN agent-containers profile {profile.name}\n{name}() {{\n {rendered} "$@"\n}}\n# END agent-containers profile {profile.name}\n'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_decant_shortcut(profile: Profile, source_records: Mapping[str, DeploymentRecord]) -> str:
|
|
73
|
+
"""Render a direct-mount Decant function from selected agent deployments."""
|
|
74
|
+
volumes: dict[AgentName, str] = {}
|
|
75
|
+
for source_name in profile.decant.source_profiles:
|
|
76
|
+
record = source_records.get(source_name)
|
|
77
|
+
if record is None:
|
|
78
|
+
raise ShortcutError(f"Decant source profile state is unavailable: {source_name}")
|
|
79
|
+
try:
|
|
80
|
+
source_profile = Profile.model_validate(record.profile_snapshot)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise ShortcutError(f"Decant source profile state is invalid: {source_name}") from exc
|
|
83
|
+
if source_profile.agent not in {AgentName.CLAUDE_CODE, AgentName.CODEX}:
|
|
84
|
+
raise ShortcutError(f"Decant source profile must use Claude Code or Codex: {source_name}")
|
|
85
|
+
if source_profile.agent in volumes:
|
|
86
|
+
raise ShortcutError(f"Decant has multiple {source_profile.agent.value} source profiles")
|
|
87
|
+
volumes[source_profile.agent] = record.home_volume or legacy_home_volume(source_profile)
|
|
88
|
+
try:
|
|
89
|
+
argv = build_decant_run_argv(
|
|
90
|
+
profile,
|
|
91
|
+
claude_volume=volumes.get(AgentName.CLAUDE_CODE),
|
|
92
|
+
codex_volume=volumes.get(AgentName.CODEX),
|
|
93
|
+
)
|
|
94
|
+
except DockerCommandError as exc:
|
|
95
|
+
raise ShortcutError(str(exc)) from exc
|
|
96
|
+
rendered = " \\\n ".join(shlex.quote(token) for token in argv)
|
|
97
|
+
name = decant_shortcut_function_name(profile)
|
|
98
|
+
return (
|
|
99
|
+
f"# BEGIN agent-containers decant {profile.name}\n"
|
|
100
|
+
f"{name}() {{\n"
|
|
101
|
+
f' {rendered} "$@"\n'
|
|
102
|
+
f"}}\n"
|
|
103
|
+
f"# END agent-containers decant {profile.name}\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def update_shortcuts(
|
|
108
|
+
path: Path,
|
|
109
|
+
profile: Profile,
|
|
110
|
+
record: DeploymentRecord,
|
|
111
|
+
profile_path: Path,
|
|
112
|
+
decant_sources: Mapping[str, DeploymentRecord] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Atomically replace one generated profile block in the managed shortcuts file."""
|
|
115
|
+
path = path.expanduser().resolve()
|
|
116
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
117
|
+
block = render_shortcut(profile, record, profile_path)
|
|
118
|
+
content = _replace_block(existing, f"profile {profile.name}", block)
|
|
119
|
+
decant_begin = f"decant {profile.name}"
|
|
120
|
+
if profile.decant.enabled:
|
|
121
|
+
if decant_sources is None:
|
|
122
|
+
raise ShortcutError("Decant source deployments are required when Decant is enabled")
|
|
123
|
+
content = _replace_block(content, decant_begin, render_decant_shortcut(profile, decant_sources))
|
|
124
|
+
else:
|
|
125
|
+
content = _replace_block(content, decant_begin, "")
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
|
|
128
|
+
temporary_path = Path(temporary)
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as shortcuts_file:
|
|
131
|
+
shortcuts_file.write(content)
|
|
132
|
+
shortcuts_file.flush()
|
|
133
|
+
os.fsync(shortcuts_file.fileno())
|
|
134
|
+
temporary_path.chmod(0o600)
|
|
135
|
+
temporary_path.replace(path)
|
|
136
|
+
finally:
|
|
137
|
+
temporary_path.unlink(missing_ok=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _replace_block(existing: str, key: str, block: str) -> str:
|
|
141
|
+
"""Replace or remove one generated block while preserving user shell text."""
|
|
142
|
+
begin = f"# BEGIN agent-containers {key}\n"
|
|
143
|
+
end = f"# END agent-containers {key}\n"
|
|
144
|
+
if begin in existing:
|
|
145
|
+
start = existing.index(begin)
|
|
146
|
+
finish = existing.index(end, start) + len(end)
|
|
147
|
+
replacement = block
|
|
148
|
+
return existing[:start] + replacement + existing[finish:]
|
|
149
|
+
if not block:
|
|
150
|
+
return existing
|
|
151
|
+
return existing.rstrip() + ("\n\n" if existing.strip() else "") + block
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_token(token: str | object) -> str:
|
|
155
|
+
if token is _WORKSPACE_MOUNT:
|
|
156
|
+
return '"$PWD:/workspace/$(basename "$PWD")"'
|
|
157
|
+
if token is _WORKSPACE_TARGET:
|
|
158
|
+
return '"/workspace/$(basename "$PWD")"'
|
|
159
|
+
return shlex.quote(str(token))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Machine-managed deployment state stored separately from user TOML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from agent_containers.profile import Profile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeploymentRecord(BaseModel):
|
|
19
|
+
"""One retained image and managed launch configuration."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
deployment_id: str = Field(min_length=1)
|
|
24
|
+
image: str = Field(min_length=1)
|
|
25
|
+
home_volume: str | None = None
|
|
26
|
+
profile_digest: str = Field(min_length=64, max_length=64)
|
|
27
|
+
profile_snapshot: dict[str, Any]
|
|
28
|
+
launch_digest: str = Field(min_length=64, max_length=64)
|
|
29
|
+
created_at: datetime
|
|
30
|
+
selected: bool = False
|
|
31
|
+
|
|
32
|
+
@field_validator("created_at")
|
|
33
|
+
@classmethod
|
|
34
|
+
def timestamp_is_aware(cls, value: datetime) -> datetime:
|
|
35
|
+
"""Keep state timestamps unambiguous across hosts."""
|
|
36
|
+
if value.tzinfo is None:
|
|
37
|
+
raise ValueError("created_at must include a timezone")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DeploymentState(BaseModel):
|
|
42
|
+
"""Current and retained deployment records for one profile."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="forbid")
|
|
45
|
+
|
|
46
|
+
schema_version: int = 1
|
|
47
|
+
profile_name: str = Field(min_length=1)
|
|
48
|
+
deployments: list[DeploymentRecord] = Field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@model_validator(mode="after")
|
|
51
|
+
def has_at_most_one_selected(self) -> Self:
|
|
52
|
+
"""Prevent ambiguous active deployment selection."""
|
|
53
|
+
if sum(record.selected for record in self.deployments) > 1:
|
|
54
|
+
raise ValueError("deployment state may select at most one deployment")
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def selected_deployment(self) -> DeploymentRecord | None:
|
|
59
|
+
"""Return the active record, if one has been applied."""
|
|
60
|
+
return next((record for record in self.deployments if record.selected), None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def profile_snapshot(profile: Profile) -> dict[str, Any]:
|
|
64
|
+
"""Produce a JSON-compatible desired profile snapshot."""
|
|
65
|
+
snapshot = profile.model_dump(mode="json")
|
|
66
|
+
# Keep snapshots made before the optional explicit volume field compatible
|
|
67
|
+
# with the default profile representation. An omitted value means the
|
|
68
|
+
# same user-scoped default, so it must not force a spurious image rebuild.
|
|
69
|
+
if profile.home_volume is None:
|
|
70
|
+
snapshot.pop("home_volume", None)
|
|
71
|
+
decant = snapshot.get("decant")
|
|
72
|
+
if isinstance(decant, dict):
|
|
73
|
+
if decant.get("image") is None:
|
|
74
|
+
decant.pop("image", None)
|
|
75
|
+
if decant.get("data_volume") is None:
|
|
76
|
+
decant.pop("data_volume", None)
|
|
77
|
+
return snapshot
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def digest_snapshot(snapshot: dict[str, Any]) -> str:
|
|
81
|
+
"""Hash a canonical snapshot for change detection."""
|
|
82
|
+
encoded = json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode()
|
|
83
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def profile_digest(profile: Profile, profile_path: Path | None = None) -> str:
|
|
87
|
+
"""Return the canonical digest, including deployment CA input contents."""
|
|
88
|
+
snapshot = profile_snapshot(profile)
|
|
89
|
+
if profile_path is not None and profile.proxy is not None:
|
|
90
|
+
snapshot["_proxy_ca_digest"] = _proxy_ca_digest(profile, profile_path)
|
|
91
|
+
return digest_snapshot(snapshot)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_state(path: Path) -> DeploymentState:
|
|
95
|
+
"""Load machine-managed deployment state from JSON."""
|
|
96
|
+
with path.open(encoding="utf-8") as state_file:
|
|
97
|
+
return DeploymentState.model_validate_json(state_file.read())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def default_state_path(profile: Profile) -> Path:
|
|
101
|
+
"""Return the XDG-style per-user state path for one named profile."""
|
|
102
|
+
return default_state_path_for_name(profile.name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_state_path_for_name(name: str) -> Path:
|
|
106
|
+
"""Return the XDG-style state path for a validated profile name."""
|
|
107
|
+
state_home = os.environ.get("XDG_STATE_HOME")
|
|
108
|
+
root = Path(state_home).expanduser() if state_home else Path.home() / ".local" / "state"
|
|
109
|
+
return (root / "agent-containers" / f"{name}.json").resolve()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def save_state(path: Path, state: DeploymentState) -> None:
|
|
113
|
+
"""Atomically write formatted JSON, creating its parent directory."""
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
|
|
116
|
+
temporary_path = Path(temporary)
|
|
117
|
+
try:
|
|
118
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as state_file:
|
|
119
|
+
state_file.write(state.model_dump_json(indent=2) + "\n")
|
|
120
|
+
state_file.flush()
|
|
121
|
+
os.fsync(state_file.fileno())
|
|
122
|
+
temporary_path.replace(path)
|
|
123
|
+
finally:
|
|
124
|
+
temporary_path.unlink(missing_ok=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def new_deployment_id(profile: Profile, profile_path: Path | None = None) -> str:
|
|
128
|
+
"""Create a timestamp-plus-digest identifier without using a secret."""
|
|
129
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
130
|
+
return f"{profile.name}-{timestamp}-{profile_digest(profile, profile_path)[:12]}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _proxy_ca_digest(profile: Profile, profile_path: Path) -> str:
|
|
134
|
+
"""Hash profile CA inputs so certificate rotation triggers an image update."""
|
|
135
|
+
assert profile.proxy is not None
|
|
136
|
+
hasher = hashlib.sha256()
|
|
137
|
+
base = profile_path.expanduser().resolve().parent
|
|
138
|
+
if profile.proxy.ca_file is not None:
|
|
139
|
+
source = (base / profile.proxy.ca_file).resolve()
|
|
140
|
+
if not source.is_file():
|
|
141
|
+
raise ValueError(f"proxy CA file does not exist: {source}")
|
|
142
|
+
hasher.update(source.name.encode())
|
|
143
|
+
hasher.update(source.read_bytes())
|
|
144
|
+
if profile.proxy.ca_dir is not None:
|
|
145
|
+
source = (base / profile.proxy.ca_dir).resolve()
|
|
146
|
+
if not source.is_dir():
|
|
147
|
+
raise ValueError(f"proxy CA directory does not exist: {source}")
|
|
148
|
+
files = sorted(item for item in source.iterdir() if item.is_file())
|
|
149
|
+
if not files:
|
|
150
|
+
raise ValueError(f"proxy CA directory is empty: {source}")
|
|
151
|
+
for item in files:
|
|
152
|
+
hasher.update(item.name.encode())
|
|
153
|
+
hasher.update(item.read_bytes())
|
|
154
|
+
return hasher.hexdigest()
|