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
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-01 22:59:57 +09:00
1 parent b58edace11
commit 0eec795cd1
7 files changed
+128 -19

No files matched your search

+6 -1
View File
@@ -2343,7 +2343,12 @@ sys.exit(1)
# 448 and fail on 0900), and min <= n <= 999999999. Prints n and returns 0, or
# returns 1. The per-knob minimum lives in tests/test_issue26_ints.py.
mam_int() {
[[ "${1-}" =~ ^[[:space:]]*([0-9]+)[[:space:]]*$ ]] || return 1
# Spelled-out ASCII sets, not [[:space:]]/[0-9]: those follow the locale, and
# the runner's _positive_int takes ASCII only (review R2: U+2003 30 U+2003
# was 30 here under en_US.UTF-8 and invalid there).
local _ws=$' \t\n\r\v\f' _re
_re="^[${_ws}]*([0123456789]+)[${_ws}]*\$"
[[ "${1-}" =~ $_re ]] || return 1
local d="${BASH_REMATCH[1]}"
d="${d#"${d%%[!0]*}"}"
[ ${#d} -le 9 ] || return 1
+20 -5
View File
@@ -5,8 +5,11 @@ 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).
Variables already in the environment win over the file.
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
@@ -38,23 +41,31 @@ RESERVED = frozenset("""
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``."""
"""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:
return pairs, [f"{path}: cannot read ({type(e).__name__})"]
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:
@@ -91,7 +102,11 @@ def main(argv: List[str]) -> int:
path = argv[1]
if not os.path.isfile(path):
return 0
applied, warnings = apply(path, dict(os.environ))
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:
@@ -96,7 +96,10 @@ def _load_dotenv(workspace_dir: Optional[str] = None) -> None:
_parse_env_file(legacy_env_path)
def _parse_env_file(path: str) -> None:
"""Issue #26: the rules live in envfile.py, shared with the wrapper."""
"""Issue #26: the rules live in envfile.py, shared with the wrapper. A
file that exists but cannot be read raises envfile.EnvFileError, so every
process entry point stops as the wrapper does, instead of running
unconfigured."""
for warning in envfile.apply(path, os.environ)[1]:
logger.warning("%s", warning)
@@ -41,7 +41,7 @@ fi
if [[ -n "$TARGET_ENV" && -f "$TARGET_ENV" ]]; then
_env_exports="$(SCRIPT_DIR="$SCRIPT_DIR" REPO_ROOT="$REPO_ROOT" TARGET_ENV="$TARGET_ENV" \
"${DELEGATE_JOB_PYTHON:-python3}" -I "$SCRIPT_DIR/../lib_py/delegation/envfile.py" --shell "$TARGET_ENV")" \
|| { echo "ERROR: cannot read $TARGET_ENV" >&2; exit 1; }
|| { echo "ERROR: cannot load $TARGET_ENV (see the line above)" >&2; exit 1; }
eval "$_env_exports"; unset _env_exports
fi
@@ -164,6 +164,7 @@ COUNTERPART_ROLE_EXPLICIT=0
PARENT_JOB=""
parse_opts() {
_INT_FLAGS_GIVEN=""
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) AGENT="$2"; shift 2;;
@@ -171,11 +172,11 @@ parse_opts() {
--prompt-file) PROMPT_FILE="$2"; shift 2;;
--workdir) WORKDIR="$2"; shift 2;;
--agent-session) AGENT_SESSION="$2"; shift 2;;
--timeout) TIMEOUT="$2"; shift 2;;
--idle-timeout) IDLE_TIMEOUT="$2"; shift 2;;
--heartbeat) HEARTBEAT_SEC="$2"; shift 2;;
--heartbeat-misses) HEARTBEAT_MISSES="$2"; shift 2;;
--ack-timeout) ACK_TIMEOUT="$2"; shift 2;;
--timeout) TIMEOUT="$2"; _INT_FLAGS_GIVEN+=" TIMEOUT"; shift 2;;
--idle-timeout) IDLE_TIMEOUT="$2"; _INT_FLAGS_GIVEN+=" IDLE_TIMEOUT"; shift 2;;
--heartbeat) HEARTBEAT_SEC="$2"; _INT_FLAGS_GIVEN+=" HEARTBEAT_SEC"; shift 2;;
--heartbeat-misses) HEARTBEAT_MISSES="$2"; _INT_FLAGS_GIVEN+=" HEARTBEAT_MISSES"; shift 2;;
--ack-timeout) ACK_TIMEOUT="$2"; _INT_FLAGS_GIVEN+=" ACK_TIMEOUT"; shift 2;;
--validate) VALIDATE="$2"; shift 2;;
--job) JOB_ID="$2"; shift 2;;
--registry-dir) REGISTRY_DIR="$2"; shift 2;;
@@ -191,10 +192,13 @@ parse_opts() {
*) echo "unknown option: $1" >&2; usage; exit 1;;
esac
done
# Issue #26: a flag value that is not a positive decimal integer is refused.
# Issue #26: a flag value that is not a positive decimal integer is refused -
# an explicitly given empty value too (review R3); only an absent flag keeps
# its default.
local _flag _var _n
for _flag in timeout:TIMEOUT idle-timeout:IDLE_TIMEOUT heartbeat:HEARTBEAT_SEC heartbeat-misses:HEARTBEAT_MISSES ack-timeout:ACK_TIMEOUT; do
_var="${_flag#*:}"; [ -n "${!_var}" ] || continue
_var="${_flag#*:}"
[ -n "${!_var}" ] || [[ " $_INT_FLAGS_GIVEN " == *" $_var "* ]] || continue
_n=$(mam_int "${!_var}" 1) || { echo "ERROR: --${_flag%%:*} ${!_var}: not a positive decimal integer" >&2; exit 1; }
printf -v "$_var" '%s' "$_n"
done