From fab57269fd0801d25bdb8a7a0ba178e74078f222 Mon Sep 17 00:00:00 2001 From: Matyas Stoch Date: Wed, 9 Sep 2026 16:56:12 +0200 Subject: [PATCH] feat(apodex): install once with uv tool and launch from any project A wheel built from this repository installs with `uv tool install`, but the first launch outside a checkout failed twice. Profile loading could not find `config/providers.yaml`, which lives at the repository root and is resolved relative to the package. After that, the first `read_file` call ran its helper under the system `python3` (3.9 on macOS) because a tool install only exposes the console scripts on PATH. Packaging and runtime: - Ship `config/providers.yaml` inside the wheel as `frontier_agent/infra/providers.yaml`; the loader prefers the checkout copy and falls back to the packaged one. - Native mode places the CLI interpreter's bin directory ahead of the inherited PATH so `read_file`, `download_file`, and `python3` inside `bash` use the environment the CLI was installed with. A PATH that already leads with it is left unchanged. Configuration: - Add an optional user env file at `$XDG_CONFIG_HOME/apodex/env` (default `~/.config/apodex/env`, override with `APODEX_ENV_FILE`). Precedence is CLI options, exported environment, the launch directory's `.env` and ancestors, then the user file. The file is read literally, blank values are ignored, and notes never include values. - A `_API_KEY` / `_BASE_URL` pair defined together in the user file is applied together. If a higher source fixes one half to a different value, the other half is withheld and the reason is printed once. Docker: - Building `apodex:local` needs a source checkout. Outside one, the launcher uses an image that is already present, builds from `APODEX_BUILD_CONTEXT`, or pulls an explicit `APODEX_IMAGE`; with none of those it stops and lists the options together with `--native` rather than dropping the boundary. - Forward resolved runtime variables into the container as `-e NAME`, so an exported value reaches the container without appearing on the command line. The macOS Docker preference, the Linux native default, and the BYOK policy (no login command, no credential entry or display in the TUI) are unchanged. Docs: a new `docs/install/global-install.md` covers installation, PATH troubleshooting, the user file and its precedence, the one-time Docker step, and updating. Existing checkout instructions remain supported. Tests cover env precedence and the pair guard, secret-free output, `--cwd` semantics, the packaged registry, Docker build-context and forwarding behaviour, native PATH selection, and a built wheel installed with `uv tool` and launched from unrelated directories against a local stub endpoint. --- CHANGELOG.md | 18 + README.md | 20 + apodex/README.md | 30 +- apodex/cli.py | 39 +- apodex/docker.py | 98 +++- apodex/native.py | 35 ++ apodex/tests/test_cli_runtime_selection.py | 60 +++ apodex/tests/test_config_preflight.py | 5 +- apodex/tests/test_docker.py | 152 +++++++ apodex/tests/test_global_install.py | 337 ++++++++++++++ apodex/tests/test_native.py | 79 +++- apodex/tests/test_userenv.py | 492 +++++++++++++++++++++ apodex/userenv.py | 315 +++++++++++++ docs/README.md | 4 + docs/install/README.md | 5 + docs/install/global-install.md | 199 +++++++++ docs/install/macos.md | 6 + docs/install/tui-endpoint-quickstart.md | 5 + frontier_agent/infra/providers.py | 30 +- pyproject.toml | 5 + tests/test_providers_packaged.py | 61 +++ 21 files changed, 1959 insertions(+), 36 deletions(-) create mode 100644 apodex/tests/test_global_install.py create mode 100644 apodex/tests/test_userenv.py create mode 100644 apodex/userenv.py create mode 100644 docs/install/global-install.md create mode 100644 tests/test_providers_packaged.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ae801..afa13b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,25 @@ Initial open-source release of FrontierAgent. - Clean-machine Linux + NVIDIA installation and release-certification guide, distinguishing deployment health from production agent correctness. +- Standalone installation with `uv tool install`: the wheel now ships the + provider registry, and `frontier-agent` runs from any directory without a + checkout. An optional user env file (`$XDG_CONFIG_HOME/apodex/env`, default + `~/.config/apodex/env`, override with `APODEX_ENV_FILE`) holds the endpoint + below exported variables and the launch directory's `.env`; a key defined + next to a base URL is only applied together with that base URL. +- `APODEX_BUILD_CONTEXT` names a checkout to build `apodex:local` from when the + installed CLI is not one. Without an image, a checkout, or an explicit + `APODEX_IMAGE`, the Docker path stops with the options instead of silently + running natively. + ### Fixed +- Native mode puts the CLI's own Python environment ahead of the inherited + `PATH`, so `read_file`, `download_file`, and `python3` inside `bash` use the + interpreter the CLI was installed with rather than a system Python. +- The Docker launcher forwards the resolved runtime variables (exported + environment, launch directory `.env`, user env file) into the container by + name with `docker run -e NAME`, so an exported value now takes precedence over + the checkout's `.env` inside the container as it already did natively. - Apply benchmark question limits after seeded shuffling so repeated runs can sample different questions while `--no-shuffle` keeps canonical ordering. diff --git a/README.md b/README.md index 77273a9..734e2a0 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,26 @@ document packages are intentionally optional in native mode; the agent installs only what a task actually needs into `/.apodex/runtime/native`. The `apodex` command is retained as a compatibility alias. +### Install once, launch from any project + +To run `frontier-agent` like any other command-line tool, install it from this +repository with `uv` and keep the endpoint in one user file: + +```bash +uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git + +# Put OPENAI_API_KEY, OPENAI_BASE_URL and OPENAI_MODEL into +# ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env and chmod 600 it. + +cd /path/to/project +frontier-agent +``` + +Exported variables and a project `.env` still take precedence over the user +file. On macOS with Docker running, the container image has to be built once +from a clone. [Install once and launch from any project](docs/install/global-install.md) +covers the PATH setup, the precedence rules, the Docker step, and updating. + Prefer a script that does all of the above? `./scripts/run-macos.sh` and `./scripts/run-linux.sh` set up a hosted-endpoint install, and `./scripts/run-linux-gpu.sh --install-system-deps --setup-only` prepares a native, diff --git a/apodex/README.md b/apodex/README.md index 7e0a0c4..1297c94 100644 --- a/apodex/README.md +++ b/apodex/README.md @@ -67,8 +67,18 @@ is enough. ## Install and run -Run from the repository root, so `frontier_agent`, `plugins` and `workflows` -import: +Two ways to install. As a standalone tool, so the command works from any +directory: + +```bash +uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git +# credentials in ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env, or exported; +# see docs/install/global-install.md +cd /path/to/your/repo && frontier-agent +``` + +Or from a checkout, run from the repository root so `frontier_agent`, +`plugins` and `workflows` import from the source tree: ```bash uv sync @@ -119,14 +129,24 @@ attached through the same session input manager; ordinary text is inserted into the prompt. `Cmd+V` remains the terminal's normal text paste shortcut. This is a local, open-source BYOK tool: there is no account or `login` command. -Keys stay in your environment or local `.env`; the TUI never asks for or displays -them. Startup validates the local configuration before opening the TUI, and +Keys stay in your environment, a local `.env`, or the optional user file +`$XDG_CONFIG_HOME/apodex/env` (default `~/.config/apodex/env`, override with +`APODEX_ENV_FILE`); the TUI never asks for or displays them. Precedence is CLI +options, then exported variables, then the launch directory's `.env`, then the +user file. The user file is read literally, without `${VAR}` expansion, and a +key it defines next to a base URL is only applied together with that base URL. +Startup validates the local configuration before opening the TUI, and `/config` shows only safe diagnostics such as provider, model, endpoint host and whether the required key is configured. The first `--docker` run builds the image, which takes a few minutes (LibreOffice and the document readers are large); later runs reuse it. -`APODEX_IMAGE` overrides the tag. +`APODEX_IMAGE` overrides the tag. Building needs a source checkout. A tool +installed with `uv tool install` has none, so it uses an image that is already +present, builds from the clone named by `APODEX_BUILD_CONTEXT`, or pulls an +explicit `APODEX_IMAGE`; with none of those it stops and lists the options +instead of running natively unannounced. Configured variables cross into the +container by name (`docker run -e NAME`), never as values on the command line. On Linux, native mode is the default. On macOS, Docker remains preferred when its daemon is reachable, with automatic fallback to native mode. Native mode diff --git a/apodex/cli.py b/apodex/cli.py index 4ed29c7..8ffa095 100644 --- a/apodex/cli.py +++ b/apodex/cli.py @@ -25,25 +25,22 @@ from apodex.session import TerminalSession from apodex.terminal import resolve_terminal_ui from apodex.tui.themes import CLI_THEME_NAMES +from apodex.userenv import EnvResolution, load_environment if TYPE_CHECKING: from apodex.config import ModelConfig -def _load_env() -> None: +def _load_env() -> EnvResolution: """Load a ``.env`` (keys/base-url/model) from the launch directory or an - ancestor, the way FrontierAgent's own entry points do. ``override=False`` so - real environment variables and CLI flags always win. Must run **before** - any ``chdir`` so it finds the repo's ``.env`` rather than the target repo. + ancestor, the way FrontierAgent's own entry points do, then the optional + user env file underneath it. ``override=False`` so real environment + variables and CLI flags always win. Must run **before** any ``chdir`` so it + finds the repo's ``.env`` rather than the target repo, and before native + mode rewrites ``HOME``/``XDG_CONFIG_HOME``, so the user file is read from + the user's real config directory. See :mod:`apodex.userenv`. """ - try: - from dotenv import find_dotenv, load_dotenv - except Exception: - return - load_dotenv(".env", override=False) - found = find_dotenv(usecwd=True) - if found: - load_dotenv(found, override=False) + return load_environment() # (substring in an engine log message) -> clean one-line note to surface @@ -301,7 +298,13 @@ async def _amain(argv: list[str] | None = None) -> int: # keys are available (the standalone CLI isn't bootstrapped by the app). # (The fully-local toolchain guarantee — incl. dropping E2B_API_KEY — is # owned by TerminalSession._authorize_workspace.) - _load_env() + env_resolution = _load_env() + # Secret-free by construction (names and paths only). Printed now so the + # explanation precedes whatever the note is about — a preflight failure + # over a withheld key, say — and only once: the TUI path alone repeats + # them in its transcript, since its alternate screen covers stderr. + for note in env_resolution.notes: + print(f"apodex: {note}", file=sys.stderr) # Textual's Kitty keyboard negotiation drops IME commits in iTerm2. Set # the compatibility fallback before either starting the native TUI or @@ -377,7 +380,10 @@ async def _amain(argv: list[str] | None = None) -> int: if a != "--docker"] docker_ok, docker_reason = docker_available() if args.docker or docker_ok: - return run_in_container(passthrough, cwd=cwd) + return run_in_container( + passthrough, cwd=cwd, + forward_env=env_resolution.forwarded_names(), + ) print( f"apodex: Docker is unavailable ({docker_reason}); using native mode.", file=sys.stderr, @@ -517,7 +523,10 @@ async def _amain(argv: list[str] | None = None) -> int: # stderr is written moments before Textual takes the alternate screen, so a # warning printed here is gone by the time the TUI is up. The TUI path # carries them into the transcript instead; line mode prints as before. - startup_warnings = [warning.message for warning in runtime_config.warnings] + startup_warnings = [ + *(env_resolution.notes if use_tui else ()), + *(warning.message for warning in runtime_config.warnings), + ] if not use_tui: for message in startup_warnings: print(f"warning: {message}", file=sys.stderr) diff --git a/apodex/docker.py b/apodex/docker.py index 9de2c3a..d756711 100644 --- a/apodex/docker.py +++ b/apodex/docker.py @@ -11,10 +11,20 @@ - a dedicated ``.apodex/runs//outputs`` directory, read-write at ``/outputs``; - ``~/.apodex`` (session history, traces), so ``--resume`` works across runs; -- ``.env`` from the repo, for model and search credentials. +- ``.env`` from the repo, for model and search credentials, plus the resolved + runtime variables the host CLI loaded (exported environment, the launch + directory's ``.env``, the user env file) — forwarded by *name* with + ``-e NAME`` so Docker reads each value from the process environment and no + secret ever lands on a command line. The image is built on first use and reused after that. It is the same ``Dockerfile`` the benchmark runner uses, so there is one image to maintain. +Building needs a source checkout: a wheel installed with ``uv tool install`` +carries no Dockerfile. Outside a checkout the launcher uses an image that is +already present, pulls an explicitly requested ``APODEX_IMAGE``, or builds +from the checkout named by ``APODEX_BUILD_CONTEXT`` — and otherwise stops +with the options spelled out rather than quietly running without the +boundary the platform default promised. """ from __future__ import annotations @@ -24,12 +34,34 @@ import shutil import subprocess import sys -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from pathlib import Path _DEFAULT_IMAGE = "apodex:local" IMAGE = os.environ.get("APODEX_IMAGE", _DEFAULT_IMAGE) _REPO_ROOT = Path(__file__).resolve().parents[1] +BUILD_CONTEXT_VAR = "APODEX_BUILD_CONTEXT" + + +class BuildContextUnavailable(RuntimeError): + """No directory with a Dockerfile to build the default image from.""" + + +def build_context(environ: Mapping[str, str] | None = None) -> Path | None: + """The directory whose ``Dockerfile`` builds :data:`_DEFAULT_IMAGE`. + + ``APODEX_BUILD_CONTEXT`` names it explicitly (a FrontierAgent checkout, for + an installation that lives elsewhere). Otherwise it is this package's own + repository root when that is a checkout. ``None`` for a wheel installed + outside any checkout: ``site-packages`` has no Dockerfile. + """ + env = os.environ if environ is None else environ + override = (env.get(BUILD_CONTEXT_VAR) or "").strip() + if override: + return Path(override).expanduser() + if (_REPO_ROOT / "Dockerfile").is_file(): + return _REPO_ROOT + return None def terminal_env(environ: Mapping[str, str]) -> list[str]: @@ -122,21 +154,49 @@ def image_exists(image: str = IMAGE) -> bool: return probe.returncode == 0 -def build_image(image: str = IMAGE, *, quiet: bool = False) -> None: +def build_image( + image: str = IMAGE, *, quiet: bool = False, context: Path | None = None, +) -> None: """Build the image from the repo Dockerfile. Streams the build output: it takes minutes the first time (LibreOffice and the document readers are large), and a silent multi-minute wait reads as a - hang. + hang. Raises :class:`BuildContextUnavailable` when there is no checkout + to build from; the caller turns that into user-facing guidance. """ - print(f"apodex: building {image} (first run only, this takes a few minutes)…", - file=sys.stderr) - cmd = ["docker", "build", "-t", image, str(_REPO_ROOT)] + root = context if context is not None else build_context() + if root is None: + raise BuildContextUnavailable( + f"{image} is not present locally, and this installation is not a " + "source checkout, so it cannot be built here" + ) + if not (root / "Dockerfile").is_file(): + raise BuildContextUnavailable( + f"{BUILD_CONTEXT_VAR}={root} does not contain a Dockerfile" + ) + print(f"apodex: building {image} from {root} (first run only, this takes a " + "few minutes)…", file=sys.stderr) + cmd = ["docker", "build", "-t", image, str(root)] if quiet: cmd.insert(2, "--quiet") subprocess.run(cmd, check=True) +def _build_unavailable_message(reason: str) -> str: + return ( + f"apodex: cannot use the Docker path — {reason}.\n" + " Choose one:\n" + " docker build -t apodex:local /path/to/FrontierAgent " + "(build once from a checkout)\n" + f" export {BUILD_CONTEXT_VAR}=/path/to/FrontierAgent " + "(let this command build from it)\n" + " export APODEX_IMAGE= " + "(use a registry image)\n" + " frontier-agent --native … " + "(workspace-local host runtime, not an OS sandbox)" + ) + + def pull_image(image: str) -> bool: """Fetch *image* from its registry, returning whether it arrived. @@ -148,9 +208,19 @@ def pull_image(image: str) -> bool: def run_in_container( - argv: list[str], *, cwd: str | None = None, image: str = IMAGE, + argv: list[str], + *, + cwd: str | None = None, + image: str = IMAGE, + forward_env: Sequence[str] = (), ) -> int: - """Re-exec ``apodex argv`` inside the container. Returns its exit code.""" + """Re-exec ``apodex argv`` inside the container. Returns its exit code. + + ``forward_env`` names host variables to carry inward (``-e NAME``, value + read by Docker from this process's environment). It is how an exported + key, a launch-directory ``.env`` or the user env file reach a run that the + checkout's ``--env-file`` alone would not cover. + """ ok, why = docker_available() if not ok: print( @@ -168,6 +238,9 @@ def run_in_container( if image == _DEFAULT_IMAGE: try: build_image(image) + except BuildContextUnavailable as exc: + print(_build_unavailable_message(str(exc)), file=sys.stderr) + return 1 except subprocess.CalledProcessError as exc: print(f"apodex: image build failed (exit {exc.returncode}).", file=sys.stderr) @@ -312,6 +385,13 @@ def run_in_container( env_file = _REPO_ROOT / ".env" if env_file.is_file(): docker_cmd += ["--env-file", str(env_file)] + # Name only: ``-e NAME`` makes Docker read the value from this process's + # environment, so the resolved key is never an argv token. Ordering after + # --env-file is what lets the host-resolved value win over the checkout's + # file, matching the exported-environment precedence native runs have. + for name in dict.fromkeys(forward_env): + if name in os.environ: + docker_cmd += ["-e", name] docker_cmd += [image, "apodex", *_without_cwd_arg(argv)] try: diff --git a/apodex/native.py b/apodex/native.py index 9468e7c..5c974bf 100644 --- a/apodex/native.py +++ b/apodex/native.py @@ -7,10 +7,42 @@ from __future__ import annotations import os +import sys from collections.abc import MutableMapping from pathlib import Path +def _interpreter_bin_dir(inherited_path: str) -> Path | None: + """The CLI interpreter's ``bin`` directory, to lead the inherited PATH. + + Native mode promises that the CLI's own Python environment is what + ``python3`` means to the tools (``read_file`` and ``download_file`` pipe + their helpers to ``python3 -``; the model's ``bash`` heredocs do the same). + From a checkout, ``uv run`` puts the venv first on PATH and the promise + holds by accident. An installation made with ``uv tool install`` exposes + only ``frontier-agent``/``apodex`` on PATH, so ``python3`` fell through to + whatever the system ships — on macOS a 3.9 that cannot even parse the + readers. + + ``sys.executable`` is used *unresolved* on purpose: a venv's ``bin/python`` + is a symlink to the base interpreter, and following it would name the base + installation's ``bin`` — the wrong environment, without the CLI's + dependencies. Only the directory itself is normalised. ``None`` when the + directory already leads the inherited PATH, so the ``uv run`` case keeps + its PATH byte-for-byte. + """ + executable = sys.executable + if not executable: + return None + bin_dir = Path(os.path.abspath(os.path.dirname(executable))) + if not bin_dir.is_dir(): + return None + first = inherited_path.split(os.pathsep, 1)[0].strip() if inherited_path else "" + if first and os.path.abspath(first) == str(bin_dir): + return None + return bin_dir + + def prepare_native_runtime( workspace: str, session_id: str, @@ -69,6 +101,9 @@ def prepare_native_runtime( dependencies / "cargo" / "bin", ] native_path = os.pathsep.join(str(path) for path in native_bins) + interpreter_bin = _interpreter_bin_dir(inherited_path) + if interpreter_bin is not None: + native_path = f"{native_path}{os.pathsep}{interpreter_bin}" if inherited_path: native_path = f"{native_path}{os.pathsep}{inherited_path}" diff --git a/apodex/tests/test_cli_runtime_selection.py b/apodex/tests/test_cli_runtime_selection.py index d9a01de..7cd9d73 100644 --- a/apodex/tests/test_cli_runtime_selection.py +++ b/apodex/tests/test_cli_runtime_selection.py @@ -46,3 +46,63 @@ def _refuse(requested: str | None = None) -> sandbox.Strategy: assert cli.main(["--bwrap"]) == 2 assert macos == [] assert "no bubblewrap here" in capsys.readouterr().err + + +def test_macos_global_install_with_docker_but_no_image_fails_closed( + monkeypatch, tmp_path, capsys, +) -> None: + """A wheel on a Mac with Docker running must not quietly go native. + + The platform default promised a container. With no image and no checkout + to build one from, the honest outcome is a stop that names the options, + not a native run the user never asked for. + """ + from apodex import docker + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli.sys, "platform", "darwin") + monkeypatch.setattr("apodex.docker.docker_available", lambda: (True, "available")) + monkeypatch.setattr(docker, "image_exists", lambda image: False) + monkeypatch.setattr(docker, "_REPO_ROOT", tmp_path / "site-packages") + monkeypatch.delenv(docker.BUILD_CONTEXT_VAR, raising=False) + monkeypatch.setattr( + docker.subprocess, "run", + lambda *a, **k: pytest.fail("no docker command may run without an image"), + ) + monkeypatch.setattr( + "apodex.native.prepare_native_runtime", + lambda *a, **k: pytest.fail("must not fall back to the native runtime"), + ) + + assert cli.main([]) == 1 + + err = capsys.readouterr().err + assert "cannot use the Docker path" in err + assert docker.BUILD_CONTEXT_VAR in err + assert "--native" in err + + +def test_macos_container_launch_forwards_the_resolved_environment( + monkeypatch, tmp_path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli.sys, "platform", "darwin") + monkeypatch.setattr("apodex.docker.docker_available", lambda: (True, "available")) + seen: dict[str, object] = {} + + def _record(argv, **kwargs): + seen["argv"] = list(argv) + seen["forward_env"] = tuple(kwargs.get("forward_env", ())) + return 0 + + monkeypatch.setattr("apodex.docker.run_in_container", _record) + (tmp_path / ".env").write_text("OPENAI_MODEL=project-model\n", encoding="utf-8") + monkeypatch.setenv("OPENAI_API_KEY", "sk-exported") + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + assert cli.main(["--docker", "-p", "hi"]) == 0 + + assert seen["argv"] == ["-p", "hi"] + assert "OPENAI_MODEL" in seen["forward_env"] # from the launch .env + assert "OPENAI_API_KEY" in seen["forward_env"] # exported + assert all("sk-exported" not in name for name in seen["forward_env"]) diff --git a/apodex/tests/test_config_preflight.py b/apodex/tests/test_config_preflight.py index 64e866d..d94c353 100644 --- a/apodex/tests/test_config_preflight.py +++ b/apodex/tests/test_config_preflight.py @@ -15,6 +15,7 @@ format_runtime_config_status, inspect_runtime_config, ) +from apodex.userenv import EnvResolution def _profile(**overrides): @@ -241,7 +242,7 @@ def test_cli_fails_before_session_construction_with_actionable_guidance( profile.runtime_config = lambda cfg, mode=None: inspect_runtime_config( cfg, profile=profile, mode=mode, environ={}, ) - monkeypatch.setattr(cli, "_load_env", lambda: None) + monkeypatch.setattr(cli, "_load_env", EnvResolution.empty) monkeypatch.setattr(cli, "terminal_mode_names", lambda: ["react", "agent_team"]) monkeypatch.setattr(cli, "get_profile", lambda _mode: profile) @@ -295,7 +296,7 @@ def test_resume_rejects_legacy_saved_mode_before_mutating_session( def test_cli_resume_rejects_legacy_saved_mode(tmp_path, monkeypatch): from apodex import session as session_module - monkeypatch.setattr(cli, "_load_env", lambda: None) + monkeypatch.setattr(cli, "_load_env", EnvResolution.empty) monkeypatch.setattr(cli, "terminal_mode_names", lambda: ["react", "agent_team"]) monkeypatch.setattr( session_module, diff --git a/apodex/tests/test_docker.py b/apodex/tests/test_docker.py index e8e4997..040b990 100644 --- a/apodex/tests/test_docker.py +++ b/apodex/tests/test_docker.py @@ -331,3 +331,155 @@ def close(self) -> None: assert lifecycle == ["start", "close"] assert "APODEX_CLIPBOARD_BROKER_URL=http://host.docker.internal:43210" in calls[0] assert "APODEX_CLIPBOARD_BROKER_TOKEN=session-token" in calls[0] + + +# ── outside a checkout: no Dockerfile to build from ────────────────────── + + +def test_build_context_is_the_checkout_when_it_has_a_dockerfile(monkeypatch) -> None: + monkeypatch.delenv(docker.BUILD_CONTEXT_VAR, raising=False) + assert (docker._REPO_ROOT / "Dockerfile").is_file() + + assert docker.build_context() == docker._REPO_ROOT + + +def test_build_context_is_none_for_an_installed_wheel(monkeypatch, tmp_path) -> None: + site_packages = tmp_path / "site-packages" + site_packages.mkdir() + monkeypatch.setattr(docker, "_REPO_ROOT", site_packages) + monkeypatch.delenv(docker.BUILD_CONTEXT_VAR, raising=False) + + assert docker.build_context() is None + + +def test_explicit_build_context_names_a_checkout_to_build_from(monkeypatch, tmp_path) -> None: + checkout = tmp_path / "FrontierAgent" + checkout.mkdir() + (checkout / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + monkeypatch.setattr(docker, "_REPO_ROOT", tmp_path / "site-packages") + monkeypatch.setenv(docker.BUILD_CONTEXT_VAR, str(checkout)) + builds: list[list[str]] = [] + monkeypatch.setattr( + docker.subprocess, "run", + lambda command, check=False: builds.append(command) or SimpleNamespace(returncode=0), + ) + + docker.build_image("apodex:local") + + assert builds == [["docker", "build", "-t", "apodex:local", str(checkout)]] + + +def test_missing_default_image_outside_a_checkout_stops_with_the_options( + monkeypatch, tmp_path, capsys, +) -> None: + workspace = tmp_path / "project" + workspace.mkdir() + calls = _stub_container(monkeypatch, tmp_path) + monkeypatch.setattr(docker, "image_exists", lambda image: False) + monkeypatch.setattr(docker, "_REPO_ROOT", tmp_path / "site-packages") + monkeypatch.delenv(docker.BUILD_CONTEXT_VAR, raising=False) + monkeypatch.setattr( + docker, "pull_image", + lambda image: pytest.fail("the local default has no registry to pull from"), + ) + + assert docker.run_in_container( + [], cwd=str(workspace), image=docker._DEFAULT_IMAGE, + ) == 1 + + assert calls == [] # neither a build nor a container was attempted + err = capsys.readouterr().err + assert "not a source checkout" in err + assert "docker build -t apodex:local" in err + assert docker.BUILD_CONTEXT_VAR in err + assert "APODEX_IMAGE" in err + assert "--native" in err + assert "not an OS sandbox" in err + + +def test_build_context_without_a_dockerfile_is_named_in_the_error( + monkeypatch, tmp_path, capsys, +) -> None: + workspace = tmp_path / "project" + workspace.mkdir() + calls = _stub_container(monkeypatch, tmp_path) + monkeypatch.setattr(docker, "image_exists", lambda image: False) + bad_context = tmp_path / "not-a-checkout" + bad_context.mkdir() + monkeypatch.setenv(docker.BUILD_CONTEXT_VAR, str(bad_context)) + + assert docker.run_in_container( + [], cwd=str(workspace), image=docker._DEFAULT_IMAGE, + ) == 1 + + assert calls == [] + err = capsys.readouterr().err + assert f"{docker.BUILD_CONTEXT_VAR}={bad_context} does not contain a Dockerfile" in err + + +def test_present_image_is_used_without_a_checkout(monkeypatch, tmp_path) -> None: + # An image built once from a checkout keeps working for a global install. + workspace = tmp_path / "project" + workspace.mkdir() + calls = _stub_container(monkeypatch, tmp_path) + monkeypatch.setattr(docker, "_REPO_ROOT", tmp_path / "site-packages") + monkeypatch.delenv(docker.BUILD_CONTEXT_VAR, raising=False) + monkeypatch.setattr( + docker, "build_image", lambda *a, **k: pytest.fail("nothing to build"), + ) + + assert docker.run_in_container( + [], cwd=str(workspace), image=docker._DEFAULT_IMAGE, + ) == 0 + assert len(calls) == 1 + assert calls[0][:3] == ["docker", "run", "--rm"] + + +# ── resolved host environment crosses the boundary by name ─────────────── + + +def test_forwarded_variables_travel_by_name_never_by_value(monkeypatch, tmp_path) -> None: + workspace = tmp_path / "project" + workspace.mkdir() + calls = _stub_container(monkeypatch, tmp_path) + secret = "sk-forwarded-secret-1x2y" + monkeypatch.setenv("OPENAI_API_KEY", secret) + monkeypatch.setenv("OPENAI_MODEL", "forwarded-model") + monkeypatch.delenv("NOT_SET_ANYWHERE", raising=False) + + assert docker.run_in_container( + [], cwd=str(workspace), image="test-image", + forward_env=("OPENAI_API_KEY", "OPENAI_MODEL", "NOT_SET_ANYWHERE", "OPENAI_API_KEY"), + ) == 0 + + command = calls[0] + image_at = command.index("test-image") + flags = command[:image_at] + assert flags.count("OPENAI_API_KEY") == 1 # deduplicated + assert flags[flags.index("OPENAI_API_KEY") - 1] == "-e" + assert "OPENAI_MODEL" in flags + assert "NOT_SET_ANYWHERE" not in flags # unset names are skipped + assert secret not in " ".join(command) # value never on argv + assert "forwarded-model" not in " ".join(command) + + +def test_forwarded_names_follow_the_checkout_env_file(monkeypatch, tmp_path) -> None: + # ``-e NAME`` must come after ``--env-file`` so the host-resolved value + # (exported environment first) outranks the checkout's file, as it does + # for a native run. + workspace = tmp_path / "project" + workspace.mkdir() + calls = _stub_container(monkeypatch, tmp_path) + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / ".env").write_text("OPENAI_MODEL=file-model\n", encoding="utf-8") + monkeypatch.setattr(docker, "_REPO_ROOT", checkout) + monkeypatch.setenv("OPENAI_MODEL", "exported-model") + + assert docker.run_in_container( + [], cwd=str(workspace), image="test-image", forward_env=("OPENAI_MODEL",), + ) == 0 + + command = calls[0] + assert command.index("--env-file") < command.index("OPENAI_MODEL") + assert command[command.index("--env-file") + 1] == str(checkout / ".env") diff --git a/apodex/tests/test_global_install.py b/apodex/tests/test_global_install.py new file mode 100644 index 0000000..2f39e0f --- /dev/null +++ b/apodex/tests/test_global_install.py @@ -0,0 +1,337 @@ +"""A built wheel, installed as a ``uv tool``, launched from unrelated directories. + +This is the one test that proves the "install once, launch anywhere" promise +with the real artifact: no source ``PYTHONPATH``, a throwaway ``HOME``, a +synthetic credential, and a local stub endpoint standing in for the model. +``--help`` alone would pass with a broken package; these runs go through +profile loading, the packaged provider registry, workflow dispatch, a tool +call against the project, and the configuration precedence. + +``--native`` is passed so the same run works on a macOS runner with Docker +Desktop up; the platform defaults themselves are pinned separately in +``test_cli_runtime_selection.py``. + +Skipped when ``uv`` is not on PATH. ``uv build`` and ``uv tool install`` reuse +the ambient uv cache, so a warmed CI runner installs in seconds. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from deploy.huggingface.mock_llm import MockLLMServer, text_turn, tool_call_turn + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SECRET = "sk-synthetic-install-test-key-4242" + +pytestmark = pytest.mark.skipif( + shutil.which("uv") is None, + reason="uv is not on PATH", +) + + +@dataclass(frozen=True) +class Installed: + binary: Path # /frontier-agent + python: Path # the tool environment's own interpreter + wheel: Path + + +@pytest.fixture(scope="module") +def installed(tmp_path_factory) -> Installed: + """A wheel from this checkout, installed into an isolated tool directory.""" + root = tmp_path_factory.mktemp("global-install") + dist, tool_dir, bin_dir = root / "dist", root / "tools", root / "bin" + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(dist)], + cwd=_REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + wheel = next(dist.glob("frontier_agent-*.whl")) + env = {**os.environ, "UV_TOOL_DIR": str(tool_dir), "UV_TOOL_BIN_DIR": str(bin_dir)} + python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + subprocess.run( + ["uv", "tool", "install", "--python", python_version, str(wheel)], + env=env, + check=True, + capture_output=True, + text=True, + ) + binary = bin_dir / "frontier-agent" + assert binary.exists(), sorted(bin_dir.iterdir()) + assert (bin_dir / "apodex").exists() # the compatibility alias + # uv lays the tool environment out as //bin/python; the + # console script's shebang is the authoritative pointer to it. + shebang = binary.read_text(encoding="utf-8", errors="replace").splitlines()[0] + python = Path(shebang.removeprefix("#!").strip()) + assert python.exists(), shebang + assert tool_dir in python.parents + return Installed(binary=binary, python=python, wheel=wheel) + + +def _launch( + installed: Installed, + args: list[str], + *, + cwd: Path, + home: Path, + extra: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run the installed CLI with a deliberately minimal environment.""" + system_path = os.pathsep.join( + p for p in ("/usr/bin", "/bin", "/usr/sbin", "/sbin") if Path(p).is_dir() + ) + env = { + "PATH": f"{installed.binary.parent}{os.pathsep}{system_path}", + "HOME": str(home), + "TERM": "dumb", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PYTHONIOENCODING": "utf-8", + **(extra or {}), + } + assert "PYTHONPATH" not in env + return subprocess.run( + [str(installed.binary), *args], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=300, + ) + + +def _tool_result_texts(payload: dict) -> list[str]: + return [ + str(m.get("content") or "") for m in payload.get("messages", []) if m.get("role") == "tool" + ] + + +def _project(root: Path, name: str) -> Path: + project = root / name + project.mkdir() + return project + + +# A ``bash`` turn: where model commands run, and which ``python3`` they get. +# ``pwd`` names the run-private scratch directory, which lives under the +# project's ``.apodex/runs//workspace``; the heredoc proves the +# interpreter is the tool environment (its ``sys.prefix`` plus a dependency +# a bare system Python does not have), not whatever ``/usr/bin`` offers. +_PROBE = "pwd\npython3 <<'PY'\nimport sys, textual\nprint('PREFIX=' + sys.prefix)\nPY\n" + + +def _probe_script(answer: str) -> list: + return [tool_call_turn("bash", {"command": _PROBE}), text_turn(answer)] + + +def _session_records(project: Path) -> list[dict]: + runs = project / ".apodex" / "runs" + return [ + json.loads(path.read_text(encoding="utf-8")) for path in sorted(runs.glob("*/session.json")) + ] + + +def _assert_task_ran_in(project: Path, installed: Installed, requests: list[dict]) -> None: + """The run was bound to *project* and its tools saw the tool environment.""" + assert len(requests) >= 2, [r.get("model") for r in requests] + results = _tool_result_texts(requests[1]) + assert any(f"PREFIX={installed.python.parent.parent}" in t for t in results), results + real_project = Path(os.path.realpath(project)) + assert any( + Path(line.strip()).is_relative_to(real_project) + for t in results + for line in t.splitlines() + if line.startswith("/") + ), results + # The session record is the CLI's own statement of the workspace. + records = _session_records(project) + assert records, sorted((project / ".apodex").rglob("*")) + assert Path(os.path.realpath(records[-1]["cwd"])) == real_project + + +def _write_user_env(home: Path, base_url: str) -> Path: + user_env = home / ".config" / "apodex" / "env" + user_env.parent.mkdir(parents=True, exist_ok=True) + user_env.write_text( + f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL={base_url}\nOPENAI_MODEL=user-file-model\n", + encoding="utf-8", + ) + user_env.chmod(0o600) + return user_env + + +# ── the artifact ────────────────────────────────────────────────────────── + + +def test_wheel_carries_the_runtime_resources_and_no_dockerfile(installed) -> None: + names = set(zipfile.ZipFile(installed.wheel).namelist()) + + for required in ( + "frontier_agent/infra/providers.yaml", # packaged provider registry + "frontier_agent/model_registry.yaml", + "apodex/profiles/react.yaml", + "apodex/profiles/agent_team.yaml", + "workflows/stateful_react_agent/profiles/tui.yaml", + "workflows/agent_team/profiles/tui.yaml", + ): + assert required in names, required + # No image can be built from site-packages, which is why the Docker path + # needs a checkout or an explicit context outside one. + assert not any(name.endswith("Dockerfile") for name in names) + + +def test_installed_package_imports_without_the_checkout(installed, tmp_path) -> None: + """Every runtime package imports from site-packages alone.""" + probe = ( + "import json, apodex.cli, apodex.docker, apodex.userenv, " + "workflows.stateful_react_agent, workflows.agent_team, plugins.tools, " + "frontier_agent.infra.providers as p; " + "print(json.dumps({'providers': str(p._providers_path()), " + "'names': sorted(p.load_providers())}))" + ) + result = subprocess.run( + [str(installed.python), "-c", probe], + cwd=tmp_path, + capture_output=True, + text=True, + env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin"}, + ) + + assert result.returncode == 0, result.stderr + info = json.loads(result.stdout) + assert info["providers"].endswith("frontier_agent/infra/providers.yaml") + assert "openai" in info["names"] + + +def test_version_runs_without_any_configuration(installed, tmp_path) -> None: + launch = tmp_path / "anywhere" + launch.mkdir() + + result = _launch(installed, ["--version"], cwd=launch, home=tmp_path / "user-home") + + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("FrontierAgent ") + + +# ── the promised UX: cd into a project and run the bare command ────────── + + +def test_bare_launch_in_two_projects_shares_one_user_env_file(installed, tmp_path) -> None: + home = tmp_path / "user-home" + home.mkdir() + first = _project(tmp_path, "first") + second = _project(tmp_path, "second") + + with MockLLMServer(script=_probe_script("first done"), require_auth=True) as server: + _write_user_env(home, server.base_url) + result = _launch( + installed, + ["--native", "--no-tui", "--yes", "-p", "where am I"], + cwd=first, + home=home, # no --cwd, no --model, nothing exported + ) + first_requests = server.requests + + assert result.returncode == 0, result.stderr + assert "first done" in result.stdout + assert _SECRET not in result.stdout + result.stderr + assert first_requests[0]["model"] == "user-file-model" + _assert_task_ran_in(first, installed, first_requests) + assert not (second / ".apodex").exists() + + with MockLLMServer(script=_probe_script("second done"), require_auth=True) as server: + _write_user_env(home, server.base_url) # same file, new stub port + result = _launch( + installed, + ["--native", "--no-tui", "--yes", "-p", "where am I"], + cwd=second, + home=home, + ) + second_requests = server.requests + + assert result.returncode == 0, result.stderr + assert "second done" in result.stdout + assert second_requests[0]["model"] == "user-file-model" + _assert_task_ran_in(second, installed, second_requests) + # Native mode redirected HOME under each project only after the user file + # had been read from the real one: nothing else was written to it. + assert sorted(p.name for p in home.iterdir()) == [".config"] + + +def test_model_flag_outranks_the_user_env_file(installed, tmp_path) -> None: + home = tmp_path / "user-home" + home.mkdir() + project = _project(tmp_path, "flagged") + + with MockLLMServer(script=[text_turn("flag honoured")], require_auth=True) as server: + _write_user_env(home, server.base_url) + result = _launch( + installed, + ["--native", "--no-tui", "--model", "flag-model", "-p", "hello"], + cwd=project, + home=home, + ) + requests = server.requests + + assert result.returncode == 0, result.stderr + assert "flag honoured" in result.stdout + assert requests and requests[0]["model"] == "flag-model" + + +# ── explicit --cwd from an unrelated directory, exported credentials ────── + + +def test_exported_credentials_and_cwd_from_an_unrelated_directory(installed, tmp_path) -> None: + launch = tmp_path / "launch" # unrelated to both the checkout and the project + launch.mkdir() + home = tmp_path / "user-home" + home.mkdir() + project = _project(tmp_path, "target") + + with MockLLMServer(script=_probe_script("target done"), require_auth=True) as server: + result = _launch( + installed, + ["--native", "--no-tui", "--yes", "--cwd", str(project), "-p", "where am I"], + cwd=launch, + home=home, + extra={ + "OPENAI_API_KEY": _SECRET, + "OPENAI_BASE_URL": server.base_url, + "OPENAI_MODEL": server.model, + }, + ) + requests = server.requests + + assert result.returncode == 0, result.stderr + assert "target done" in result.stdout + assert _SECRET not in result.stdout + result.stderr + assert requests[0]["model"] == server.model # the exported model, via the profile + _assert_task_ran_in(project, installed, requests) + assert not (launch / ".apodex").exists() + + +def test_resume_listing_needs_no_credentials(installed, tmp_path) -> None: + project = tmp_path / "project" + project.mkdir() + + result = _launch( + installed, + ["--native", "--no-tui", "--resume"], + cwd=project, + home=tmp_path / "user-home", + ) + + assert result.returncode == 0, result.stderr + assert "No saved sessions." in result.stdout diff --git a/apodex/tests/test_native.py b/apodex/tests/test_native.py index 20c1a88..fe1fff5 100644 --- a/apodex/tests/test_native.py +++ b/apodex/tests/test_native.py @@ -7,6 +7,7 @@ from apodex import cli, docker, sandbox from apodex.native import prepare_native_runtime from apodex.sandbox import BWRAP, CONTAINER, NATIVE, Strategy, resolve_strategy +from apodex.userenv import EnvResolution from plugins.tools._sandbox import resolve_runtime_path @@ -53,6 +54,78 @@ def test_native_runtime_keeps_mutable_state_under_workspace(tmp_path) -> None: assert Path(env[key]).is_dir() +def _venv_bin() -> str: + """The running interpreter's bin dir, unresolved (a venv's python is a symlink).""" + import sys + + return os.path.abspath(os.path.dirname(sys.executable)) + + +def test_native_path_leads_with_the_cli_interpreter_for_a_global_install(tmp_path) -> None: + """``python3`` inside tools must be the CLI's environment, not the system's. + + A ``uv tool install`` puts only the console scripts on PATH. Without this, + read_file's ``python3 -`` helper ran under whatever ``/usr/bin/python3`` + is — a 3.9 on macOS, which fails on ``X | None`` at parse time. + """ + workspace = tmp_path / "project" + workspace.mkdir() + env: dict[str, str] = {"PATH": "/usr/bin:/bin"} + + root = prepare_native_runtime(str(workspace), "20260806-120000-react-ab12", environ=env) + + entries = env["PATH"].split(os.pathsep) + assert _venv_bin() in entries + # After the workspace-local bins, before whatever the shell had. + assert entries.index(_venv_bin()) > entries.index(str(root / "home" / ".local" / "bin")) + assert entries.index(_venv_bin()) < entries.index("/usr/bin") + + +def test_native_path_is_unchanged_when_the_interpreter_already_leads_it(tmp_path) -> None: + workspace = tmp_path / "project" + workspace.mkdir() + inherited = f"{_venv_bin()}:/opt/homebrew/bin:/usr/bin" + env: dict[str, str] = {"PATH": inherited} + + prepare_native_runtime(str(workspace), "20260806-120000-react-ab12", environ=env) + + # The checkout / ``uv run`` case: exactly one copy, in its original place. + assert env["PATH"].endswith(inherited) + assert env["PATH"].count(_venv_bin()) == 1 + + +def test_native_python3_is_the_cli_environment_even_behind_a_decoy(tmp_path) -> None: + """A subprocess probe: the PATH really selects the CLI's interpreter. + + The inherited PATH leads with a directory whose ``python3`` is a decoy, the + shape of a system or Homebrew interpreter sitting in front of the tool + environment. ``sys.prefix`` and a dependency the CLI environment has and a + bare interpreter does not (textual) prove which one answered. + """ + import subprocess + import sys + + decoy_bin = tmp_path / "decoy-bin" + decoy_bin.mkdir() + decoy = decoy_bin / "python3" + decoy.write_text("#!/bin/sh\necho DECOY\n", encoding="utf-8") + decoy.chmod(0o755) + workspace = tmp_path / "project" + workspace.mkdir() + env: dict[str, str] = {"PATH": f"{decoy_bin}:/usr/bin:/bin"} + + prepare_native_runtime(str(workspace), "20260806-120000-react-ab12", environ=env) + + probe = subprocess.run( + ["/bin/sh", "-c", "python3 -c 'import sys, textual; print(sys.prefix)'"], + env={"PATH": env["PATH"], "HOME": env["HOME"]}, + capture_output=True, text=True, check=False, + ) + assert probe.returncode == 0, probe.stderr + assert probe.stdout.strip() == sys.prefix + assert "DECOY" not in probe.stdout + + def test_native_strategy_is_explicitly_not_os_isolated() -> None: strategy = Strategy(NATIVE, "test") @@ -204,7 +277,7 @@ def test_macos_falls_back_to_native_when_docker_is_unavailable( ) -> None: prepared: list[tuple[str, str]] = [] monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cli, "_load_env", lambda: None) + monkeypatch.setattr(cli, "_load_env", EnvResolution.empty) monkeypatch.setattr(cli.sys, "platform", "darwin") monkeypatch.setattr( docker, "docker_available", lambda: (False, "daemon is stopped"), @@ -244,7 +317,7 @@ def test_linux_uses_native_runtime_by_default( ) -> None: prepared: list[tuple[str, str]] = [] monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cli, "_load_env", lambda: None) + monkeypatch.setattr(cli, "_load_env", EnvResolution.empty) monkeypatch.setattr(cli.sys, "platform", "linux") monkeypatch.delenv("APODEX_SANDBOX", raising=False) monkeypatch.delenv("SANDBOX_BACKEND", raising=False) @@ -271,7 +344,7 @@ def test_linux_bwrap_is_explicit_and_skips_native_runtime( prepared: list[tuple[str, str]] = [] requested: list[str | None] = [] monkeypatch.chdir(tmp_path) - monkeypatch.setattr(cli, "_load_env", lambda: None) + monkeypatch.setattr(cli, "_load_env", EnvResolution.empty) monkeypatch.setattr(cli.sys, "platform", "linux") monkeypatch.setattr( "apodex.native.prepare_native_runtime", diff --git a/apodex/tests/test_userenv.py b/apodex/tests/test_userenv.py new file mode 100644 index 0000000..36e13eb --- /dev/null +++ b/apodex/tests/test_userenv.py @@ -0,0 +1,492 @@ +"""The optional user env file and the CLI's configuration precedence. + +Every test drives the real resolver against a temporary HOME and a temporary +launch directory. None of them relies on a checkout ``.env``: the autouse +fixture in ``conftest.py`` already points HOME/XDG at ``tmp_path``. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import ClassVar + +import pytest + +from apodex import cli +from apodex.userenv import ( + USER_ENV_FILE_VAR, + EnvResolution, + apply_user_env, + load_environment, + user_env_path, +) + +_SECRET = "sk-user-file-secret-7Q9x" +_OTHER_SECRET = "sk-project-secret-Zz41" + + +def _write(path: Path, text: str, *, mode: int = 0o600) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(mode) + return path + + +@pytest.fixture +def user_file(tmp_path) -> Path: + """The default user env location under the fixture HOME.""" + home = Path(os.environ["HOME"]) + return home / ".config" / "apodex" / "env" + + +@pytest.fixture +def launch(tmp_path, monkeypatch) -> Path: + """An empty launch directory with no ``.env`` anywhere above it.""" + launch = tmp_path / "launch" + launch.mkdir() + monkeypatch.chdir(launch) + # Earlier tests may leave publish_model_overrides' variables behind in the + # real environment; the precedence assertions need a clean slate. + for name in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL", "OPENAI_MAX_TOKENS"): + monkeypatch.delenv(name, raising=False) + monkeypatch.delenv(USER_ENV_FILE_VAR, raising=False) + return launch + + +# ── location ────────────────────────────────────────────────────────────── + + +def test_default_location_follows_xdg_then_home() -> None: + assert user_env_path({"XDG_CONFIG_HOME": "/x/cfg", "HOME": "/h"}) == Path( + "/x/cfg/apodex/env", + ) + assert user_env_path({"HOME": "/h"}) == Path("/h/.config/apodex/env") + # A blank XDG value means unset, as the spec says, rather than "/apodex/env". + assert user_env_path({"XDG_CONFIG_HOME": " ", "HOME": "/h"}) == Path( + "/h/.config/apodex/env", + ) + + +def test_explicit_path_variable_wins_over_the_config_directory() -> None: + env = {USER_ENV_FILE_VAR: "~/elsewhere/agent.env", "HOME": "/h"} + assert user_env_path(env) == Path("~/elsewhere/agent.env").expanduser() + + +# ── presence and absence ────────────────────────────────────────────────── + + +def test_missing_user_file_is_not_an_error(launch) -> None: + resolution = load_environment() + + assert resolution.user_env_path is None + assert resolution.applied == () + assert resolution.notes == () + assert resolution.dotenv_paths == () + + +def test_user_file_supplies_values_nothing_else_set(launch, user_file) -> None: + _write( + user_file, + ( + f"OPENAI_API_KEY={_SECRET}\n" + "OPENAI_BASE_URL=https://user.example/v1\n" + "OPENAI_MODEL=user-model\n" + "BLANK_VALUE=\n" + "# comment\n" + ), + ) + + resolution = load_environment() + + assert resolution.user_env_path == user_file + assert set(resolution.applied) == {"OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"} + assert os.environ["OPENAI_API_KEY"] == _SECRET + assert os.environ["OPENAI_MODEL"] == "user-model" + # A blank line in the file is "not provided", not an empty credential. + assert "BLANK_VALUE" not in os.environ + assert resolution.notes == () + + +def test_user_file_is_read_literally_without_interpolation(launch, user_file, monkeypatch) -> None: + monkeypatch.setenv("SOMEWHERE_ELSE", "https://attacker.example/v1") + _write(user_file, "OPENAI_BASE_URL=${SOMEWHERE_ELSE}\n") + + load_environment() + + # dotenv would have expanded this against the exported environment; the + # user file never resolves against whatever happens to be exported. + assert os.environ["OPENAI_BASE_URL"] == "${SOMEWHERE_ELSE}" + + +@pytest.mark.skipif( + os.name != "posix" or os.geteuid() == 0, + reason="root ignores file modes", +) +def test_unreadable_user_file_is_reported_by_name_and_ignored(launch, user_file) -> None: + _write(user_file, f"OPENAI_API_KEY={_SECRET}\n", mode=0o000) + + resolution = load_environment() + + assert resolution.user_env_path == user_file + assert resolution.applied == () + assert "OPENAI_API_KEY" not in os.environ + assert len(resolution.notes) == 1 + assert str(user_file) in resolution.notes[0] + assert "ignoring it" in resolution.notes[0] + assert _SECRET not in resolution.notes[0] + + +# ── precedence ──────────────────────────────────────────────────────────── + + +def test_exported_environment_wins_over_the_user_file(launch, user_file, monkeypatch) -> None: + monkeypatch.setenv("OPENAI_MODEL", "exported-model") + _write(user_file, "OPENAI_MODEL=user-model\n") + + resolution = load_environment() + + assert os.environ["OPENAI_MODEL"] == "exported-model" + assert "OPENAI_MODEL" not in resolution.applied + + +def test_launch_directory_dotenv_wins_over_the_user_file(launch, user_file) -> None: + _write(launch / ".env", "OPENAI_MODEL=project-model\n") + _write(user_file, "OPENAI_MODEL=user-model\nOPENAI_MAX_TOKENS=1234\n") + + resolution = load_environment() + + assert os.environ["OPENAI_MODEL"] == "project-model" + assert os.environ["OPENAI_MAX_TOKENS"] == "1234" # only the file had it + assert resolution.dotenv_paths == (launch / ".env",) + assert resolution.applied == ("OPENAI_MAX_TOKENS",) + + +def test_ancestor_dotenv_is_still_discovered(launch, user_file) -> None: + _write(launch.parent / ".env", "OPENAI_MODEL=ancestor-model\n") + _write(user_file, "OPENAI_MODEL=user-model\n") + + resolution = load_environment() + + assert os.environ["OPENAI_MODEL"] == "ancestor-model" + assert [p.resolve() for p in resolution.dotenv_paths] == [ + (launch.parent / ".env").resolve(), + ] + + +def test_exported_environment_wins_over_every_file(launch, user_file, monkeypatch) -> None: + monkeypatch.setenv("OPENAI_MODEL", "exported-model") + _write(launch / ".env", "OPENAI_MODEL=project-model\n") + _write(user_file, "OPENAI_MODEL=user-model\n") + + load_environment() + + assert os.environ["OPENAI_MODEL"] == "exported-model" + + +# ── the credential / endpoint pair guard ────────────────────────────────── + + +def test_user_key_is_withheld_when_a_project_overrides_only_the_endpoint( + launch, + user_file, + capsys, +) -> None: + _write(launch / ".env", "OPENAI_BASE_URL=https://other.example/v1\n") + _write( + user_file, + ( + f"OPENAI_API_KEY={_SECRET}\n" + "OPENAI_BASE_URL=https://user.example/v1\n" + "OPENAI_MODEL=user-model\n" + ), + ) + + resolution = load_environment() + + # The key written next to user.example must not travel to other.example. + assert "OPENAI_API_KEY" not in os.environ + assert os.environ["OPENAI_BASE_URL"] == "https://other.example/v1" + assert os.environ["OPENAI_MODEL"] == "user-model" # unpaired: still applied + assert resolution.withheld == ("OPENAI_API_KEY",) + assert len(resolution.notes) == 1 + note = resolution.notes[0] + assert "OPENAI_API_KEY" in note and "OPENAI_BASE_URL" in note + assert str(user_file) in note + assert _SECRET not in note + assert "other.example" not in note # the overriding value is not echoed either + + +def test_same_endpoint_written_differently_does_not_withhold(launch, user_file) -> None: + _write(launch / ".env", "OPENAI_BASE_URL=https://user.example/v1/\n") + _write(user_file, (f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://user.example/v1\n")) + + resolution = load_environment() + + assert os.environ["OPENAI_API_KEY"] == _SECRET + assert resolution.withheld == () + + +def test_user_endpoint_is_withheld_when_a_different_key_is_already_set( + launch, + user_file, + monkeypatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", _OTHER_SECRET) + _write(user_file, (f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://user.example/v1\n")) + + resolution = load_environment() + + assert os.environ["OPENAI_API_KEY"] == _OTHER_SECRET + assert "OPENAI_BASE_URL" not in os.environ + assert resolution.withheld == ("OPENAI_BASE_URL",) + assert _SECRET not in resolution.notes[0] + assert _OTHER_SECRET not in resolution.notes[0] + + +def test_fully_overridden_pair_needs_no_note(launch, user_file) -> None: + _write( + launch / ".env", + (f"OPENAI_API_KEY={_OTHER_SECRET}\nOPENAI_BASE_URL=https://other.example/v1\n"), + ) + _write(user_file, (f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://user.example/v1\n")) + + resolution = load_environment() + + assert os.environ["OPENAI_API_KEY"] == _OTHER_SECRET + assert resolution.withheld == () + assert resolution.notes == () + + +def test_a_lone_key_in_the_user_file_is_applied_as_written(launch, user_file) -> None: + # No endpoint next to it means the user chose "this key, wherever I point + # the CLI"; the guard only protects pairs that were written as pairs. + _write(launch / ".env", "OPENAI_BASE_URL=https://other.example/v1\n") + _write(user_file, f"OPENAI_API_KEY={_SECRET}\n") + + resolution = load_environment() + + assert os.environ["OPENAI_API_KEY"] == _SECRET + assert resolution.withheld == () + + +def test_pair_guard_covers_every_provider_prefix(tmp_path) -> None: + path = _write( + tmp_path / "env", + ( + "ANTHROPIC_API_KEY=a-key\nANTHROPIC_BASE_URL=https://a.example\n" + "BEDROCK_API_KEY=b-key\nBEDROCK_BASE_URL=https://b.example\n" + ), + ) + environ = {"ANTHROPIC_BASE_URL": "https://elsewhere.example"} + + _, applied, withheld, notes, defined = apply_user_env(environ, path=path) + + assert withheld == ("ANTHROPIC_API_KEY",) + assert set(applied) == {"BEDROCK_API_KEY", "BEDROCK_BASE_URL"} + assert "ANTHROPIC_API_KEY" not in environ + assert len(notes) == 1 + assert set(defined) == { + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "BEDROCK_API_KEY", + "BEDROCK_BASE_URL", + } + + +# ── secrets stay out of every channel ───────────────────────────────────── + + +@pytest.mark.skipif(os.name != "posix", reason="file modes are POSIX") +def test_world_readable_file_gets_a_permission_note_without_its_contents( + launch, + user_file, +) -> None: + _write(user_file, f"OPENAI_API_KEY={_SECRET}\n", mode=0o644) + + resolution = load_environment() + + assert os.environ["OPENAI_API_KEY"] == _SECRET + assert any("chmod 600" in note for note in resolution.notes) + assert all(_SECRET not in note for note in resolution.notes) + + +def test_resolution_never_carries_a_value(launch, user_file) -> None: + _write(user_file, f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://u.example/v1\n") + + resolution = load_environment() + + assert _SECRET not in repr(resolution) + assert "u.example" not in repr(resolution) + + +def test_forwarded_names_are_names_only_and_only_when_set(launch, user_file, monkeypatch) -> None: + _write(user_file, f"OPENAI_API_KEY={_SECRET}\nCUSTOM_PROVIDER_TOKEN=abc\n") + monkeypatch.setenv("SERPER_API_KEY", "serper-secret") + monkeypatch.delenv("JINA_API_KEY", raising=False) + + resolution = load_environment() + names = resolution.forwarded_names() + + assert "OPENAI_API_KEY" in names # from the file + assert "CUSTOM_PROVIDER_TOKEN" in names # file-defined, even if unlisted + assert "SERPER_API_KEY" in names # well-known and exported + assert "JINA_API_KEY" not in names # well-known but not set + assert _SECRET not in " ".join(names) + + +def test_empty_resolution_forwards_only_exported_well_known_names(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_MODEL", "m") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + names = EnvResolution.empty().forwarded_names() + + assert "OPENAI_MODEL" in names + assert "OPENAI_API_KEY" not in names + + +# ── the CLI: --cwd, --model, and where .env is looked up ────────────────── + + +class _RecordingSession: + """Stands in for TerminalSession once preflight has passed.""" + + constructed: ClassVar[list[dict]] = [] + + def __init__(self, **kwargs) -> None: + type(self).constructed.append(kwargs) + self.session_id = "recorded-session" + self.history: list = [] + + async def run_task(self, task: str) -> None: + return None + + +@pytest.fixture +def cli_harness(monkeypatch, launch): + """A CLI whose session is recorded rather than run, with fresh profile caches.""" + from apodex import profiles + from frontier_agent.infra import providers + + monkeypatch.setattr(profiles, "_CACHE", {}) + providers._reset_cache() + _RecordingSession.constructed = [] + monkeypatch.setattr(cli, "TerminalSession", _RecordingSession) + for name in ("OPENAI_MAX_TOKENS", "SERPER_API_KEY", "JINA_API_KEY"): + monkeypatch.delenv(name, raising=False) + yield _RecordingSession + providers._reset_cache() + + +def _run_cli(*args: str) -> int: + return asyncio.run(cli._amain(["--no-tui", "--no-sandbox", *args])) + + +def test_dotenv_is_resolved_from_the_launch_directory_not_from_cwd( + cli_harness, + launch, + tmp_path, + capsys, +) -> None: + target = tmp_path / "target" + target.mkdir() + _write( + launch / ".env", + ( + f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://launch.example/v1\n" + "OPENAI_MODEL=launch-model\n" + ), + ) + _write( + target / ".env", + ( + f"OPENAI_API_KEY={_OTHER_SECRET}\nOPENAI_BASE_URL=https://target.example/v1\n" + "OPENAI_MODEL=target-model\n" + ), + ) + + assert _run_cli("--cwd", str(target), "-p", "task") == 0 + + cfg = cli_harness.constructed[0]["cfg"] + assert cli_harness.constructed[0]["cwd"] == str(target) + assert cfg.model == "launch-model" + assert cfg.base_url == "https://launch.example/v1" + assert cfg.api_key == _SECRET + out = capsys.readouterr() + assert _SECRET not in out.out + out.err + assert _OTHER_SECRET not in out.out + out.err + + +def test_a_target_dotenv_alone_does_not_configure_the_run( + cli_harness, + launch, + tmp_path, + capsys, +) -> None: + target = tmp_path / "target" + target.mkdir() + _write( + target / ".env", + ( + f"OPENAI_API_KEY={_OTHER_SECRET}\nOPENAI_BASE_URL=https://target.example/v1\n" + "OPENAI_MODEL=target-model\n" + ), + ) + + assert _run_cli("--cwd", str(target), "-p", "task") == 2 + + assert cli_harness.constructed == [] + err = capsys.readouterr().err + assert "preflight failed" in err + assert "OPENAI_API_KEY" in err + assert _OTHER_SECRET not in err + + +def test_cli_flags_outrank_every_file_and_the_user_file_fills_the_rest( + cli_harness, + launch, + user_file, + tmp_path, + monkeypatch, +) -> None: + target = tmp_path / "target" + target.mkdir() + _write( + user_file, + ( + f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://user.example/v1\n" + "OPENAI_MODEL=user-model\n" + ), + ) + _write(launch / ".env", "OPENAI_MODEL=project-model\n") + + assert _run_cli("--cwd", str(target), "--model", "flag-model", "-p", "task") == 0 + + cfg = cli_harness.constructed[0]["cfg"] + assert cfg.model == "flag-model" # explicit option + assert cfg.api_key == _SECRET # user file default + assert cfg.base_url == "https://user.example/v1" + # The workflow reads the model from the environment; the flag reached it. + assert os.environ["OPENAI_MODEL"] == "flag-model" + + +def test_withheld_key_explains_the_preflight_failure_once( + cli_harness, + launch, + user_file, + tmp_path, + capsys, +) -> None: + _write( + launch / ".env", ("OPENAI_BASE_URL=https://other.example/v1\nOPENAI_MODEL=project-model\n") + ) + _write(user_file, (f"OPENAI_API_KEY={_SECRET}\nOPENAI_BASE_URL=https://user.example/v1\n")) + + assert _run_cli("-p", "task") == 2 + + err = capsys.readouterr().err + assert err.count("was not applied") == 1 # printed once, not per channel + assert err.index("was not applied") < err.index("preflight failed") + assert _SECRET not in err + assert cli_harness.constructed == [] diff --git a/apodex/userenv.py b/apodex/userenv.py new file mode 100644 index 0000000..71796d0 --- /dev/null +++ b/apodex/userenv.py @@ -0,0 +1,315 @@ +"""Environment resolution for the CLI, including the optional user env file. + +A checkout keeps its endpoint in ``/.env``. An installation that +lives outside any checkout (``uv tool install``) has no such file to fall back +on, so credentials may also come from one user-level file: + + ``$XDG_CONFIG_HOME/apodex/env`` (default ``~/.config/apodex/env``) + +Precedence, highest first: + +1. explicit CLI options (``--model``, ``--max-tokens`` …); +2. the exported environment; +3. ``.env`` in the launch directory or the nearest ancestor that has one + (the pre-existing behaviour, unchanged); +4. the user env file. + +The file is plain ``KEY=value`` lines. It is read literally: no ``${VAR}`` +interpolation, so a value can never resolve against whatever happens to be +exported. Blank values are ignored. Nothing here prints or logs a value — +every note names variables and files only. + +Credential and endpoint pairs (``_API_KEY`` / ``_BASE_URL``) +defined together in the user file are applied together. If a higher source +already fixes one half to a different value, the other half is withheld and +reported: a key that was written down next to one endpoint must not be sent to +another one just because a project ``.env`` overrode the URL. +""" + +from __future__ import annotations + +import contextlib +import os +import stat +from collections.abc import Mapping, MutableMapping +from dataclasses import dataclass +from pathlib import Path + +USER_ENV_FILE_VAR = "APODEX_ENV_FILE" +_API_KEY_SUFFIX = "_API_KEY" +_BASE_URL_SUFFIX = "_BASE_URL" + +# Names the Docker launcher forwards from the resolved host environment when +# they are set, in addition to whatever the loaded env files define. These are +# the runtime variables ``.env.example`` documents plus the ones the shipped +# profiles interpolate, so an exported value reaches the container the same +# way it reaches a native run. +FORWARDED_RUNTIME_VARS: tuple[str, ...] = ( + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "OPENAI_PROVIDER", + "OPENAI_MAX_TOKENS", + "OPENAI_CONTEXT_WINDOW", + "APODEX_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "BEDROCK_API_KEY", + "BEDROCK_BASE_URL", + "JUDGE_API_KEY", + "JUDGE_BASE_URL", + "JUDGE_MODEL", + "HF_TOKEN", + "OFFICEQA_DOC_MODE", + "FRONTIER_AGENT_DATASETS_DIR", + "SERPER_API_KEY", + "SERPER_BASE_URL", + "JINA_API_KEY", + "JINA_BASE_URL", + "SUMMARY_LLM_API_KEY", + "SUMMARY_LLM_BASE_URL", + "SUMMARY_LLM_MODEL_NAME", + "READDOC_VISION_URL", + "READDOC_VISION_MODEL", + "READDOC_VISION_KEY", + "READDOC_OCR_URL", + "READDOC_OCR_KEY", + "REACT_NO_WEB", + "SWARM_NO_WEB", +) + + +@dataclass(frozen=True) +class EnvResolution: + """What the CLI's environment load did, without any of the values.""" + + #: The user env file that was read, or ``None`` when there is none. + user_env_path: Path | None + #: ``.env`` files loaded from the launch directory or its ancestors. + dotenv_paths: tuple[Path, ...] + #: Variables the user env file supplied (they were not set before). + applied: tuple[str, ...] + #: Variables the user env file defined but the pair guard withheld. + withheld: tuple[str, ...] + #: Human-facing, secret-free notes for stderr / the TUI transcript. + notes: tuple[str, ...] + #: Every variable any loaded file defined, for the Docker launcher. + file_names: tuple[str, ...] + + @classmethod + def empty(cls) -> EnvResolution: + """The resolution of a run that loaded nothing (tests stub this in).""" + return cls(None, (), (), (), (), ()) + + def forwarded_names(self, environ: Mapping[str, str] | None = None) -> tuple[str, ...]: + """Names worth carrying into a container, restricted to what is set. + + Only names, never values: the launcher passes them as ``-e NAME`` so + Docker reads each value from the process environment rather than + from a command line that ``ps`` could show. + """ + env = os.environ if environ is None else environ + candidates = dict.fromkeys((*self.file_names, *FORWARDED_RUNTIME_VARS)) + return tuple(name for name in candidates if name in env) + + +def user_env_path(environ: Mapping[str, str] | None = None) -> Path: + """Where the user env file lives for this process. + + ``APODEX_ENV_FILE`` names it outright. Otherwise it is the ``apodex`` + directory under ``XDG_CONFIG_HOME``, falling back to ``~/.config``, which + is the same namespace the CLI already uses for ``settings.json``. + """ + env = os.environ if environ is None else environ + override = (env.get(USER_ENV_FILE_VAR) or "").strip() + if override: + return Path(override).expanduser() + xdg = (env.get("XDG_CONFIG_HOME") or "").strip() + if xdg: + base = Path(xdg).expanduser() + else: + home = (env.get("HOME") or "").strip() + base = (Path(home).expanduser() if home else Path.home()) / ".config" + return base / "apodex" / "env" + + +def _read_env_file(path: Path) -> dict[str, str]: + """Parse ``path`` literally: no interpolation, blank values dropped.""" + from dotenv import dotenv_values + + values: dict[str, str] = {} + for name, value in dotenv_values(path, interpolate=False).items(): + if not name or value is None: + continue + stripped = value.strip() + if stripped: + values[str(name)] = stripped + return values + + +def _same_endpoint(a: str, b: str) -> bool: + return a.strip().rstrip("/") == b.strip().rstrip("/") + + +def _pairs(names: set[str]) -> list[tuple[str, str]]: + """``(

_API_KEY,

_BASE_URL)`` for every prefix that has both.""" + out: list[tuple[str, str]] = [] + for name in sorted(names): + if not name.endswith(_API_KEY_SUFFIX): + continue + prefix = name[: -len(_API_KEY_SUFFIX)] + url_name = f"{prefix}{_BASE_URL_SUFFIX}" + if url_name in names: + out.append((name, url_name)) + return out + + +def _permission_note(path: Path) -> str | None: + """A warning when other users could read the file; POSIX only.""" + try: + mode = path.stat().st_mode + except OSError: + return None + if os.name != "posix": + return None + if mode & (stat.S_IRWXG | stat.S_IRWXO): + return ( + f"{path} is readable by other users on this machine; restrict it with: chmod 600 {path}" + ) + return None + + +def apply_user_env( + environ: MutableMapping[str, str], + *, + path: Path | None = None, +) -> tuple[Path | None, tuple[str, ...], tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + """Layer the user env file under ``environ`` (never over it). + + Returns ``(path_or_None, applied, withheld, notes, defined_names)``. A + missing file is not an error: it is simply the checkout-only setup. + """ + target = path if path is not None else user_env_path(environ) + if not target.is_file(): + return None, (), (), (), () + + try: + values = _read_env_file(target) + except (OSError, UnicodeDecodeError) as exc: + # Ignored rather than fatal, but said out loud: a run that silently + # fell back to "no credentials" would fail one step later with a + # message that never mentions the file. + return ( + target, + (), + (), + (f"could not read {target} ({exc.__class__.__name__}: {exc}); ignoring it",), + (), + ) + + notes: list[str] = [] + withheld: set[str] = set() + for key_name, url_name in _pairs(set(values)): + key_fixed = key_name in environ + url_fixed = url_name in environ + if key_fixed and url_fixed: + continue # the pair is fully decided elsewhere; nothing to apply + if url_fixed and not _same_endpoint(environ[url_name], values[url_name]): + withheld.add(key_name) + notes.append( + f"{key_name} from {target} was not applied: {url_name} is set " + "to a different endpoint by the environment or a .env file. " + f"Set {key_name} alongside that {url_name}, or remove the " + "override, so a key is only sent to the endpoint it was " + "written next to." + ) + elif key_fixed and environ[key_name] != values[key_name]: + withheld.add(url_name) + notes.append( + f"{url_name} from {target} was not applied: {key_name} is " + "already set to a different value by the environment or a " + f".env file. Set {url_name} alongside that {key_name} if the " + "two belong together." + ) + + applied: list[str] = [] + for name, value in values.items(): + if name in withheld or name in environ: + continue + environ[name] = value + applied.append(name) + + permission = _permission_note(target) + if permission: + notes.append(permission) + return ( + target, + tuple(applied), + tuple(sorted(withheld)), + tuple(notes), + tuple(values), + ) + + +def load_environment() -> EnvResolution: + """Resolve ``.env`` files and the user env file into ``os.environ``. + + Deliberately bound to the real process: ``load_dotenv`` only writes to + ``os.environ`` and ``find_dotenv`` only walks up from the process cwd, so + accepting a mapping or a directory here would promise an isolation the + call cannot keep. Tests set the environment and ``chdir`` instead. + + Runs before any ``--cwd`` chdir and before native mode rewrites ``HOME`` + and the XDG directories, so both the launch directory's ``.env`` and the + user's real config directory are what get read. ``override=False`` + throughout: an exported variable always wins over every file. + """ + dotenv_paths: list[Path] = [] + file_names: dict[str, None] = {} + try: + from dotenv import find_dotenv, load_dotenv + except ImportError: + load_dotenv = None # type: ignore[assignment] + find_dotenv = None # type: ignore[assignment] + + if load_dotenv is not None and find_dotenv is not None: + local = Path.cwd() / ".env" + candidates: list[Path] = [] + if local.is_file(): + candidates.append(local) + found = find_dotenv(usecwd=True) + if found: + found_path = Path(found) + if found_path.resolve() not in {c.resolve() for c in candidates}: + candidates.append(found_path) + for candidate in candidates: + # Same call the CLI has always made: dotenv semantics, including + # interpolation, are unchanged for a project's own .env. + load_dotenv(candidate, override=False) + dotenv_paths.append(candidate) + # Names only, for the Docker launcher. ``load_dotenv`` already + # tolerated an unparsable line; recording the names must not be + # stricter than the load itself was. + with contextlib.suppress(OSError, UnicodeDecodeError): + file_names.update(dict.fromkeys(_read_env_file(candidate))) + + path, applied, withheld, notes, defined = apply_user_env(os.environ) + file_names.update(dict.fromkeys(defined)) + return EnvResolution( + user_env_path=path, + dotenv_paths=tuple(dotenv_paths), + applied=applied, + withheld=withheld, + notes=notes, + file_names=tuple(file_names), + ) + + +__all__ = [ + "FORWARDED_RUNTIME_VARS", + "USER_ENV_FILE_VAR", + "EnvResolution", + "apply_user_env", + "load_environment", + "user_env_path", +] diff --git a/docs/README.md b/docs/README.md index 1aea7d3..f982f1b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ useful when changing that subsystem rather than when getting started. |---|---| | Understand the project and run a first task | [Project README](../README.md#quick-start) | | Run the TUI with an existing LLM endpoint | [English quickstart](install/tui-endpoint-quickstart.md) / [中文教程](install/tui-endpoint-quickstart.zh-CN.md) | +| Install the CLI once and run it from any project | [Global install](install/global-install.md) | | Learn the TUI panes, previews, approvals, and live steering | [English user guide](tui-user-guide.md) / [中文使用教程](tui-user-guide.zh-CN.md) | | Install on any environment | [Installation and deployment](#installation-and-deployment) | | Run FrontierAgent from a container | [Docker and Compose](install/docker.md) | @@ -27,6 +28,7 @@ container—instead of asking you to guess from the operating system alone. | Environment | Canonical guide | |---|---| | macOS or Linux, fastest hosted-endpoint TUI setup | [English quickstart](install/tui-endpoint-quickstart.md) / [中文教程](install/tui-endpoint-quickstart.zh-CN.md) | +| macOS or Linux, one `uv tool install` and a bare `frontier-agent` in any project | [Global install](install/global-install.md) | | macOS, hosted or remote endpoint | [macOS](install/macos.md) / [中文详细版](install/macos.zh-CN.md) | | Linux, hosted or remote endpoint | [Linux](install/linux.md) | | Windows | [WSL2 section in the Linux guide](install/linux.md#windows-and-wsl2) | @@ -117,6 +119,8 @@ second set of commands: - root `README.md`: product story, capabilities, short quick start, and results; - `docs/install/`: environment-specific installation and deployment; +- `docs/install/global-install.md`: the `uv tool install` path, the user env + file and its precedence, and the Docker step for an installed tool; - `docs/install/docker.md`: Compose, image pinning, `docker run`, and cloud deployment of the CPU agent container; - `.env.example`: the runtime agent, web-tool, and document-reader variables, diff --git a/docs/install/README.md b/docs/install/README.md index 9a1f0da..5974c0a 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -11,6 +11,10 @@ on macOS or Linux, use the copy-and-run [English quickstart](tui-endpoint-quicks or [中文教程](tui-endpoint-quickstart.zh-CN.md). It does not deploy a model or require Docker. +To install the command once and run `frontier-agent` from inside any project, +without keeping a checkout around, use +[Install once and launch from any project](global-install.md). + ## Three questions 1. **Do you need to run the model on a local NVIDIA GPU?** @@ -32,6 +36,7 @@ require Docker. | Your environment | FrontierAgent | Model runtime | Guide | |---|---|---|---| | macOS laptop or desktop | native, optionally Docker | hosted/remote endpoint | [macOS](macos.md) | +| macOS or Linux, the CLI as a globally installed tool | `uv tool install`, native or Docker | hosted/remote endpoint | [Global install](global-install.md) | | Linux laptop, server, or CI without a local model | `scripts/run-linux.sh` (native, bubblewrap, or Docker) | hosted/remote endpoint | [Linux](linux.md) | | Any host with Docker and no local Python environment | published agent container | hosted/remote endpoint | [Docker and Compose](docker.md) | | Linux bare metal or VM with an NVIDIA GPU and Docker daemon | native or agent container | SGLang container | [Linux NVIDIA + Docker](linux-nvidia.md) | diff --git a/docs/install/global-install.md b/docs/install/global-install.md new file mode 100644 index 0000000..7430466 --- /dev/null +++ b/docs/install/global-install.md @@ -0,0 +1,199 @@ +# Install once and launch from any project + +[Documentation index](../README.md) · [Installation chooser](README.md) + +This page is for people who want to run `frontier-agent` the way they run any +other command-line tool. Install it once with `uv`. Open a terminal in a +project. Run `frontier-agent`. The TUI opens against that directory. + +The installation and its Python dependencies live in uv's tool directory. They +do not depend on a repository checkout and they do not touch the projects you +run the agent in. Everything below was verified with a wheel built from this +repository and installed with the commands shown. Install directly from this +repository, or from a local clone of it. + +The clone-and-`uv sync` workflow in the [quickstart](tui-endpoint-quickstart.md) +keeps working unchanged. Use it when you develop FrontierAgent itself. + +## 1. Install the tool + +You need Git and [uv](https://docs.astral.sh/uv/getting-started/installation/). +uv downloads a Python 3.12 for the tool if the machine has none. + +```bash +uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git +``` + +From a local clone instead: + +```bash +uv tool install --python 3.12 /path/to/FrontierAgent +``` + +Either command installs two executables, `frontier-agent` and its +compatibility alias `apodex`, into uv's tool bin directory. + +### If the command is not found + +uv prints a warning when its bin directory is not on `PATH`. Fix it once: + +```bash +uv tool update-shell +``` + +Then open a new terminal. To see the directory it is talking about, run +`uv tool dir --bin` and add it to `PATH` yourself if you prefer. + +## 2. Configure the endpoint once + +FrontierAgent is a bring-your-own-key tool. There is no login command and the +TUI never asks for or displays a key. Credentials come from environment +variables. A globally installed tool has no `.env` next to it, so it also reads +one optional user file: + +```text +$XDG_CONFIG_HOME/apodex/env # $HOME/.config/apodex/env when XDG_CONFIG_HOME is unset +``` + +Create it with the same three values the quickstart puts into `.env`. The +commands below respect `XDG_CONFIG_HOME` and fall back to `~/.config`: + +```bash +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/apodex" +mkdir -p "$config_dir" +cat > "$config_dir/env" <<'EOF' +OPENAI_API_KEY=your-key +OPENAI_BASE_URL=https://your-openai-compatible-endpoint/v1 +OPENAI_MODEL=your-model-name +EOF +chmod 600 "$config_dir/env" +``` + +Optional web tools take `SERPER_API_KEY` and `JINA_API_KEY` in the same file. +`APODEX_ENV_FILE=/path/to/file` points the CLI at a different file. + +Rules for the file: + +- plain `KEY=value` lines, comments with `#`; +- values are read literally. `${VAR}` is not expanded, so a value can never + resolve against something that happens to be exported; +- blank values are ignored; +- the CLI warns when the file is readable by other users and tells you the + `chmod` to run; +- the file is optional. Exported variables alone are enough. + +### Precedence + +When the same variable is set in several places, the first match wins: + +| Priority | Source | +|---|---| +| 1 | explicit CLI options such as `--model` and `--max-tokens` | +| 2 | variables exported in the shell | +| 3 | `.env` in the directory you launch from, or the nearest parent that has one | +| 4 | the user file above | + +Row 3 is the behaviour the checkout workflow has always had, and it is why a +project can pin its own `OPENAI_MODEL` in a local `.env` while the key stays in +your user file. `.env` files are only looked up from the launch directory, not +from a directory given with `--cwd`. + +### A key stays with its endpoint + +If the user file defines `OPENAI_API_KEY` and `OPENAI_BASE_URL` together, the +CLI treats them as a pair. When a project `.env` or an exported variable sets +`OPENAI_BASE_URL` to a different endpoint but provides no key, the key from the +user file is withheld. Startup then reports that the key is missing and names +the file and the variable, without printing either value. Add the key next to +that endpoint, or remove the override. The same rule applies to every +`_API_KEY` / `_BASE_URL` pair. A key defined on its own, with +no endpoint next to it, is applied wherever the CLI points. + +## 3. Launch from a project + +```bash +cd /path/to/project + +frontier-agent # Stateful ReAct, full-screen TUI +frontier-agent --mode agent_team # coordinator plus parallel sub-agents +frontier-agent -p "explain src/main.py" # one-shot, prints, exits +frontier-agent --cwd /other/project # another project without leaving this shell +frontier-agent --resume # list this project's saved sessions +``` + +Run records, traces, and deliverables stay under `/.apodex/`, exactly +as they do for a checkout launch. See +[run artifacts and timestamps](../run-artifacts.md). + +On Linux the agent's commands run in the workspace-local native runtime by +default. Native mode is not an operating-system sandbox. Approved commands run +with your user's permissions, and the `python3` the tools see is the tool's own +Python environment. + +## 4. macOS and Docker + +On macOS the CLI prefers a container whenever a Docker daemon is reachable, and +falls back to native mode when it is not. That preference is unchanged for a +global install. What changes is that the installed tool carries no +`Dockerfile`, so it cannot build the `apodex:local` image by itself. Do one of +these once: + +```bash +# a) build the image from a clone; later launches reuse it +git clone https://github.com/ApodexAI/FrontierAgent.git +docker build -t apodex:local FrontierAgent + +# b) or let frontier-agent build from a clone when the image is missing +export APODEX_BUILD_CONTEXT=/path/to/FrontierAgent + +# c) or name an image you are already able to pull +export APODEX_IMAGE=registry.example/your-org/frontieragent:tag +``` + +Without one of these, a launch that would have entered the container stops and +prints the same three options plus `--native`. It does not fall back to native +mode on its own, because you were promised a container. Pass `--native` when +you want the workspace-local runtime instead: + +```bash +frontier-agent --native +``` + +The image this repository publishes to `ghcr.io/apodexai/frontieragent` is +private. Pulling it needs a GitHub account or token that is already authorized +for that package. Signing in with `docker login ghcr.io` does not grant that +access by itself. Build from source, options a or b, unless you have it. + +Inside the container the CLI sees the values you configured. Variables from the +user file, the launch directory `.env`, and the exported environment are +forwarded by name, so the value itself never appears on a command line. + +## 5. Update and uninstall + +```bash +uv tool install --reinstall --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git +uv tool uninstall frontier-agent +``` + +`--reinstall` implies uv's `--refresh`, so the Git source is fetched again +rather than served from the cache. For a local clone, pull it and run the same +command with the clone path. + +Uninstalling removes the tool environment only. Your user file under +`apodex/` in your config directory, the session history under `~/.apodex/`, +and each project's `.apodex/` directory stay where they are until you delete +them. + +## 6. Developing FrontierAgent + +Contributors keep the checkout workflow from [CONTRIBUTING](../../CONTRIBUTING.md): + +```bash +git clone https://github.com/ApodexAI/FrontierAgent.git +cd FrontierAgent +uv sync --python 3.12 --extra dev +cp .env.example .env +uv run frontier-agent --cwd /path/to/project +``` + +Return to the [installation chooser](README.md). diff --git a/docs/install/macos.md b/docs/install/macos.md index ecadb28..f5448d8 100644 --- a/docs/install/macos.md +++ b/docs/install/macos.md @@ -81,6 +81,12 @@ the project root. not present locally is pulled, never built under that name. For Compose and `docker run` deployments, see [Run FrontierAgent in Docker](docker.md). +A `frontier-agent` installed with `uv tool install` has no repository to build +from. Build `apodex:local` once from a clone, or set +`APODEX_BUILD_CONTEXT=/path/to/FrontierAgent`, before relying on the container +path; otherwise the launch stops and lists those options together with +`--native`. Details in [Install once and launch from any project](global-install.md). + `--bwrap` is therefore not available on macOS; it reports that and names these two paths instead. diff --git a/docs/install/tui-endpoint-quickstart.md b/docs/install/tui-endpoint-quickstart.md index 0df30f3..519d80b 100644 --- a/docs/install/tui-endpoint-quickstart.md +++ b/docs/install/tui-endpoint-quickstart.md @@ -256,6 +256,11 @@ After the first setup, continue to launch from the FrontierAgent repository: ./scripts/run-linux.sh --cwd /absolute/path/to/your-project ``` +If you would rather type `frontier-agent` inside the project itself, install +the CLI once as a tool and keep the endpoint in the user env file. That setup, +its precedence rules, and the extra Docker step on macOS are in +[Install once and launch from any project](global-install.md). + Next, read the [TUI user guide](../tui-user-guide.md) for the three sidebar tabs, Space previews, approvals, and Agent Team asynchronous intervention. diff --git a/frontier_agent/infra/providers.py b/frontier_agent/infra/providers.py index 1679e71..63f5b3c 100644 --- a/frontier_agent/infra/providers.py +++ b/frontier_agent/infra/providers.py @@ -57,12 +57,38 @@ def __init__(self, name: str, available: list[str]) -> None: ) +def _packaged_default_path() -> Path | None: + """The copy of ``config/providers.yaml`` shipped inside the wheel. + + ``pyproject.toml`` force-includes the checked-in registry next to this + module, so an installation outside a checkout (``uv tool install``) still + has a provider registry to resolve ``llm.provider:`` against. ``None`` + when running from a source tree that has not been built, where the + checkout copy is the one to use anyway. + """ + try: + from importlib.resources import files + + candidate = Path(str(files(__package__).joinpath("providers.yaml"))) + except Exception: + return None + return candidate if candidate.is_file() else None + + def _providers_path() -> Path: - """Resolve the providers.yaml path, honoring the env override.""" + """Resolve the providers.yaml path, honoring the env override. + + Order: ``FRONTIER_AGENT_PROVIDERS_PATH`` → the checkout's + ``config/providers.yaml`` → the copy packaged in the wheel. The checkout + path is also what a missing-file error names, so the message stays the + same for a source tree that lost the file. + """ override = os.environ.get("FRONTIER_AGENT_PROVIDERS_PATH") if override: return Path(override) - return _DEFAULT_PATH + if _DEFAULT_PATH.is_file(): + return _DEFAULT_PATH + return _packaged_default_path() or _DEFAULT_PATH def _load_raw_yaml(*, refresh: bool = False) -> dict[str, Any]: diff --git a/pyproject.toml b/pyproject.toml index 1078d1f..7c99d3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,11 @@ packages = ["frontier_agent", "apodex", "plugins", "workflows", "benchmarks"] # that package is enforced — see pyrightconfig.json for what is currently gated. [tool.hatch.build.targets.wheel.force-include] "frontier_agent/py.typed" = "frontier_agent/py.typed" +# The provider registry lives at the repository root, outside every package. +# A checkout reads it from there; an installed wheel has no repository root, +# so the same file ships next to its loader (frontier_agent/infra/providers.py +# falls back to this copy when config/providers.yaml is absent). +"config/providers.yaml" = "frontier_agent/infra/providers.yaml" [tool.uv] # Security floors for transitive packages reported by Dependabot. Keeping these diff --git a/tests/test_providers_packaged.py b/tests/test_providers_packaged.py new file mode 100644 index 0000000..7258c95 --- /dev/null +++ b/tests/test_providers_packaged.py @@ -0,0 +1,61 @@ +"""The provider registry resolves inside an installed wheel, not only a checkout.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +from frontier_agent.infra import providers + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_checkout_registry_is_preferred_when_present(monkeypatch) -> None: + monkeypatch.delenv("FRONTIER_AGENT_PROVIDERS_PATH", raising=False) + assert providers._DEFAULT_PATH.is_file() # this is a checkout + + assert providers._providers_path() == providers._DEFAULT_PATH + + +def test_packaged_copy_is_used_when_the_checkout_file_is_absent( + monkeypatch, + tmp_path, +) -> None: + monkeypatch.delenv("FRONTIER_AGENT_PROVIDERS_PATH", raising=False) + monkeypatch.setattr(providers, "_DEFAULT_PATH", tmp_path / "missing" / "providers.yaml") + packaged = tmp_path / "site-packages" / "frontier_agent" / "infra" / "providers.yaml" + packaged.parent.mkdir(parents=True) + packaged.write_text("providers:\n openai:\n type: openai\n", encoding="utf-8") + monkeypatch.setattr(providers, "_packaged_default_path", lambda: packaged) + + assert providers._providers_path() == packaged + assert "openai" in providers.load_providers(refresh=True) + + +def test_explicit_override_still_wins_over_both(monkeypatch, tmp_path) -> None: + override = tmp_path / "custom.yaml" + override.write_text("providers: {}\n", encoding="utf-8") + monkeypatch.setenv("FRONTIER_AGENT_PROVIDERS_PATH", str(override)) + monkeypatch.setattr(providers, "_packaged_default_path", lambda: tmp_path / "unused") + + assert providers._providers_path() == override + + +def test_missing_everywhere_names_the_checkout_path(monkeypatch, tmp_path) -> None: + # A source tree that lost the file gets the same message it always did. + monkeypatch.delenv("FRONTIER_AGENT_PROVIDERS_PATH", raising=False) + absent = tmp_path / "config" / "providers.yaml" + monkeypatch.setattr(providers, "_DEFAULT_PATH", absent) + monkeypatch.setattr(providers, "_packaged_default_path", lambda: None) + + assert providers._providers_path() == absent + + +def test_wheel_ships_the_registry_next_to_its_loader() -> None: + """pyproject force-includes config/providers.yaml where the loader looks.""" + pyproject = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"] + + assert force_include["config/providers.yaml"] == "frontier_agent/infra/providers.yaml" + # The loader resolves the packaged copy relative to its own package. + assert providers.__package__ == "frontier_agent.infra"