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,562 @@
|
|
|
1
|
+
"""Pure direct-Docker command construction for hardened workload launches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agent_containers.build_context import BuildContexts
|
|
14
|
+
from agent_containers.profile import AgentName, MountType, Profile, resolve_mount_source
|
|
15
|
+
from agent_containers.state import profile_digest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DockerCommandError(ValueError):
|
|
19
|
+
"""Raised when a profile cannot be represented safely as a Docker run."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HOME_PATHS = {
|
|
23
|
+
AgentName.CLAUDE_CODE: "/home/claude",
|
|
24
|
+
AgentName.OPENCODE: "/home/opencode",
|
|
25
|
+
AgentName.CODEX: "/home/codex",
|
|
26
|
+
AgentName.HERMES: "/opt/data",
|
|
27
|
+
}
|
|
28
|
+
_IMAGE_NAMES = {
|
|
29
|
+
AgentName.CLAUDE_CODE: "claude-code",
|
|
30
|
+
AgentName.OPENCODE: "opencode",
|
|
31
|
+
AgentName.CODEX: "codex",
|
|
32
|
+
AgentName.HERMES: "hermes",
|
|
33
|
+
}
|
|
34
|
+
_AGENT_COMMANDS = {
|
|
35
|
+
AgentName.CLAUDE_CODE: "claude",
|
|
36
|
+
AgentName.OPENCODE: "opencode",
|
|
37
|
+
AgentName.CODEX: "codex",
|
|
38
|
+
AgentName.HERMES: None,
|
|
39
|
+
}
|
|
40
|
+
_CAPABILITIES = ("NET_ADMIN", "NET_RAW", "SETUID", "SETGID")
|
|
41
|
+
_WORKSPACE_EXEC = {AgentName.OPENCODE, AgentName.HERMES}
|
|
42
|
+
_SEED_USER_IDS = {
|
|
43
|
+
AgentName.CLAUDE_CODE: 1000,
|
|
44
|
+
AgentName.OPENCODE: 1000,
|
|
45
|
+
AgentName.CODEX: 1000,
|
|
46
|
+
AgentName.HERMES: 10000,
|
|
47
|
+
}
|
|
48
|
+
_SEED_COPY_SCRIPT = """set -eu
|
|
49
|
+
test ! -e "$SEED_TARGET"
|
|
50
|
+
mkdir -p "$(dirname "$SEED_TARGET")"
|
|
51
|
+
cp -a /tmp/agent-seed "$SEED_TARGET"
|
|
52
|
+
chown -R "$SEED_UID:$SEED_UID" "$SEED_TARGET"
|
|
53
|
+
"""
|
|
54
|
+
_HERMES_BASE_URL_ENV = {
|
|
55
|
+
"anthropic": "ANTHROPIC_BASE_URL",
|
|
56
|
+
"custom": "OPENAI_BASE_URL",
|
|
57
|
+
"openai": "OPENAI_BASE_URL",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_image_tag(profile: Profile, profile_path: Path | None = None) -> str:
|
|
62
|
+
"""Return a local tag tied to the profile's complete desired content."""
|
|
63
|
+
return (
|
|
64
|
+
f"agent-containers/{_IMAGE_NAMES[profile.agent]}:{_resource_owner()}-{profile.name}-"
|
|
65
|
+
f"{profile_digest(profile, profile_path)[:12]}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_home_volume(profile: Profile) -> str:
|
|
70
|
+
"""Return the user-scoped default home volume for a profile."""
|
|
71
|
+
return f"{_IMAGE_NAMES[profile.agent]}-home-{_resource_owner()}-{profile.name}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def legacy_home_volume(profile: Profile) -> str:
|
|
75
|
+
"""Return the pre-user-scoped volume name used by older deployments."""
|
|
76
|
+
return f"{_IMAGE_NAMES[profile.agent]}-home-{profile.name}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def default_decant_data_volume(profile: Profile) -> str:
|
|
80
|
+
"""Return the user-scoped default volume for Decant's writable database."""
|
|
81
|
+
return f"decant-data-{_resource_owner()}-{profile.name}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def default_decant_image_tag(profile: Profile) -> str:
|
|
85
|
+
"""Return the user-scoped tag for an account-matched Decant image."""
|
|
86
|
+
return f"agent-containers/decant:{_resource_owner()}-{profile.name}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def default_decant_container_name(profile: Profile) -> str:
|
|
90
|
+
"""Return the user-scoped name for the managed Decant container."""
|
|
91
|
+
return f"agent-containers-decant-{_resource_owner()}-{profile.name}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_decant_run_argv(
|
|
95
|
+
profile: Profile,
|
|
96
|
+
*,
|
|
97
|
+
claude_volume: str | None = None,
|
|
98
|
+
codex_volume: str | None = None,
|
|
99
|
+
) -> tuple[str, ...]:
|
|
100
|
+
"""Build the documented direct-mount Decant launch for selected agent homes."""
|
|
101
|
+
if not profile.decant.enabled:
|
|
102
|
+
raise DockerCommandError("Decant is not enabled for this profile")
|
|
103
|
+
if claude_volume is None and codex_volume is None:
|
|
104
|
+
raise DockerCommandError("Decant requires at least one selected Claude Code or Codex home volume")
|
|
105
|
+
config = profile.decant
|
|
106
|
+
data_volume = config.data_volume or default_decant_data_volume(profile)
|
|
107
|
+
argv = [
|
|
108
|
+
"docker",
|
|
109
|
+
"run",
|
|
110
|
+
"--rm",
|
|
111
|
+
"-it",
|
|
112
|
+
"--name",
|
|
113
|
+
default_decant_container_name(profile),
|
|
114
|
+
"--publish",
|
|
115
|
+
f"{config.bind_address}:{config.port}:3000",
|
|
116
|
+
"--mount",
|
|
117
|
+
f"type=volume,source={data_volume},target=/var/lib/decant",
|
|
118
|
+
]
|
|
119
|
+
if claude_volume is not None:
|
|
120
|
+
argv.extend(
|
|
121
|
+
[
|
|
122
|
+
"--mount",
|
|
123
|
+
f"type=volume,source={claude_volume},target=/sources/claude,readonly,volume-subpath=.claude",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
if codex_volume is not None:
|
|
127
|
+
argv.extend(
|
|
128
|
+
[
|
|
129
|
+
"--mount",
|
|
130
|
+
f"type=volume,source={codex_volume},target=/sources/codex,readonly,volume-subpath=.codex",
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
argv.extend(
|
|
134
|
+
[
|
|
135
|
+
config.image or default_decant_image_tag(profile),
|
|
136
|
+
"serve",
|
|
137
|
+
"--host",
|
|
138
|
+
"0.0.0.0",
|
|
139
|
+
"--port",
|
|
140
|
+
"3000",
|
|
141
|
+
"--no-fs-watch",
|
|
142
|
+
"--interval-ms",
|
|
143
|
+
"45000",
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
return tuple(argv)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_image_argv(
|
|
150
|
+
profile: Profile,
|
|
151
|
+
contexts: BuildContexts,
|
|
152
|
+
*,
|
|
153
|
+
image: str | None = None,
|
|
154
|
+
uid: int | None = None,
|
|
155
|
+
gid: int | None = None,
|
|
156
|
+
) -> tuple[str, ...]:
|
|
157
|
+
"""Build argv for a materialized profile context without executing Docker."""
|
|
158
|
+
if not contexts.image.is_dir() or not contexts.shared.is_dir():
|
|
159
|
+
raise DockerCommandError("materialized image and shared contexts must be directories")
|
|
160
|
+
if (uid is None) != (gid is None):
|
|
161
|
+
raise DockerCommandError("uid and gid must be supplied together")
|
|
162
|
+
if uid is not None and (uid < 1 or gid is None or gid < 1):
|
|
163
|
+
raise DockerCommandError("uid and gid must be positive")
|
|
164
|
+
argv = [
|
|
165
|
+
"docker",
|
|
166
|
+
"build",
|
|
167
|
+
"--build-context",
|
|
168
|
+
f"shared={contexts.shared}",
|
|
169
|
+
"--tag",
|
|
170
|
+
image or default_image_tag(profile),
|
|
171
|
+
]
|
|
172
|
+
if profile.agent != AgentName.HERMES and uid is not None and gid is not None:
|
|
173
|
+
argv.extend(["--build-arg", f"UID={uid}", "--build-arg", f"GID={gid}"])
|
|
174
|
+
argv.append(str(contexts.image))
|
|
175
|
+
return tuple(argv)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_seed_argv(
|
|
179
|
+
profile: Profile,
|
|
180
|
+
mount_target: str,
|
|
181
|
+
profile_path: Path,
|
|
182
|
+
*,
|
|
183
|
+
image: str,
|
|
184
|
+
home_volume: str | None = None,
|
|
185
|
+
) -> tuple[str, ...]:
|
|
186
|
+
"""Build a networkless, create-only copy-once seed command without running it."""
|
|
187
|
+
mount = next((item for item in profile.mounts if item.target == mount_target), None)
|
|
188
|
+
if mount is None or mount.type != MountType.SEED:
|
|
189
|
+
raise DockerCommandError(f"seed mount is not configured: {mount_target}")
|
|
190
|
+
home_path = _HOME_PATHS[profile.agent]
|
|
191
|
+
if not mount.target.startswith(f"{home_path}/"):
|
|
192
|
+
raise DockerCommandError(f"seed target must be inside the persistent home: {mount.target}")
|
|
193
|
+
source = resolve_mount_source(profile, mount, profile_path)
|
|
194
|
+
if not source.exists():
|
|
195
|
+
raise DockerCommandError(f"seed source does not exist: {source}")
|
|
196
|
+
home = home_volume or profile.home_volume or default_home_volume(profile)
|
|
197
|
+
uid = _SEED_USER_IDS[profile.agent]
|
|
198
|
+
return (
|
|
199
|
+
"docker",
|
|
200
|
+
"run",
|
|
201
|
+
"--rm",
|
|
202
|
+
"--network=none",
|
|
203
|
+
"--user",
|
|
204
|
+
"0:0",
|
|
205
|
+
"--security-opt=no-new-privileges",
|
|
206
|
+
"--read-only",
|
|
207
|
+
"--tmpfs",
|
|
208
|
+
"/tmp",
|
|
209
|
+
"--tmpfs",
|
|
210
|
+
"/run",
|
|
211
|
+
"--cap-drop=ALL",
|
|
212
|
+
"--cap-add=CHOWN",
|
|
213
|
+
# A populated home directory may be mode 0700 and owned by the
|
|
214
|
+
# remapped host UID. The short-lived root seed helper needs DAC
|
|
215
|
+
# override to inspect/create the target, while the workload itself
|
|
216
|
+
# never receives this capability.
|
|
217
|
+
"--cap-add=DAC_OVERRIDE",
|
|
218
|
+
"--mount",
|
|
219
|
+
f"type=volume,src={home},dst={home_path}",
|
|
220
|
+
"--mount",
|
|
221
|
+
f"type=bind,src={source},dst=/tmp/agent-seed,readonly",
|
|
222
|
+
"-e",
|
|
223
|
+
f"SEED_TARGET={mount.target}",
|
|
224
|
+
"-e",
|
|
225
|
+
f"SEED_UID={uid}",
|
|
226
|
+
"--entrypoint",
|
|
227
|
+
"/bin/sh",
|
|
228
|
+
image,
|
|
229
|
+
"-ceu",
|
|
230
|
+
_SEED_COPY_SCRIPT,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_run_argv(
|
|
235
|
+
profile: Profile,
|
|
236
|
+
workspace: Path,
|
|
237
|
+
profile_path: Path,
|
|
238
|
+
*,
|
|
239
|
+
image: str | None = None,
|
|
240
|
+
home_volume: str | None = None,
|
|
241
|
+
agent_args: Iterable[str] = (),
|
|
242
|
+
permit_unrestricted: bool = False,
|
|
243
|
+
) -> tuple[str, ...]:
|
|
244
|
+
"""Build a hardened docker run argv without executing Docker."""
|
|
245
|
+
workspace = workspace.expanduser().resolve()
|
|
246
|
+
if not workspace.is_dir():
|
|
247
|
+
raise DockerCommandError(f"workspace is not a directory: {workspace}")
|
|
248
|
+
if profile.egress.mode == "unrestricted" and not permit_unrestricted:
|
|
249
|
+
raise DockerCommandError("unrestricted egress requires explicit permit_unrestricted=True")
|
|
250
|
+
image_ref = image or f"{_IMAGE_NAMES[profile.agent]}:latest"
|
|
251
|
+
home = home_volume or profile.home_volume or default_home_volume(profile)
|
|
252
|
+
workspace_target = f"/workspace/{workspace.name}"
|
|
253
|
+
tmpfs_suffix = ":exec" if profile.agent in _WORKSPACE_EXEC else ""
|
|
254
|
+
argv = [
|
|
255
|
+
"docker",
|
|
256
|
+
"run",
|
|
257
|
+
"-it",
|
|
258
|
+
"--rm",
|
|
259
|
+
"--security-opt=no-new-privileges",
|
|
260
|
+
"--read-only",
|
|
261
|
+
"--tmpfs",
|
|
262
|
+
f"/tmp{tmpfs_suffix}",
|
|
263
|
+
"--tmpfs",
|
|
264
|
+
f"/run{tmpfs_suffix}",
|
|
265
|
+
"--cap-drop=ALL",
|
|
266
|
+
*[f"--cap-add={cap}" for cap in _CAPABILITIES],
|
|
267
|
+
"-v",
|
|
268
|
+
f"{home}:{_HOME_PATHS[profile.agent]}",
|
|
269
|
+
"-v",
|
|
270
|
+
f"{workspace}:{workspace_target}",
|
|
271
|
+
"-w",
|
|
272
|
+
workspace_target,
|
|
273
|
+
]
|
|
274
|
+
if profile.agent == AgentName.HERMES:
|
|
275
|
+
argv.extend(["--cap-add=CHOWN", "--cap-add=DAC_OVERRIDE"])
|
|
276
|
+
_append_network_args(argv, profile)
|
|
277
|
+
_append_proxy_args(argv, profile, profile_path)
|
|
278
|
+
_append_mount_args(argv, profile, profile_path)
|
|
279
|
+
argv.append(image_ref)
|
|
280
|
+
command = _AGENT_COMMANDS[profile.agent]
|
|
281
|
+
if command is not None:
|
|
282
|
+
argv.append(command)
|
|
283
|
+
argv.extend(_provider_agent_args(profile))
|
|
284
|
+
argv.extend(agent_args)
|
|
285
|
+
return tuple(argv)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _append_network_args(argv: list[str], profile: Profile) -> None:
|
|
289
|
+
"""Append explicit gateway, allowlist and secret-reference arguments."""
|
|
290
|
+
if profile.egress.gateway_host is not None:
|
|
291
|
+
argv.extend(
|
|
292
|
+
[
|
|
293
|
+
"-e",
|
|
294
|
+
f"AGENT_GATEWAY_HOST={profile.egress.gateway_host}",
|
|
295
|
+
"-e",
|
|
296
|
+
f"AGENT_GATEWAY_PORT={profile.egress.gateway_port}",
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
if profile.egress.gateway_user is not None:
|
|
300
|
+
argv.extend(["-e", f"AGENT_GATEWAY_USER={profile.egress.gateway_user}"])
|
|
301
|
+
if profile.egress.gateway_access_hostname is not None:
|
|
302
|
+
argv.extend(["-e", f"AGENT_GATEWAY_ACCESS_HOSTNAME={profile.egress.gateway_access_hostname}"])
|
|
303
|
+
if profile.egress.gateway_bootstrap_allow:
|
|
304
|
+
argv.extend(["-e", f"AGENT_GATEWAY_BOOTSTRAP_ALLOW={','.join(profile.egress.gateway_bootstrap_allow)}"])
|
|
305
|
+
elif profile.egress.mode == "allowlist" and profile.egress.hosts:
|
|
306
|
+
argv.extend(["-e", f"AGENT_ALLOWED_EGRESS={','.join(profile.egress.hosts)}"])
|
|
307
|
+
if profile.provider and profile.provider.api_key_env:
|
|
308
|
+
argv.extend(["-e", profile.provider.api_key_env])
|
|
309
|
+
if profile.provider and profile.agent == AgentName.CLAUDE_CODE:
|
|
310
|
+
if profile.provider.endpoint is not None:
|
|
311
|
+
argv.extend(["-e", f"ANTHROPIC_BASE_URL={_url_value(profile.provider.endpoint)}"])
|
|
312
|
+
if profile.provider.model is not None:
|
|
313
|
+
argv.extend(["-e", f"ANTHROPIC_MODEL={profile.provider.model}"])
|
|
314
|
+
if profile.provider and profile.agent == AgentName.HERMES and profile.provider.endpoint is not None:
|
|
315
|
+
environment = _HERMES_BASE_URL_ENV.get(profile.provider.kind)
|
|
316
|
+
if environment is None:
|
|
317
|
+
raise DockerCommandError(
|
|
318
|
+
f"provider endpoint mapping is not implemented for Hermes provider {profile.provider.kind!r}"
|
|
319
|
+
)
|
|
320
|
+
argv.extend(["-e", f"{environment}={_url_value(profile.provider.endpoint)}"])
|
|
321
|
+
if profile.agent == AgentName.OPENCODE and (
|
|
322
|
+
profile.langfuse.enabled
|
|
323
|
+
or (
|
|
324
|
+
profile.provider is not None
|
|
325
|
+
and (profile.provider.endpoint is not None or profile.provider.model is not None)
|
|
326
|
+
)
|
|
327
|
+
):
|
|
328
|
+
argv.extend(["-e", f"AGENT_OPENCODE_CONFIG_JSON={_opencode_config(profile)}"])
|
|
329
|
+
_append_langfuse_args(argv, profile)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _provider_agent_args(profile: Profile) -> list[str]:
|
|
333
|
+
"""Render provider settings through the selected agent's supported surface."""
|
|
334
|
+
provider = profile.provider
|
|
335
|
+
if profile.agent == AgentName.CLAUDE_CODE:
|
|
336
|
+
return []
|
|
337
|
+
if profile.agent == AgentName.CODEX:
|
|
338
|
+
args = []
|
|
339
|
+
if profile.langfuse.enabled:
|
|
340
|
+
args.extend(
|
|
341
|
+
[
|
|
342
|
+
"--config",
|
|
343
|
+
"features.hooks=true",
|
|
344
|
+
"--config",
|
|
345
|
+
'plugins."tracing@codex-observability-plugin".enabled=true',
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
if provider is None:
|
|
349
|
+
return args
|
|
350
|
+
if provider.model is not None:
|
|
351
|
+
args.extend(["--model", provider.model])
|
|
352
|
+
if provider.endpoint is not None:
|
|
353
|
+
if provider.kind == "openai":
|
|
354
|
+
args.extend(["--config", f'openai_base_url="{_toml_string(_url_value(provider.endpoint))}"'])
|
|
355
|
+
else:
|
|
356
|
+
provider_id = "agent_containers"
|
|
357
|
+
args.extend(
|
|
358
|
+
[
|
|
359
|
+
"--config",
|
|
360
|
+
f'model_provider="{provider_id}"',
|
|
361
|
+
"--config",
|
|
362
|
+
f'model_providers.{provider_id}.name="{provider.kind}"',
|
|
363
|
+
"--config",
|
|
364
|
+
f'model_providers.{provider_id}.base_url="{_toml_string(_url_value(provider.endpoint))}"',
|
|
365
|
+
"--config",
|
|
366
|
+
f'model_providers.{provider_id}.wire_api="responses"',
|
|
367
|
+
]
|
|
368
|
+
)
|
|
369
|
+
return args
|
|
370
|
+
if profile.agent == AgentName.HERMES:
|
|
371
|
+
if provider is None:
|
|
372
|
+
return []
|
|
373
|
+
args = []
|
|
374
|
+
if provider.kind:
|
|
375
|
+
args.extend(["--provider", provider.kind])
|
|
376
|
+
if provider.model is not None:
|
|
377
|
+
args.extend(["--model", provider.model])
|
|
378
|
+
return args
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _append_proxy_args(argv: list[str], profile: Profile, profile_path: Path) -> None:
|
|
383
|
+
"""Append proxy environment, runtime trust pointers and a read-only CA mount."""
|
|
384
|
+
if profile.proxy is None:
|
|
385
|
+
return
|
|
386
|
+
if profile.proxy.http is not None:
|
|
387
|
+
value = _url_value(profile.proxy.http)
|
|
388
|
+
argv.extend(["-e", f"HTTP_PROXY={value}", "-e", f"http_proxy={value}"])
|
|
389
|
+
if profile.proxy.https is not None:
|
|
390
|
+
value = _url_value(profile.proxy.https)
|
|
391
|
+
argv.extend(["-e", f"HTTPS_PROXY={value}", "-e", f"https_proxy={value}"])
|
|
392
|
+
if profile.proxy.no_proxy:
|
|
393
|
+
value = ",".join(profile.proxy.no_proxy)
|
|
394
|
+
argv.extend(["-e", f"NO_PROXY={value}", "-e", f"no_proxy={value}"])
|
|
395
|
+
if profile.proxy.http is not None or profile.proxy.https is not None:
|
|
396
|
+
argv.extend(["-e", "NODE_USE_ENV_PROXY=1"])
|
|
397
|
+
if profile.proxy.ca_file is not None:
|
|
398
|
+
_resolve_input_file(profile_path, profile.proxy.ca_file, "proxy CA")
|
|
399
|
+
if profile.proxy.ca_dir is not None:
|
|
400
|
+
_resolve_input_directory(profile_path, profile.proxy.ca_dir, "proxy CA directory")
|
|
401
|
+
if profile.proxy.ca_file is not None or profile.proxy.ca_dir is not None:
|
|
402
|
+
target = "/etc/ssl/certs/ca-certificates.crt"
|
|
403
|
+
argv.extend(
|
|
404
|
+
[
|
|
405
|
+
"-e",
|
|
406
|
+
f"SSL_CERT_FILE={target}",
|
|
407
|
+
"-e",
|
|
408
|
+
f"REQUESTS_CA_BUNDLE={target}",
|
|
409
|
+
"-e",
|
|
410
|
+
f"NODE_EXTRA_CA_CERTS={target}",
|
|
411
|
+
]
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _url_value(value: object) -> str:
|
|
416
|
+
"""Render a validated URL without Pydantic's cosmetic root slash."""
|
|
417
|
+
return str(value).rstrip("/")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _resource_owner() -> str:
|
|
421
|
+
"""Return a stable, Docker-safe host-user namespace for local resources."""
|
|
422
|
+
try:
|
|
423
|
+
username = getpass.getuser().strip().lower()
|
|
424
|
+
except (KeyError, OSError):
|
|
425
|
+
username = ""
|
|
426
|
+
username = re.sub(r"[^a-z0-9_.-]+", "-", username).strip("-_.")[:32].strip("-_.")
|
|
427
|
+
uid = getattr(os, "getuid", lambda: 0)()
|
|
428
|
+
return f"{username or 'user'}-{uid}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _toml_string(value: str) -> str:
|
|
432
|
+
"""Escape a value embedded in a Codex TOML string override."""
|
|
433
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _opencode_config(profile: Profile) -> str:
|
|
437
|
+
"""Render a secret-free, per-run OpenCode provider configuration."""
|
|
438
|
+
provider = profile.provider
|
|
439
|
+
options: dict[str, str] = {}
|
|
440
|
+
document: dict[str, object] = {
|
|
441
|
+
"$schema": "https://opencode.ai/config.json",
|
|
442
|
+
}
|
|
443
|
+
if provider is not None:
|
|
444
|
+
if provider.endpoint is not None:
|
|
445
|
+
options["baseURL"] = _url_value(provider.endpoint)
|
|
446
|
+
if provider.api_key_env is not None:
|
|
447
|
+
options["apiKey"] = "{env:" + provider.api_key_env + "}"
|
|
448
|
+
provider_config: dict[str, object] = {
|
|
449
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
450
|
+
"name": provider.kind,
|
|
451
|
+
"options": options,
|
|
452
|
+
"models": {},
|
|
453
|
+
}
|
|
454
|
+
if provider.model is not None:
|
|
455
|
+
provider_config["models"] = {provider.model: {"name": provider.model}}
|
|
456
|
+
document["model"] = f"agent_containers/{provider.model}"
|
|
457
|
+
document["provider"] = {"agent_containers": provider_config}
|
|
458
|
+
if profile.langfuse.enabled:
|
|
459
|
+
document["experimental"] = {"openTelemetry": True}
|
|
460
|
+
document["plugin"] = ["@langfuse/opencode-observability-plugin@latest"]
|
|
461
|
+
return json.dumps(document, separators=(",", ":"))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _append_langfuse_args(argv: list[str], profile: Profile) -> None:
|
|
465
|
+
"""Append opt-in Langfuse environment references and enforce egress scope."""
|
|
466
|
+
config = profile.langfuse
|
|
467
|
+
if not config.enabled:
|
|
468
|
+
return
|
|
469
|
+
base_url = config.base_url
|
|
470
|
+
assert base_url is not None
|
|
471
|
+
host = base_url.host
|
|
472
|
+
assert host is not None
|
|
473
|
+
host = host.lower()
|
|
474
|
+
if profile.egress.gateway_host is None:
|
|
475
|
+
if profile.egress.mode == "deny":
|
|
476
|
+
raise DockerCommandError("Langfuse requires an egress allowlist entry or gateway")
|
|
477
|
+
if profile.egress.mode == "allowlist" and host not in {entry.lower() for entry in profile.egress.hosts}:
|
|
478
|
+
raise DockerCommandError(f"Langfuse host must be included in egress hosts: {host}")
|
|
479
|
+
base_url_value = _url_value(base_url)
|
|
480
|
+
base_environment = "LANGFUSE_BASEURL" if profile.agent == AgentName.OPENCODE else "LANGFUSE_BASE_URL"
|
|
481
|
+
argv.extend(
|
|
482
|
+
[
|
|
483
|
+
"-e",
|
|
484
|
+
"TRACE_TO_LANGFUSE=true",
|
|
485
|
+
"-e",
|
|
486
|
+
config.public_key_env,
|
|
487
|
+
"-e",
|
|
488
|
+
f"AGENT_LANGFUSE_PUBLIC_KEY_ENV={config.public_key_env}",
|
|
489
|
+
"-e",
|
|
490
|
+
config.secret_key_env,
|
|
491
|
+
"-e",
|
|
492
|
+
f"AGENT_LANGFUSE_SECRET_KEY_ENV={config.secret_key_env}",
|
|
493
|
+
"-e",
|
|
494
|
+
f"{base_environment}={base_url_value}",
|
|
495
|
+
]
|
|
496
|
+
)
|
|
497
|
+
if config.environment is not None:
|
|
498
|
+
environment = "LANGFUSE_ENVIRONMENT" if profile.agent == AgentName.OPENCODE else "LANGFUSE_TRACING_ENVIRONMENT"
|
|
499
|
+
argv.extend(["-e", f"{environment}={config.environment}"])
|
|
500
|
+
if config.user_id is not None:
|
|
501
|
+
argv.extend(["-e", f"LANGFUSE_USER_ID={config.user_id}"])
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _append_mount_args(argv: list[str], profile: Profile, profile_path: Path) -> None:
|
|
505
|
+
"""Append explicit bind/directory mounts and reject copy-once seeds."""
|
|
506
|
+
targets = {"/etc/ssl/certs/ca-certificates.crt"}
|
|
507
|
+
if profile.egress.gateway_host is not None:
|
|
508
|
+
key_mount = next((mount for mount in profile.mounts if mount.target == "/etc/agent/gateway-key"), None)
|
|
509
|
+
known_hosts_mount = next(
|
|
510
|
+
(mount for mount in profile.mounts if mount.target == "/etc/agent/gateway-known-hosts"), None
|
|
511
|
+
)
|
|
512
|
+
if profile.egress.gateway_key_file is None and key_mount is None:
|
|
513
|
+
raise DockerCommandError("gateway key input is required when gateway mode is enabled")
|
|
514
|
+
if profile.egress.gateway_known_hosts_file is None and known_hosts_mount is None:
|
|
515
|
+
raise DockerCommandError("gateway known-hosts input is required when gateway mode is enabled")
|
|
516
|
+
for mount, label in ((key_mount, "gateway key"), (known_hosts_mount, "gateway known-hosts")):
|
|
517
|
+
if mount is not None:
|
|
518
|
+
if not mount.read_only:
|
|
519
|
+
raise DockerCommandError(f"{label} mount must be read-only")
|
|
520
|
+
if not resolve_mount_source(profile, mount, profile_path).is_file():
|
|
521
|
+
source = resolve_mount_source(profile, mount, profile_path)
|
|
522
|
+
raise DockerCommandError(f"{label} does not exist: {source}")
|
|
523
|
+
if profile.egress.gateway_key_file is not None:
|
|
524
|
+
key_source = _resolve_input_file(profile_path, profile.egress.gateway_key_file, "gateway key")
|
|
525
|
+
argv.extend(["-v", f"{key_source}:/etc/agent/gateway-key:ro"])
|
|
526
|
+
targets.add("/etc/agent/gateway-key")
|
|
527
|
+
if profile.egress.gateway_known_hosts_file is not None:
|
|
528
|
+
hosts_source = _resolve_input_file(
|
|
529
|
+
profile_path, profile.egress.gateway_known_hosts_file, "gateway known-hosts file"
|
|
530
|
+
)
|
|
531
|
+
argv.extend(["-v", f"{hosts_source}:/etc/agent/gateway-known-hosts:ro"])
|
|
532
|
+
targets.add("/etc/agent/gateway-known-hosts")
|
|
533
|
+
for mount in profile.mounts:
|
|
534
|
+
if mount.type == MountType.SEED:
|
|
535
|
+
raise DockerCommandError(f"seed mount requires apply before run: {mount.target}")
|
|
536
|
+
if mount.target in targets:
|
|
537
|
+
raise DockerCommandError(f"mount target conflicts with generated mount: {mount.target}")
|
|
538
|
+
targets.add(mount.target)
|
|
539
|
+
source = resolve_mount_source(profile, mount, profile_path)
|
|
540
|
+
access = "ro" if mount.read_only else "rw"
|
|
541
|
+
argv.extend(["-v", f"{source}:{mount.target}:{access}"])
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _resolve_input_file(profile_path: Path, value: str, label: str) -> Path:
|
|
545
|
+
"""Resolve a profile-relative file input and reject Docker-created directories."""
|
|
546
|
+
source = (profile_path.expanduser().resolve().parent / value).resolve()
|
|
547
|
+
if not source.is_file():
|
|
548
|
+
raise DockerCommandError(f"{label} does not exist: {source}")
|
|
549
|
+
return source
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _resolve_input_directory(profile_path: Path, value: str, label: str) -> Path:
|
|
553
|
+
"""Resolve a profile-relative directory input and reject Docker-created paths."""
|
|
554
|
+
source = (profile_path.expanduser().resolve().parent / value).resolve()
|
|
555
|
+
if not source.is_dir():
|
|
556
|
+
raise DockerCommandError(f"{label} does not exist: {source}")
|
|
557
|
+
return source
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def shell_command(argv: Iterable[str]) -> str:
|
|
561
|
+
"""Render argv safely for diagnostic output or a shell shortcut."""
|
|
562
|
+
return shlex.join(tuple(argv))
|