Environment
dotagents._env
Chained env-file assembly + env.py execution (contract B).
Contract B, the exact sequence :func:get_environment performs:
- Bins onto PATH FIRST, before any env eval. Each level's
bindir (contract-A precedence order, except project-root) is prepended toPATHso env scripts can call overlay helpers by name, and ahead of them the active venv's launcher dir (:func:_launcher_path). Libs go onPYTHONPATHthe same way: each level'slibdir that EXISTS (:func:get_lib_paths) is prepended toPYTHONPATHand listed,os.pathsep-joined, inAGENTS_PYTHONPATH-- so anenv.py, a skill script, abinlauncher and everything they start canimportan overlay's library (an overlay shipslib/<module>.pybeside itscmds/), 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. - Two tiers, in order: ALL
pre.env.py/pre.env/pre.local.envfirst, THEN ALLenv.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-levelenv.py/env/local.envis never executed or sourced. - 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. - 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.
.pyfiles are EXECUTED (:func:get_env_from_pyruns the script asenv.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 oneenv.pycan each print their own); plain files are sourced (:func:get_env_from_file, via bash: anenv -0snapshot before and after thesource, in one process).- :func:
get_diffreturns only the vars that differ from the caller's base environment; :func:get_environmentreturns the full change set. Both are an :class:EnvChanges: the vars set, plusremoved-- the base vars a layer unset (anenv.pyprintingnullfor a key, a plain file runningunset) 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 onAGENTS_HARNESSand override the stampedAGENTS_MODELetc. (chained: a later file wins). Never clobbers a value already present in the base env -- unlessexplicitnames the agent, in which case the identity is that agent's regardless (launchstarts a harness from inside another one). - Roots are seeded right after identity, also before the chain. The two
scope roots
AGENTS_HOME/AGENTS_PROJECT_ROOTname 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_ROOTis only-if-unset). Only-if-unset: the interpreterAGENTS_PYTHON(sys.executable-- the Python the CLI is running under, a default any shim or helper can trust), and one<NAME>_OVERLAY_ROOTper installed overlay (:attr:Scope.overlays, the same set :func:get_overlay_rootslists, named by :attr:_overlays.Overlay.root_var-- the same namedotagents contextexpands as a placeholder), so an env file can reference an overlay's install dir by name.AGENTS_PYLIB(:data:PYLIB_VAR) names the.pyzwhen dotagents runs from one -- where its own libraries (duho, pathapi) import from for an overlay built on them; never put onPYTHONPATH. - Proxy is normalized AFTER the file chain:
AGENTS_PROXYis seeded if unset (fromAGENTS_WEBFETCH_PROXY_URLelse 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_PROXYis NOT fanned out into the globalHTTP_PROXY. MirroringHTTP_PROXYintohttp_proxyis the opposite of the httpoxy mitigation, which is for a CGI handler to IGNORE the upper-case name (aProxy:request header arrives asHTTP_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 | |
removed = set(removed)
instance-attribute
__repr__()
Source code in src/dotagents/_env.py
121 122 | |
apply_proxy_model(osenv)
Return the proxy changes for osenv per the proxy model.
- Seed
AGENTS_PROXYif unset, from the first populated :data:_PROXY_SEED_ORDERvar (webfetch var wins, then the global proxy in either case). An existingAGENTS_PROXYis 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_proxyis populated fromHTTP_PROXY; see the module note on httpoxy). AGENTS_PROXYis NOT fanned into the globalHTTP_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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |