Skip to content

Environment

dotagents._env

Chained env-file assembly + env.py execution (contract B).

Contract B, the exact sequence :func:get_environment performs:

  1. Bins onto PATH FIRST, before any env eval. Each level's bin dir (contract-A precedence order, except project-root) is prepended to PATH so env scripts can call overlay helpers by name, and ahead of them the active venv's launcher dir (:func:_launcher_path). Libs go on PYTHONPATH the same way: each level's lib dir that EXISTS (:func:get_lib_paths) is prepended to PYTHONPATH and listed, os.pathsep-joined, in AGENTS_PYTHONPATH -- so an env.py, a skill script, a bin launcher and everything they start can import an overlay's library (an overlay ships lib/<module>.py beside its cmds/), including another overlay's. The price: an overlay module comes before site-packages and the stdlib, so a lib must not reuse a package name it does not mean to replace.
  2. Two tiers, in order: ALL pre.env.py / pre.env / pre.local.env first, THEN ALL env.py / env / local.env -- the concatenation of two contract-A resolutions (:func:resolve_env_files). The project-root level resolves NOTHING: a checkout's own top-level env.py / env / local.env is never executed or sourced.
  3. Within each tier, files are in the contract-A precedence order: each store in Scope.stores -- system, user, project -- with its overlays first and itself second.
  4. Chained, later-overrides-earlier: each file is evaluated against the ACCUMULATED environment of every file before it; the result also accumulates. Later files win on conflicting keys.
  5. .py files are EXECUTED (:func:get_env_from_py runs the script as env.py --level <level> [--global] and reads back its env changes as JSON -- one object, or one object per line merged in order, so overlay-managed blocks appended to one env.py can each print their own); plain files are sourced (:func:get_env_from_file, via bash: an env -0 snapshot before and after the source, in one process).
  6. :func:get_diff returns only the vars that differ from the caller's base environment; :func:get_environment returns the full change set. Both are an :class:EnvChanges: the vars set, plus removed -- the base vars a layer unset (an env.py printing null for a key, a plain file running unset) and no later layer set again.

An env.py value must be a JSON string, or null to unset the variable; any other JSON type is skipped with a warning naming the key (it used to be str()-coerced: false became False, an array its Python repr).

The identity/proxy model is wired into the output around the file chain:

  • Identity (:func:dotagents._agents.stamp_identity) is seeded BEFORE the file chain, so env files can branch on AGENTS_HARNESS and override the stamped AGENTS_MODEL etc. (chained: a later file wins). Never clobbers a value already present in the base env -- unless explicit names the agent, in which case the identity is that agent's regardless (launch starts a harness from inside another one).
  • Roots are seeded right after identity, also before the chain. The two scope roots AGENTS_HOME / AGENTS_PROJECT_ROOT name the stores this walk actually used, as absolute paths: an inherited value naming the same directory holds, any other is replaced (in a user-scope walk, which walks no project, AGENTS_PROJECT_ROOT is only-if-unset). Only-if-unset: the interpreter AGENTS_PYTHON (sys.executable -- the Python the CLI is running under, a default any shim or helper can trust), and one <NAME>_OVERLAY_ROOT per installed overlay (:attr:Scope.overlays, the same set :func:get_overlay_roots lists, named by :attr:_overlays.Overlay.root_var -- the same name dotagents context expands as a placeholder), so an env file can reference an overlay's install dir by name. AGENTS_PYLIB (:data:PYLIB_VAR) names the .pyz when dotagents runs from one -- where its own libraries (duho, pathapi) import from for an overlay built on them; never put on PYTHONPATH.
  • Proxy is normalized AFTER the file chain: AGENTS_PROXY is seeded if unset (from AGENTS_WEBFETCH_PROXY_URL else the global HTTPS/HTTP/ALL_PROXY, either case); any proxy var that already exists is mirrored into BOTH cases (never creating one that was not set). AGENTS_PROXY is NOT fanned out into the global HTTP_PROXY. Mirroring HTTP_PROXY into http_proxy is the opposite of the httpoxy mitigation, which is for a CGI handler to IGNORE the upper-case name (a Proxy: request header arrives as HTTP_PROXY). An agent session is not a CGI handler, so there the upper-case var is the user's own setting; the chain is not meant to run inside one.

Trust model: env.py runs arbitrary code, from every store the walk visits -- the system store (only when administrators alone can write it), the user store, and the project's <project>/.agents with their overlays. A project's .agents/ is trusted like a harness's own .claude/settings.json: opening a session in a repo trusts it. The project ROOT outside .agents/ never contributes code. Security (Leakage rule): never log the resulting DOTAGENTS_*/AGENTS_* secret VALUES -- callers that print the diff must treat it as sensitive; this module logs var NAMES only.

ENV_CACHE_TTL = 300.0 module-attribute

FORMAT_ALIASES = {'export': 'export', 'posix': 'export', 'sh': 'export', 'bash': 'export', 'dotenv': 'dotenv', 'env': 'dotenv', 'powershell': 'powershell', 'pwsh': 'powershell', 'ps': 'powershell', 'cmd': 'cmd', 'bat': 'cmd', 'batch': 'cmd', 'fish': 'fish', 'json': 'json', 'ini': 'ini', 'yaml': 'yaml'} module-attribute

KNOWN_FORMATS = tuple(sorted(set(FORMAT_ALIASES) | {'auto'})) module-attribute

PYLIB_VAR = 'AGENTS_PYLIB' module-attribute

EnvChanges(*args, removed=(), **kwargs)

Bases: dict

A change set: the vars set (dict[str, str], as before) plus :attr:removed, the vars a layer unset.

A plain dict subclass, so a caller that treats the result as the vars to set keeps working; one that also honours removals reads removed (empty unless an env.py printed null for a key or a plain env file ran unset). A key is never in both.

Source code in src/dotagents/_env.py
116
117
118
119
def __init__(self, *args, removed: "Iterable[str]" = (), **kwargs):
    super().__init__(*args, **kwargs)
    #: Names to unset.
    self.removed: "set[str]" = set(removed)

removed = set(removed) instance-attribute

__repr__()

Source code in src/dotagents/_env.py
121
122
def __repr__(self) -> str:
    return "EnvChanges(%s, removed=%r)" % (dict.__repr__(self), sorted(self.removed))

apply_proxy_model(osenv)

Return the proxy changes for osenv per the proxy model.

  • Seed AGENTS_PROXY if unset, from the first populated :data:_PROXY_SEED_ORDER var (webfetch var wins, then the global proxy in either case). An existing AGENTS_PROXY is respected.
  • Mirror every proxy var that ALREADY EXISTS into both cases -- fills only the missing case, never introduces a proxy that was not set (so http_proxy is populated from HTTP_PROXY; see the module note on httpoxy).
  • AGENTS_PROXY is NOT fanned into the global HTTP_PROXY.
Source code in src/dotagents/_env.py
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
def apply_proxy_model(osenv: "dict[str, str]") -> "dict[str, str]":
    """Return the proxy changes for ``osenv`` per the proxy model.

    * Seed ``AGENTS_PROXY`` if unset, from the first populated
      :data:`_PROXY_SEED_ORDER` var (webfetch var wins, then the global proxy in
      either case). An existing ``AGENTS_PROXY`` is respected.
    * Mirror every proxy var that ALREADY EXISTS into both cases -- fills only
      the missing case, never introduces a proxy that was not set (so
      ``http_proxy`` is populated from ``HTTP_PROXY``; see the module note on
      httpoxy).
    * ``AGENTS_PROXY`` is NOT fanned into the global ``HTTP_PROXY``.
    """
    changes: "dict[str, str]" = {}

    if not osenv.get("AGENTS_PROXY"):
        for src in _PROXY_SEED_ORDER:
            val = osenv.get(src)
            if val:
                changes["AGENTS_PROXY"] = val
                break

    for base in _PROXY_BASES:
        upper, lower = base, base.lower()
        up_val = osenv.get(upper)
        lo_val = osenv.get(lower)
        if up_val and not lo_val:
            changes[lower] = up_val
        elif lo_val and not up_val:
            changes[upper] = lo_val

    return changes

detect_shell_format()

Best-effort detection of the calling shell's output format.

Walks the parent-process chain (Windows: a stdlib-ctypes Toolhelp snapshot; POSIX: /proc/<ppid>/comm else $SHELL) and returns the canonical format for the first shell found. Any failure degrades to the OS default ("powershell" on win32, "export" elsewhere) and NEVER raises. Reads process names only -- no environment values.

Source code in src/dotagents/_env.py
348
349
350
351
352
353
354
355
356
357
358
359
def detect_shell_format() -> str:
    """Best-effort detection of the calling shell's output format.

    Walks the parent-process chain (Windows: a stdlib-ctypes Toolhelp snapshot;
    POSIX: ``/proc/<ppid>/comm`` else ``$SHELL``) and returns the canonical
    format for the first shell found. Any failure degrades to the OS default
    (``"powershell"`` on win32, ``"export"`` elsewhere) and NEVER raises. Reads
    process names only -- no environment values.
    """
    if sys.platform == "win32":
        return _detect_shell_format_win()
    return _detect_shell_format_posix()

env_cache_key(scope, base_env, *parts)

A digest of everything an assembly for scope over base_env reads that can be checked without running it: the scope's roots, the cwd, the interpreter and this package's version, the installed overlays, every env-file candidate's existence/mtime/size, every file under an existing lib dir, the whole base environment, and the caller's parts (the output format and mode).

