1111- a dedicated ``.apodex/runs/<session-id>/outputs`` directory, read-write at
1212 ``/outputs``;
1313- ``~/.apodex`` (session history, traces), so ``--resume`` works across runs;
14- - ``.env`` from the repo, for model and search credentials.
14+ - ``.env`` from the repo, for model and search credentials, plus the resolved
15+ runtime variables the host CLI loaded (exported environment, the launch
16+ directory's ``.env``, the user env file) — forwarded by *name* with
17+ ``-e NAME`` so Docker reads each value from the process environment and no
18+ secret ever lands on a command line.
1519
1620The image is built on first use and reused after that. It is the same
1721``Dockerfile`` the benchmark runner uses, so there is one image to maintain.
22+ Building needs a source checkout: a wheel installed with ``uv tool install``
23+ carries no Dockerfile. Outside a checkout the launcher uses an image that is
24+ already present, pulls an explicitly requested ``APODEX_IMAGE``, or builds
25+ from the checkout named by ``APODEX_BUILD_CONTEXT`` — and otherwise stops
26+ with the options spelled out rather than quietly running without the
27+ boundary the platform default promised.
1828"""
1929from __future__ import annotations
2030
2434import shutil
2535import subprocess
2636import sys
27- from collections .abc import Mapping
37+ from collections .abc import Mapping , Sequence
2838from pathlib import Path
2939
3040_DEFAULT_IMAGE = "apodex:local"
3141IMAGE = os .environ .get ("APODEX_IMAGE" , _DEFAULT_IMAGE )
3242_REPO_ROOT = Path (__file__ ).resolve ().parents [1 ]
43+ BUILD_CONTEXT_VAR = "APODEX_BUILD_CONTEXT"
44+
45+
46+ class BuildContextUnavailable (RuntimeError ):
47+ """No directory with a Dockerfile to build the default image from."""
48+
49+
50+ def build_context (environ : Mapping [str , str ] | None = None ) -> Path | None :
51+ """The directory whose ``Dockerfile`` builds :data:`_DEFAULT_IMAGE`.
52+
53+ ``APODEX_BUILD_CONTEXT`` names it explicitly (a FrontierAgent checkout, for
54+ an installation that lives elsewhere). Otherwise it is this package's own
55+ repository root when that is a checkout. ``None`` for a wheel installed
56+ outside any checkout: ``site-packages`` has no Dockerfile.
57+ """
58+ env = os .environ if environ is None else environ
59+ override = (env .get (BUILD_CONTEXT_VAR ) or "" ).strip ()
60+ if override :
61+ return Path (override ).expanduser ()
62+ if (_REPO_ROOT / "Dockerfile" ).is_file ():
63+ return _REPO_ROOT
64+ return None
3365
3466
3567def terminal_env (environ : Mapping [str , str ]) -> list [str ]:
@@ -122,21 +154,49 @@ def image_exists(image: str = IMAGE) -> bool:
122154 return probe .returncode == 0
123155
124156
125- def build_image (image : str = IMAGE , * , quiet : bool = False ) -> None :
157+ def build_image (
158+ image : str = IMAGE , * , quiet : bool = False , context : Path | None = None ,
159+ ) -> None :
126160 """Build the image from the repo Dockerfile.
127161
128162 Streams the build output: it takes minutes the first time (LibreOffice and
129163 the document readers are large), and a silent multi-minute wait reads as a
130- hang.
164+ hang. Raises :class:`BuildContextUnavailable` when there is no checkout
165+ to build from; the caller turns that into user-facing guidance.
131166 """
132- print (f"apodex: building { image } (first run only, this takes a few minutes)…" ,
133- file = sys .stderr )
134- cmd = ["docker" , "build" , "-t" , image , str (_REPO_ROOT )]
167+ root = context if context is not None else build_context ()
168+ if root is None :
169+ raise BuildContextUnavailable (
170+ f"{ image } is not present locally, and this installation is not a "
171+ "source checkout, so it cannot be built here"
172+ )
173+ if not (root / "Dockerfile" ).is_file ():
174+ raise BuildContextUnavailable (
175+ f"{ BUILD_CONTEXT_VAR } ={ root } does not contain a Dockerfile"
176+ )
177+ print (f"apodex: building { image } from { root } (first run only, this takes a "
178+ "few minutes)…" , file = sys .stderr )
179+ cmd = ["docker" , "build" , "-t" , image , str (root )]
135180 if quiet :
136181 cmd .insert (2 , "--quiet" )
137182 subprocess .run (cmd , check = True )
138183
139184
185+ def _build_unavailable_message (reason : str ) -> str :
186+ return (
187+ f"apodex: cannot use the Docker path — { reason } .\n "
188+ " Choose one:\n "
189+ " docker build -t apodex:local /path/to/FrontierAgent "
190+ "(build once from a checkout)\n "
191+ f" export { BUILD_CONTEXT_VAR } =/path/to/FrontierAgent "
192+ "(let this command build from it)\n "
193+ " export APODEX_IMAGE=<image you can pull> "
194+ "(use a registry image)\n "
195+ " frontier-agent --native … "
196+ "(workspace-local host runtime, not an OS sandbox)"
197+ )
198+
199+
140200def pull_image (image : str ) -> bool :
141201 """Fetch *image* from its registry, returning whether it arrived.
142202
@@ -148,9 +208,19 @@ def pull_image(image: str) -> bool:
148208
149209
150210def run_in_container (
151- argv : list [str ], * , cwd : str | None = None , image : str = IMAGE ,
211+ argv : list [str ],
212+ * ,
213+ cwd : str | None = None ,
214+ image : str = IMAGE ,
215+ forward_env : Sequence [str ] = (),
152216) -> int :
153- """Re-exec ``apodex argv`` inside the container. Returns its exit code."""
217+ """Re-exec ``apodex argv`` inside the container. Returns its exit code.
218+
219+ ``forward_env`` names host variables to carry inward (``-e NAME``, value
220+ read by Docker from this process's environment). It is how an exported
221+ key, a launch-directory ``.env`` or the user env file reach a run that the
222+ checkout's ``--env-file`` alone would not cover.
223+ """
154224 ok , why = docker_available ()
155225 if not ok :
156226 print (
@@ -168,6 +238,9 @@ def run_in_container(
168238 if image == _DEFAULT_IMAGE :
169239 try :
170240 build_image (image )
241+ except BuildContextUnavailable as exc :
242+ print (_build_unavailable_message (str (exc )), file = sys .stderr )
243+ return 1
171244 except subprocess .CalledProcessError as exc :
172245 print (f"apodex: image build failed (exit { exc .returncode } )." ,
173246 file = sys .stderr )
@@ -274,6 +347,9 @@ def run_in_container(
274347 "-v" , f"{ home_state } :/root/.apodex" ,
275348 "-v" , f"{ home_config } :/root/.config/apodex" ,
276349 "-e" , "APODEX_IN_CONTAINER=1" ,
350+ # The host already selected and resolved the user env file. The
351+ # mounted default config directory must not supply a second one.
352+ "-e" , "APODEX_USER_ENV_RESOLVED=1" ,
277353 "-e" , "HOME=/root" ,
278354 # Same contract as the Compose launchers: files written by the dropped-
279355 # privilege tool process keep useful host ownership. The entrypoint
@@ -312,6 +388,25 @@ def run_in_container(
312388 env_file = _REPO_ROOT / ".env"
313389 if env_file .is_file ():
314390 docker_cmd += ["--env-file" , str (env_file )]
391+ # Name only: ``-e NAME`` makes Docker read the value from this process's
392+ # environment, so the resolved key is never an argv token. Ordering after
393+ # --env-file is what lets the host-resolved value win over the checkout's
394+ # file, matching the exported-environment precedence native runs have.
395+ # Host files may also configure native execution. Preserve every explicit
396+ # container setting above, and keep host interpreter/config paths and the
397+ # higher-priority sandbox alias out of the container environment.
398+ reserved_names = {
399+ docker_cmd [index + 1 ].partition ("=" )[0 ]
400+ for index , arg in enumerate (docker_cmd [:- 1 ]) if arg == "-e"
401+ }
402+ reserved_names .update ({
403+ "APODEX_SANDBOX" , "APODEX_ENV_FILE" , "PATH" , "PYTHONPATH" ,
404+ "PYTHONHOME" , "VIRTUAL_ENV" , "XDG_CONFIG_HOME" , "XDG_CACHE_HOME" ,
405+ "XDG_STATE_HOME" , "XDG_DATA_HOME" , "XDG_RUNTIME_DIR" , "TMPDIR" ,
406+ })
407+ for name in dict .fromkeys (forward_env ):
408+ if name in os .environ and name not in reserved_names :
409+ docker_cmd += ["-e" , name ]
315410 docker_cmd += [image , "apodex" , * _without_cwd_arg (argv )]
316411
317412 try :
0 commit comments