Files
multi-agent-mux/.agents/skills/lib_py/delegation/envfile.py
T
GodopuandClaude Opus 5.5 71ea8095c5 fix(followups): F3 - .mam.env BOM, repeated keys and a missing MAM_ENV_FILE
PR #43 review (grok):
(a) A UTF-8 byte-order mark at the start of the file made the first line an
    "invalid key", so its setting was dropped. The one parser (envfile.py,
    used by the wrapper and mqtt_common) now reads with utf-8-sig.
(b) A key given twice keeps its first value (a later line does not replace
    it). The behaviour is unchanged; .mam.env.example's FORMAT section and
    envfile.py's docstring now say so.
(c) MESSAGING.md said a MAM_ENV_FILE naming no file logs an error and makes
    connections fail closed. Both loaders skip it silently and refuse the
    ambient search; a file that exists but cannot be read is the error
    (EnvFileError, one ERROR line and rc 1). The text now says that.

Tests A7: BOM in both loaders, first-value-wins in both loaders plus the
FORMAT text, and a missing explicit file loading neither it nor the ambient
.mam.env without an error plus the MESSAGING text (all three fail at 0051fd6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
2026-10-02 08:10:42 +09:00

122 lines
4.9 KiB
Python

"""The one `.mam.env` parser (Issue #26).
Rules: blank lines and `#` comments are skipped; `KEY=VALUE` with the key and
the value stripped; one matching pair of surrounding quotes is removed; no
shell interpretation (`$(...)`, `${...}` and backticks stay literal). A key
must match ``^[A-Za-z_][A-Za-z0-9_]*$`` and not be one of bash's own variables
(RESERVED); a line that breaks either, or that has no `=`, is skipped with a
warning naming the line (never the value — the file holds secrets). A line
holding a NUL byte is skipped the same way (no environment can carry it).
A UTF-8 byte-order mark at the start of the file is dropped. Variables
already in the environment win over the file, and a key given twice keeps its
first value. A file that exists
but cannot be read or decoded as a whole is an error (EnvFileError; the
``--shell`` command exits 1), not an empty configuration.
Stdlib only: the delegate-job wrapper runs this file by path
(``envfile.py --shell <path>``) and evals the ``export`` lines it prints, so
values travel over a pipe, never on a command line.
"""
import os
import re
import shlex
import sys
from typing import List, MutableMapping, Tuple
_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# Bash's own variables (bash(1) "Shell Variables"). Assigning some runs code
# even from a quoted value (RANDOM/SECONDS/... are evaluated arithmetically,
# so RANDOM='a[$(cmd)]' executes cmd), others steer the shell (IFS, PS4,
# BASH_ENV, SHELLOPTS) or are readonly. A file key never assigns them - in
# either loader. tests/test_issue26_envfile.py checks this covers every
# variable a clean bash defines.
RESERVED = frozenset("""
BASH BASHOPTS BASHPID CDPATH CHILD_MAX COLUMNS COMPREPLY COPROC DIRSTACK EMACS ENV
EPOCHREALTIME EPOCHSECONDS EUID EXECIGNORE FCEDIT FIGNORE FUNCNAME FUNCNEST GLOBIGNORE
GLOBSORT GROUPS HISTCMD HISTCONTROL HISTFILE HISTFILESIZE HISTIGNORE HISTSIZE
HISTTIMEFORMAT HOSTFILE HOSTNAME HOSTTYPE IFS IGNOREEOF INPUTRC INSIDE_EMACS LINENO LINES
MACHTYPE MAIL MAILCHECK MAILPATH MAPFILE OLDPWD OPTARG OPTERR OPTIND OSTYPE PIPESTATUS
POSIXLY_CORRECT PPID PROMPT_COMMAND PROMPT_DIRTRIM PS0 PS1 PS2 PS3 PS4 PWD RANDOM REPLY
SECONDS SHELL SHELLOPTS SHLVL SRANDOM TIMEFORMAT TMOUT UID auto_resume histchars _
""".split())
RESERVED_PREFIXES = ("BASH_", "COMP_", "READLINE_")
class EnvFileError(RuntimeError):
"""The env file exists but cannot be read or decoded as a whole."""
def is_reserved(key: str) -> bool:
return key in RESERVED or key.startswith(RESERVED_PREFIXES)
def parse(path: str) -> Tuple[List[Tuple[str, str]], List[str]]:
"""Return ([(key, value)], [warning]) for the file at ``path``; raise
EnvFileError when the file cannot be read or decoded as a whole."""
pairs: List[Tuple[str, str]] = []
warnings: List[str] = []
try:
# utf-8-sig: a byte-order mark at the start is not part of the first key
with open(path, "r", encoding="utf-8-sig") as f:
lines = f.read().splitlines()
except (OSError, UnicodeDecodeError) as e:
raise EnvFileError(f"{path}: cannot read ({type(e).__name__})") from None
for n, raw in enumerate(lines, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "\0" in line:
warnings.append(f"{path}:{n}: skipped (NUL byte)")
continue
key, sep, val = line.partition("=")
key = key.strip()
if not sep:
warnings.append(f"{path}:{n}: skipped (no '=')")
continue
if not _KEY.fullmatch(key):
warnings.append(f"{path}:{n}: skipped (invalid key)")
continue
if is_reserved(key):
warnings.append(f"{path}:{n}: skipped (reserved shell variable {key})")
continue
val = val.strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
val = val[1:-1]
pairs.append((key, val))
return pairs, warnings
def apply(path: str, environ: MutableMapping[str, str]) -> Tuple[List[Tuple[str, str]], List[str]]:
"""Set each pair not already in ``environ``; return (the pairs set, warnings)."""
pairs, warnings = parse(path)
applied = []
for key, val in pairs:
if key not in environ:
environ[key] = val
applied.append((key, val))
return applied, warnings
def main(argv: List[str]) -> int:
if len(argv) != 2 or argv[0] != "--shell":
print("usage: envfile.py --shell <path>", file=sys.stderr)
return 2
path = argv[1]
if not os.path.isfile(path):
return 0
try:
applied, warnings = apply(path, dict(os.environ))
except EnvFileError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
for w in warnings:
print(f"WARNING: {w}", file=sys.stderr)
for key, val in applied:
print(f"export {key}={shlex.quote(val)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))