Source code in src/dotagents/_env.py
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
def env_cache_key(scope: Scope, base_env: "dict[str, str]", *parts: str) -> str:
    """A digest of everything an assembly for ``scope`` over ``base_env`` reads
    that can be checked without running it: the scope's roots, the cwd, the
    interpreter and this package's version, the installed overlays, every
    env-file candidate's existence/mtime/size, every file under an existing
    ``lib`` dir, the whole base environment, and the caller's ``parts`` (the
    output format and mode)."""
    import hashlib

    from dotagents import __version__
    from dotagents._resources import pyz_archive

    scope = _absolute_scope(scope)
    items = [
        __version__, sys.executable or "", pyz_archive() or "", os.getcwd(), scope.level,
        str(scope.user_root), str(scope.agents_root), str(scope.project_root),
        str(scope.system_root), *parts,
    ]
    items += ["overlay %s %s" % (o.name, o.path) for o in scope.overlays]
    items += [
        "%s %s %s" % (level, path, _stat_sig(path))
        for level, path, _root in scope.paths(*_ENV_FILE_NAMES, include_missing=True)
    ]
    for _level, path, _root in scope.paths({"default": "lib", "project-root": ""}, include_missing=True):
        items.append("lib %s %s" % (path, _stat_sig(path)))
        if path.is_dir():
            items += _tree_sig(path)
    # A venv sync (`_pyvenv`) changes AGENTS_PYTHON and what stays off PYTHONPATH.
    from dotagents import _pyvenv

    items += [
        "venv %s %s" % (store, _stat_sig(_pyvenv.venv_dir(store) / _pyvenv.STATE_FILE))
        for store in scope.stores
    ]
    items += sorted("%s=%s" % kv for kv in base_env.items())
    h = hashlib.sha256()
    for item in items:
        h.update(item.encode("utf-8", "surrogatepass") + b"\0")
    return h.hexdigest()

find_bash(logger=None)

The absolute path of a bash that can source a plain env file, or None. Resolved once per process against the REAL environment (not the chain's accumulated PATH, which need not contain bash's install dir).

On POSIX it is bash on PATH. On Windows a bare which is wrong whenever WindowsApps precedes Git on PATH -- the usual case in a PowerShell -- because that bash.exe is the WSL launcher; so Git's bash beside git.exe and under the standard install dirs is tried first, and every candidate must prove it is MSYS2 / Cygwin (:func:_is_windows_posix_bash). Warns once when none is found.

Source code in src/dotagents/_env.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
def find_bash(logger=None) -> "Optional[str]":
    """The absolute path of a bash that can source a plain env file, or
    ``None``. Resolved once per process against the REAL environment (not the
    chain's accumulated PATH, which need not contain bash's install dir).

    On POSIX it is ``bash`` on ``PATH``. On Windows a bare ``which`` is wrong
    whenever ``WindowsApps`` precedes Git on PATH -- the usual case in a
    PowerShell -- because that ``bash.exe`` is the WSL launcher; so Git's bash
    beside ``git.exe`` and under the standard install dirs is tried first,
    and every candidate must prove it is MSYS2 / Cygwin
    (:func:`_is_windows_posix_bash`). Warns once when none is found."""
    if not _BASH_RESOLVED:
        if os.name == "nt":
            seen: "set[str]" = set()
            resolved = None
            for cand in _windows_bash_candidates():
                key = os.path.normcase(cand)
                if key in seen or not os.path.isfile(cand):
                    continue
                seen.add(key)
                if _is_windows_posix_bash(cand):
                    resolved = cand
                    break
        else:
            resolved = shutil.which("bash", path=os.environ.get("PATH"))
        _BASH_RESOLVED.append(resolved)
    bash = _BASH_RESOLVED[0]
    if bash is None and logger and not _BASH_WARNED:
        _BASH_WARNED.append(True)
        logger.warning("no usable bash found: plain env files are skipped")
    return bash

get_bin_paths(scope)

Each level's bin dir in contract-A precedence order, EXCEPT project-root.

Uses include_missing=True: a bin dir is offered for every level even if absent, and :func:get_environment prepends ALL of them to PATH -- including the ones that do not exist (so a later-created <store>/bin is found without re-running env). project-root's bin is explicitly excluded ({"project-root": ""}): a project's own top-level bin is not an agent bin.

Source code in src/dotagents/_env.py
798
799
800
801
802
803
804
805
806
807
808
809
810
def get_bin_paths(scope: Scope) -> "list[Path]":
    """Each level's ``bin`` dir in contract-A precedence order, EXCEPT project-root.

    Uses ``include_missing=True``: a bin dir is offered for every level even
    if absent, and
    :func:`get_environment` prepends ALL of them to ``PATH`` -- including the
    ones that do not exist (so a later-created ``<store>/bin`` is found without
    re-running ``env``). project-root's ``bin`` is explicitly excluded
    (``{"project-root": ""}``): a project's own top-level ``bin`` is not an
    agent bin.
    """
    resolved = scope.paths({"default": "bin", "project-root": ""}, include_missing=True)
    return [path for _level, path, _root in resolved]

get_diff(scope, *, base_env=None, explicit=None, logger=None)

Only the assembled vars that differ from base_env (current env).

get_environment already returns changes vs base_env, so the diff is the subset whose value actually differs from the base, plus the base vars a layer unset (removed).

Source code in src/dotagents/_env.py
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
def get_diff(
    scope: Scope,
    *,
    base_env: "Optional[dict[str, str]]" = None,
    explicit: "Optional[str]" = None,
    logger=None,
) -> EnvChanges:
    """Only the assembled vars that differ from ``base_env`` (current env).

    ``get_environment`` already returns changes vs ``base_env``, so the diff is
    the subset whose value actually differs from the base, plus the base vars
    a layer unset (``removed``).
    """
    base = dict(base_env if base_env is not None else os.environ)
    full = get_environment(
        scope,
        base_env=base,
        explicit=explicit,
        logger=logger,
    )
    return EnvChanges(
        {k: v for k, v in full.items() if k not in base or base[k] != v},
        removed=(k for k in full.removed if k in base),
    )

get_env_from_file(env_file, base_env, logger=None)

Source a plain env file in bash and return the vars it changed.

Runs :data:_SOURCE_SCRIPT: an env -0 snapshot, set -a; source <file> || exit 1, a second snapshot -- in one bash process, so only what the file changed differs (an MSYS2 bash's own PATH/HOME/TEMP rewriting cancels out), with a changed *PATH value converted back to Windows form under MSYS2 / Cygwin. If no bash is found (:func:find_bash), or the source FAILS (a missing file, a directory, a syntax error, an exit in the file), the file contributes nothing -- logged by name, never fatal. A variable the file unset is in the result's removed.

Source code in src/dotagents/_env.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
def get_env_from_file(
    env_file: Path, base_env: "dict[str, str]", logger=None
) -> EnvChanges:
    """Source a plain env file in bash and return the vars it changed.

    Runs :data:`_SOURCE_SCRIPT`: an ``env -0`` snapshot, ``set -a; source
    <file> || exit 1``, a second snapshot -- in one bash process, so only what
    the file changed differs (an MSYS2 bash's own PATH/HOME/TEMP rewriting
    cancels out), with a changed ``*PATH`` value converted back to Windows form
    under MSYS2 / Cygwin. If no bash is found (:func:`find_bash`), or the
    source FAILS (a missing file, a directory, a syntax error, an ``exit`` in
    the file), the file contributes nothing -- logged by name, never fatal.
    A variable the file ``unset`` is in the result's ``removed``.
    """
    spawn = _spawn_env(base_env)

    bash = find_bash(logger)
    if bash is None:
        return EnvChanges()
    try:
        proc = subprocess.run(
            # The path is bash's `$1`, never spliced into the script: a
            # JSON-quoted path lost non-ASCII characters (`\u00e9`) and
            # expanded `$` / backticks inside the double quotes.
            [bash, "-c", _SOURCE_SCRIPT, "bash", str(env_file)],
            capture_output=True,
            text=False,
            check=False,
            env=spawn,
        )
    except OSError as e:
        if logger:
            logger.warning("cannot source env file (no bash?): %s (%s)", env_file, e)
        return EnvChanges()
    records = proc.stdout.split(b"\0")
    # A file that calls `exit` itself ends the shell before the second
    # snapshot: that is a failure too, not an empty change set.
    if proc.returncode != 0 or _SNAP_AFTER not in records:
        if logger:
            logger.warning("env file source failed: %s", env_file)
        return EnvChanges()
    cut = records.index(_SNAP_AFTER)
    tail = records[cut + 1:]
    native_at = tail.index(_SNAP_NATIVE) if _SNAP_NATIVE in tail else len(tail)
    before = _parse_env_entries(records[:cut])
    after = _parse_env_entries(tail[:native_at])
    native = _parse_env_entries(tail[native_at + 1:])

    changes = EnvChanges(removed=(k for k in before if k not in after))
    for key, value in after.items():
        if key in before and before[key] == value:
            continue
        converted = native.get("A:" + key)
        if converted is not None:
            value = _splice_native(
                converted, native.get("B:" + key), _env_lookup(spawn, key)
            )
        changes[key] = value
    return changes

get_env_from_py(env_py, base_env, *, level='', global_scope=False, logger=None)

Execute an env.py and read back its JSON object(s) of env changes.

The script runs as <python> env.py --level <level> [--global]: <level> is the contract-A level it was found at (system, user, project, or an overlay's name) and --global says the project tiers are skipped. --agent <level> is passed as well -- the old name of --level (it never named an agent), kept for scripts that read it.

The script runs as a child with base_env (the accumulated environment) and prints its changes to stdout as JSON: ONE object, or one object PER LINE, merged in order (a later line wins on a key), so a script made of independent sections can let each print its own object instead of merging them itself. (An overlay ships its own env.py at its root rather than editing the store's.) A non-zero exit or unparseable output contributes nothing and is logged by NAME only -- never abort assembly, and never echo the child's stdout (it may carry secret values).

The child runs with PYTHONIOENCODING=utf-8 and its stdout is decoded as UTF-8 with surrogateescape, so no byte it prints can fail the read. A key that cannot be an environment variable (empty, or containing = or NUL, or bytes that are not UTF-8) and a value containing NUL or bytes that are not UTF-8 are dropped with a warning naming the key (escaped): applied, they would crash the next spawn instead.

A value must be a JSON string; null unsets the variable (it lands in the result's :attr:EnvChanges.removed); any other type is skipped with a warning naming the key, never the value.

Source code in src/dotagents/_env.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
def get_env_from_py(
    env_py: Path,
    base_env: "dict[str, str]",
    *,
    level: str = "",
    global_scope: bool = False,
    logger=None,
) -> EnvChanges:
    """Execute an ``env.py`` and read back its JSON object(s) of env changes.

    The script runs as ``<python> env.py --level <level> [--global]``:
    ``<level>`` is the contract-A level it was found at (``system``, ``user``,
    ``project``, or an overlay's name) and ``--global`` says the project tiers
    are skipped. ``--agent <level>`` is passed as well -- the old name of
    ``--level`` (it never named an agent), kept for scripts that read it.

    The script runs as a child with ``base_env`` (the accumulated environment)
    and prints its changes to stdout as JSON: ONE object, or one object PER
    LINE, merged in order (a later line wins on a key), so a script made of
    independent sections can let each print its own object instead of
    merging them itself. (An overlay ships its own ``env.py`` at its root
    rather than editing the store's.) A non-zero exit or unparseable output
    contributes nothing and is logged by NAME only -- never abort assembly,
    and never echo the child's stdout (it may carry secret values).

    The child runs with ``PYTHONIOENCODING=utf-8`` and its stdout is decoded as
    UTF-8 with ``surrogateescape``, so no byte it prints can fail the read. A
    key that cannot be an environment variable (empty, or containing ``=`` or
    NUL, or bytes that are not UTF-8) and a value containing NUL or bytes that
    are not UTF-8 are dropped with a warning naming the key (escaped): applied,
    they would crash the next spawn instead.

    A value must be a JSON string; ``null`` unsets the variable (it lands in
    the result's :attr:`EnvChanges.removed`); any other type is skipped with
    a warning naming the key, never the value.
    """
    args = [interpreter(base_env), str(env_py), "--level", level, "--agent", level]
    if global_scope:
        args.append("--global")
    spawn = _spawn_env(base_env)
    spawn["PYTHONIOENCODING"] = "utf-8"
    # The overlay libs lead the child's PYTHONPATH even when an earlier env
    # layer rewrote PYTHONPATH in the chain.
    libs = [p for p in base_env.get("AGENTS_PYTHONPATH", "").split(os.pathsep) if p]
    if libs:
        updated = _prepended_path_var(spawn, "PYTHONPATH", list(reversed(libs)))
        if updated is not None:
            spawn["PYTHONPATH"] = updated
    try:
        proc = subprocess.run(args, capture_output=True, check=False, env=spawn)
    except (OSError, ValueError) as e:  # interpreter missing / unusable env
        if logger:
            logger.warning("env.py could not run: %s (%s)", env_py, type(e).__name__)
        return EnvChanges()
    if proc.returncode != 0:
        if logger:
            logger.warning("env.py failed (exit %s): %s", proc.returncode, env_py)
        return EnvChanges()
    try:
        parsed = _parse_env_json(proc.stdout.decode("utf-8", "surrogateescape"))
    except (json.JSONDecodeError, ValueError) as e:
        if logger:
            logger.warning("env.py output not JSON: %s (%s)", env_py, e)
        return EnvChanges()
    out = EnvChanges()
    for key, value in parsed.items():
        if not _is_valid_env_key(key) or not _is_utf8(key):
            if logger:
                logger.warning("env.py %s: skipped a key that is not a valid variable name", env_py)
            continue
        if value is None:
            out.pop(key, None)
            out.removed.add(key)
            continue
        if not isinstance(value, str):
            if logger:
                logger.warning(
                    "env.py %s: skipped %s (value is a JSON %s, not a string)",
                    env_py, key, type(value).__name__,
                )
            continue
        if "\0" in value:
            if logger:
                logger.warning("env.py %s: skipped %s (value contains NUL)", env_py, key)
            continue
        if not _is_utf8(value):
            if logger:
                logger.warning("env.py %s: skipped %s (value is not UTF-8)", env_py, key)
            continue
        out.removed.discard(key)
        out[key] = value
    return out

get_environment(scope, *, base_env=None, explicit=None, logger=None)

Assemble the env CHANGES (vars this adds/overrides vs base_env) for a :class:Scope.

Follows contract B: identity seeded, PATH bins first, the two tiers chained (later overrides earlier), then proxy normalization. Returns only what changed: the vars set, and in removed the base_env vars a layer unset (and no later layer set again).

Source code in src/dotagents/_env.py
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
def get_environment(
    scope: Scope,
    *,
    base_env: "Optional[dict[str, str]]" = None,
    explicit: "Optional[str]" = None,
    logger=None,
) -> EnvChanges:
    """Assemble the env CHANGES (vars this adds/overrides vs ``base_env``) for a
    :class:`Scope`.

    Follows contract B: identity seeded, PATH bins first, the two tiers
    chained (later overrides earlier), then proxy normalization. Returns only
    what changed: the vars set, and in ``removed`` the ``base_env`` vars a
    layer unset (and no later layer set again).
    """
    from dotagents._agents import stamp_identity

    # Absolute roots: every path this emits (the roots, overlay roots, bin and
    # lib dirs) is read by children that may run in another cwd.
    scope = _absolute_scope(scope)
    agents_dir = scope.user_root
    # The project root a user-scope walk pins: the resolved default, so the
    # emitted AGENTS_PROJECT_ROOT still names the project the session is in.
    project_root = scope.project_root or Path(os.path.abspath(project_root_default()))
    global_scope = scope.global_scope

    osenv = dict(base_env if base_env is not None else os.environ)
    base_keys = frozenset(osenv)
    env = EnvChanges()

    def _apply(changes: "dict[str, str]") -> None:
        for key, value in changes.items():
            env[key] = osenv[key] = value
            env.removed.discard(key)
        for key in getattr(changes, "removed", ()):
            env.pop(key, None)
            osenv.pop(key, None)
            if key in base_keys:
                env.removed.add(key)

    # --- Identity seed --- before the file chain so files can override.
    _apply(stamp_identity(osenv, explicit=explicit, root=project_root))

    def _seed(key: str, value: "Optional[str]") -> None:
        """Only-if-unset: a value already set upstream (user, harness, an
        earlier layer) holds; an empty value is never written."""
        if value and not osenv.get(key):
            _apply({key: value})

    def _pin_walked(key: str, value: str) -> None:
        """The root this walk USED: an inherited value naming the same
        directory holds (no spurious diff); any other is replaced -- a session
        pinned to store A that runs `env --agents-dir B` walked B, and every
        later `context` / `init -g` / `overlays` call must act on B too."""
        current = osenv.get(key)
        if current and _same_dir(current, value):
            return
        _apply({key: value})

    # --- Scope roots --- pin the two scope roots so every command/subprocess agrees:
    # AGENTS_HOME = the user store (agents_dir, ~/.agents by default); AGENTS_PROJECT_ROOT
    # = this project's root (resolve_scope reads it). A user-scope walk (-g)
    # never walked a project, so there AGENTS_PROJECT_ROOT is only a default.
    _pin_walked("AGENTS_HOME", str(agents_dir))
    if scope.project_root is not None:
        _pin_walked("AGENTS_PROJECT_ROOT", str(project_root))
    else:
        _seed("AGENTS_PROJECT_ROOT", str(project_root))
    # --- Interpreter --- AGENTS_PYTHON = the Python dotagents itself is running
    # under, so shims and helper scripts have a known-good default: a bare
    # `python`/`python3` on PATH may be a Store alias stub, an emulated build,
    # a venv's, or absent, while this one demonstrably runs the CLI.
    # `sys.executable` is '' or None under an embedding host: then nothing is
    # exported rather than an empty command name.
    # With a scope venv (`_pyvenv`) it is that venv's interpreter instead: it
    # sees every overlay lib and requirement through its .pth layering. It
    # replaces an inherited value this code would have set (the bare
    # interpreter, or another scope's venv); any other pin holds.
    from dotagents import _pyvenv

    venv = _pyvenv.active_venv(scope, osenv)
    python = str(_pyvenv.venv_python(venv)) if venv is not None else sys.executable
    pinned = osenv.get("AGENTS_PYTHON")
    if python and pinned != python and (
        not pinned or pinned == sys.executable or _pyvenv.is_pyvenv_python(pinned)
    ):
        _apply({"AGENTS_PYTHON": python})
    if venv is not None:
        if osenv.get("AGENTS_PYVENV") != str(venv):
            _apply({"AGENTS_PYVENV": str(venv)})
    elif "AGENTS_PYVENV" in osenv:
        _apply(EnvChanges(removed=["AGENTS_PYVENV"]))
    # --- AGENTS_PYLIB --- where dotagents' own libraries (duho, pathapi)
    # import from for an overlay that builds on them. Set only when dotagents
    # runs from a .pyz: the archive, importable by zipimport. An installed
    # dotagents has them in AGENTS_PYTHON's site-packages already. Never put on
    # PYTHONPATH, which would shadow a project's own copy: a consumer appends
    # it to sys.path when its import fails.
    from dotagents._resources import pyz_archive

    pylib = pyz_archive()
    if pylib and osenv.get(PYLIB_VAR) != pylib:
        _apply({PYLIB_VAR: pylib})

    # --- Overlay roots --- one `<NAME>_OVERLAY_ROOT` per installed overlay, the
    # same name `dotagents context` expands as a `<NAME_OVERLAY_ROOT>` placeholder
    # (`Overlay.root_var` is the single naming rule). Seeded before the chain so
    # env files can reference an overlay's install dir; only-if-unset, like the
    # scope roots, so an upstream pin holds.
    for overlay in scope.overlays:
        pinned = osenv.get(overlay.root_var)
        store_copy = str(Path(agents_dir) / "overlays" / overlay.name)
        if not pinned:
            _apply({overlay.root_var: str(overlay.path)})
        elif pinned == store_copy and str(overlay.path) != store_copy:
            # The session pinned the STORE's copy (as the SessionStart env does),
            # and a same-named PROJECT overlay now shadows it: re-point to the
            # copy that is actually in play. Any other pin (a harness's own
            # value) is respected.
            _apply({overlay.root_var: str(overlay.path)})

    # --- Contract B step 1: bins onto PATH FIRST. ---
    bin_paths = [str(p) for p in get_bin_paths(scope)]
    updated = _prepended_path_var(osenv, "PATH", bin_paths)
    if updated is not None:
        _apply({"PATH": updated})
    updated = _launcher_path(osenv.get("PATH", ""), venv if venv is not None
                             and osenv.get("AGENTS_PYTHON") == str(_pyvenv.venv_python(venv)) else None)
    if updated is not None:
        _apply({"PATH": updated})

    # --- Libs: on PYTHONPATH, and published as AGENTS_PYTHONPATH, same moment.
    # Everything an overlay starts -- a skill script the agent runs, a `bin`
    # launcher, an env.py, a setup script -- must import the overlays' `lib`
    # modules, or an overlay built on another's lib breaks; the session's
    # PYTHONPATH is the one thing all of them inherit. Highest precedence first
    # (the project's lib before the user store's), existing dirs only (see
    # `get_lib_paths`). The value is this walk's: the libs an inherited
    # AGENTS_PYTHONPATH named (another scope's, before a `cd`) leave PYTHONPATH
    # before this walk's go to its front. AGENTS_PYTHONPATH says which
    # PYTHONPATH entries are dotagents', for a process that builds its own.
    lib_paths: "list[str]" = []
    for p in reversed(get_lib_paths(scope)):
        if str(p) not in lib_paths:
            lib_paths.append(str(p))
    stale = {
        os.path.normcase(p) for p in (osenv.get("AGENTS_PYTHONPATH") or "").split(os.pathsep) if p
    }
    if lib_paths:
        if osenv.get("AGENTS_PYTHONPATH") != os.pathsep.join(lib_paths):
            _apply({"AGENTS_PYTHONPATH": os.pathsep.join(lib_paths)})
    elif "AGENTS_PYTHONPATH" in osenv:
        _apply(EnvChanges(removed=["AGENTS_PYTHONPATH"]))
    inherited = osenv.get("PYTHONPATH") or ""
    kept = [
        e for e in inherited.split(os.pathsep)
        if e and os.path.normcase(e) not in stale
    ]
    # A lib the scope venv's .pth already layers stays off PYTHONPATH: the
    # venv's interpreter (AGENTS_PYTHON) imports it anyway, and no other
    # Python in the session should. AGENTS_PYTHONPATH still names them all.
    layered = {
        os.path.normcase(e) for e in (_pyvenv.read_state(venv).get("pth") or [] if venv else [])
    }
    on_path = [p for p in lib_paths if os.path.normcase(p) not in layered]
    pythonpath = _prepended_path_var({"PYTHONPATH": os.pathsep.join(kept)}, "PYTHONPATH",
                                     list(reversed(on_path)))
    if pythonpath is None:
        pythonpath = os.pathsep.join(kept)
    if pythonpath != inherited:
        if pythonpath:
            _apply({"PYTHONPATH": pythonpath})
        elif "PYTHONPATH" in osenv:
            _apply(EnvChanges(removed=["PYTHONPATH"]))

    # --- Contract B steps 2-5: the two tiers, chained, later-overrides-earlier. ---
    # One broken layer contributes nothing; it never takes the rest of the
    # env with it (the hooks send stderr to /dev/null, so a crash here would
    # silently empty every session). Logged by file name only.
    for level, path, _root in resolve_env_files(scope):
        try:
            if path.suffix == ".py":
                changes = get_env_from_py(
                    path, osenv, level=level, global_scope=global_scope, logger=logger
                )
            else:
                changes = get_env_from_file(path, osenv, logger=logger)
        except Exception as e:  # noqa: BLE001 - see above
            if logger:
                logger.warning("env layer skipped: %s (%s)", path, type(e).__name__)
            continue
        _apply(changes)

    # --- Proxy normalization --- after the chain so file-set proxies
    #     are normalized too.
    _apply(apply_proxy_model(osenv))

    return env

get_lib_paths(scope)

Each level's lib dir in contract-A precedence order, EXCEPT project-root -- what :func:get_environment puts at the front of the session's PYTHONPATH and publishes as AGENTS_PYTHONPATH (in reverse, highest precedence first), and what command discovery appends to sys.path.

Only dirs that EXIST are returned (unlike bin): a dozen absent entries on a search path are noise a reader has to rule out. project-root's lib is excluded for the same reason as its bin: a project's own top-level lib is not an agent library.

Source code in src/dotagents/_env.py
822
823
824
825
826
827
828
829
830
831
832
833
834
def get_lib_paths(scope: Scope) -> "list[Path]":
    """Each level's ``lib`` dir in contract-A precedence order, EXCEPT project-root
    -- what :func:`get_environment` puts at the front of the session's
    ``PYTHONPATH`` and publishes as ``AGENTS_PYTHONPATH`` (in reverse, highest
    precedence first), and what command discovery appends to ``sys.path``.

    Only dirs that EXIST are returned (unlike ``bin``): a dozen absent entries
    on a search path are noise a reader has to rule out. project-root's
    ``lib`` is excluded for the same reason as its ``bin``: a project's own
    top-level ``lib`` is not an agent library.
    """
    resolved = scope.paths({"default": "lib", "project-root": ""}, include_missing=False)
    return [path for _level, path, _root in resolved if path.is_dir()]

get_overlay_roots(scope)

The installed overlay dirs of the scope (:attr:Scope.overlays -- the user store's, then the project's, a same-named project overlay shadowing the store's copy): the set of overlays that get a <NAME>_OVERLAY_ROOT var is exactly the set whose bin/env files the contract-A walk resolves.

Source code in src/dotagents/_env.py
813
814
815
816
817
818
819
def get_overlay_roots(scope: Scope) -> "list[Path]":
    """The installed overlay dirs of the scope (:attr:`Scope.overlays` -- the
    user store's, then the project's, a same-named project overlay shadowing
    the store's copy): the set of overlays that get a ``<NAME>_OVERLAY_ROOT``
    var is exactly the set whose ``bin``/``env`` files the contract-A walk
    resolves."""
    return [overlay.path for overlay in scope.overlays]

interpreter(osenv)

The Python to run env-layer scripts with: a pinned AGENTS_PYTHON that names an existing file, else the interpreter running this code. The pin is what env exports for shims, so dotagents honours it for its own subprocesses too -- otherwise a user who pins 3.12 because the CLI's venv is 3.9 gets their helpers on 3.12 and their env.py on 3.9.

Source code in src/dotagents/_env.py
498
499
500
501
502
503
504
505
506
507
def interpreter(osenv: "dict[str, str]") -> str:
    """The Python to run env-layer scripts with: a pinned ``AGENTS_PYTHON`` that
    names an existing file, else the interpreter running this code. The pin is
    what ``env`` exports for shims, so dotagents honours it for its own
    subprocesses too -- otherwise a user who pins 3.12 because the CLI's venv
    is 3.9 gets their helpers on 3.12 and their ``env.py`` on 3.9."""
    pinned = osenv.get("AGENTS_PYTHON")
    if pinned and Path(pinned).is_file():
        return pinned
    return sys.executable

read_env_cache(scope, key, *, now=None)

The rendering cached under key, or None when there is none or it is older than :data:ENV_CACHE_TTL.

Source code in src/dotagents/_env.py
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
def read_env_cache(scope: Scope, key: str, *, now: "Optional[float]" = None) -> "Optional[str]":
    """The rendering cached under ``key``, or None when there is none or it is
    older than :data:`ENV_CACHE_TTL`."""
    import time

    path = _env_cache_dir(scope) / key
    try:
        age = (time.time() if now is None else now) - os.stat(str(path)).st_mtime
        if age < -_ENV_CACHE_CLOCK_SLACK or age >= ENV_CACHE_TTL:
            return None
        with open(str(path), encoding="utf-8", newline="") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None

resolve_env_files(scope)

The ordered, existing env files: ALL pre-tier then ALL main-tier, for a :class:Scope.

Each tier is one contract-A resolution (:meth:Scope.paths). Per-level filename resolution (contract A point 2): pre.env.py/pre.env and env.py/env resolve at every level EXCEPT project-root; the project level (<project>/.agents) ADDITIONALLY resolves pre.local.env / local.env. Only existing regular FILES are returned (a directory named env -- a common virtualenv name -- is not an env file).

The project ROOT resolves nothing: the env chain runs at every session start, so a checkout's top-level env.py / env / local.env would be code a cloned repository runs the moment a session opened in it. A user's own local overrides live in <project>/.agents/local.env.

Source code in src/dotagents/_env.py
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
def resolve_env_files(scope: Scope) -> "list[tuple[str, Path, Optional[Path]]]":
    """The ordered, existing env files: ALL pre-tier then ALL main-tier, for a
    :class:`Scope`.

    Each tier is one contract-A resolution (:meth:`Scope.paths`). Per-level
    filename resolution (contract A point 2): ``pre.env.py``/``pre.env`` and
    ``env.py``/``env`` resolve at every level EXCEPT project-root; the project
    level (``<project>/.agents``) ADDITIONALLY resolves ``pre.local.env`` /
    ``local.env``. Only existing regular FILES are returned (a directory named
    ``env`` -- a common virtualenv name -- is not an env file).

    The project ROOT resolves nothing: the env chain runs at every session
    start, so a checkout's top-level ``env.py`` / ``env`` / ``local.env`` would
    be code a cloned repository runs the moment a session opened in it. A
    user's own local overrides live in ``<project>/.agents/local.env``.
    """
    pre_tier = scope.paths(
        {"default": "pre.env.py", "project-root": ""},
        {"default": "pre.env", "project-root": ""},
        {"project": "pre.local.env"},
    )
    main_tier = scope.paths(
        {"default": "env.py", "project-root": ""},
        {"default": "env", "project-root": ""},
        {"project": "local.env"},
    )
    return [item for item in pre_tier + main_tier if item[1].is_file()]

write_env_cache(scope, key, text)

Store a rendering (it holds env VALUES: written owner-only, through a private temp file) and drop entries past :data:ENV_CACHE_TTL. Never fails the caller: a store that cannot be written just is not cached.

Source code in src/dotagents/_env.py
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
def write_env_cache(scope: Scope, key: str, text: str) -> None:
    """Store a rendering (it holds env VALUES: written owner-only, through a
    private temp file) and drop entries past :data:`ENV_CACHE_TTL`. Never
    fails the caller: a store that cannot be written just is not cached."""
    import time

    from dotagents._fs import write_text_lf

    cache = _env_cache_dir(scope)
    try:
        cache.mkdir(parents=True, exist_ok=True, mode=0o700)
        write_text_lf(cache / key, text, atomic=True)
        cutoff = time.time() - ENV_CACHE_TTL
        for entry in os.scandir(str(cache)):
            if entry.name != key and entry.is_file() and entry.stat().st_mtime < cutoff:
                os.unlink(entry.path)
    except OSError:
        pass