Files
multi-agent-mux/.agents/skills/lib_py/delegation/envfile.py
T
GodopuandClaude Opus 5.5 0eec795cd1 fix(issue-26): review - an unreadable env file is an error, NUL lines are skipped, integers are ASCII in every locale, empty flags are refused
Merge-workspace review of PR #43 (codex R1-R4, claude R1/R2).

- R1: envfile.parse raises EnvFileError when the file exists but cannot be
  read or decoded as a whole; `--shell` prints the cause and exits 1, so the
  wrapper stops (rc 1, as before #26) instead of running on defaults, and
  mqtt_common's entry points stop on the same error. A bad single line is
  still a warning. The wrapper's message no longer claims a read failure
  when the cause is elsewhere ("cannot load ... see the line above").
- R2: lib.sh mam_int spells out the ASCII blanks and digits instead of
  [[:space:]]/[0-9], which follow the locale: U+2003 30 U+2003 was 30 in the
  wrapper and invalid in the runner under en_US.UTF-8. The test oracle
  _expected uses the same ASCII rule.
- R3: an integer flag given explicitly with an empty value is refused like
  any other invalid value; only an absent flag keeps its default.
- R4: a line holding a NUL byte is skipped with a warning by both loaders;
  before, the wrapper read "leftright" and the Python loader died.
- .mam.env.example states the format: no expansion, `export` and bash's own
  variables skipped, a trailing `# ...` is part of the value, the environment
  wins, an unreadable file is an error.

Tests: unreadable (0000) and invalid-UTF-8 files stop the wrapper and the
Python loader; a NUL line is skipped by both; Unicode blanks/digits are
invalid for mam_int, _positive_int, the wrapper and the runner under C and
en_US.UTF-8; explicit empty values for the five integer flags are refused.
All of them fail on b58edac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-01 22:59:57 +09:00

119 lines
4.7 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).
Variables already in the environment win over the file. 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:
with open(path, "r", encoding="utf-8") 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:]))