fix(issue-26): review round 1 - no file key assigns bash's own variables; SKS_QUIESCENT_TRIES; long decimals
R1 (P1): a quoted export still ran code when the key was a bash special variable (RANDOM=a[$(cmd)] is evaluated arithmetically on assignment). envfile.RESERVED (bash's own variables, plus BASH_/COMP_/READLINE_ prefixes) is skipped with a line-numbered warning by both loaders; a test keeps it covering every variable a clean bash defines. The wrapper also hands SCRIPT_DIR/REPO_ROOT/TARGET_ENV to the parser as environment, so the file cannot replace the variables it has already set. R2 (P2): SKS_QUIESCENT_TRIES reached `for ((i < tries))` through a function argument; it is normalised at the call site (minimum 0). B5 now requires every integer-looking env default in the shell code to be normalised or listed in NOT_ARITHMETIC with where it goes, and exercises _pane_quiescent. R3 (P2): _positive_int strips leading zeros and rejects more than nine significant digits before int(), like mam_int; 5000-digit inputs no longer raise in the runner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2826e709ae
commit
34241d7a65
6 files changed
+186
-16
No files matched your search
@@ -2782,7 +2782,7 @@ send_keys_safe() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local _q_rc=0
|
local _q_rc=0
|
||||||
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
_pane_quiescent "$sess" "$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
||||||
if [ "$_q_rc" = "1" ]; then
|
if [ "$_q_rc" = "1" ]; then
|
||||||
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
Rules: blank lines and `#` comments are skipped; `KEY=VALUE` with the key and
|
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
|
the value stripped; one matching pair of surrounding quotes is removed; no
|
||||||
shell interpretation (`$(...)`, `${...}` and backticks stay literal). A key
|
shell interpretation (`$(...)`, `${...}` and backticks stay literal). A key
|
||||||
must match ``^[A-Za-z_][A-Za-z0-9_]*$``; a line that does not, or that has no
|
must match ``^[A-Za-z_][A-Za-z0-9_]*$`` and not be one of bash's own variables
|
||||||
`=`, is skipped with a warning naming the line (never the value — the file
|
(RESERVED); a line that breaks either, or that has no `=`, is skipped with a
|
||||||
holds secrets). Variables already in the environment win over the file.
|
warning naming the line (never the value — the file holds secrets).
|
||||||
|
Variables already in the environment win over the file.
|
||||||
|
|
||||||
Stdlib only: the delegate-job wrapper runs this file by path
|
Stdlib only: the delegate-job wrapper runs this file by path
|
||||||
(``envfile.py --shell <path>``) and evals the ``export`` lines it prints, so
|
(``envfile.py --shell <path>``) and evals the ``export`` lines it prints, so
|
||||||
@@ -19,6 +20,27 @@ from typing import List, MutableMapping, Tuple
|
|||||||
|
|
||||||
_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
_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_")
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
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``."""
|
||||||
@@ -41,6 +63,9 @@ def parse(path: str) -> Tuple[List[Tuple[str, str]], List[str]]:
|
|||||||
if not _KEY.fullmatch(key):
|
if not _KEY.fullmatch(key):
|
||||||
warnings.append(f"{path}:{n}: skipped (invalid key)")
|
warnings.append(f"{path}:{n}: skipped (invalid key)")
|
||||||
continue
|
continue
|
||||||
|
if is_reserved(key):
|
||||||
|
warnings.append(f"{path}:{n}: skipped (reserved shell variable {key})")
|
||||||
|
continue
|
||||||
val = val.strip()
|
val = val.strip()
|
||||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
||||||
val = val[1:-1]
|
val = val[1:-1]
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ def _positive_int(text: Optional[str], minimum: int = 1) -> Optional[int]:
|
|||||||
s = (text or "").strip(" \t\n\r\v\f")
|
s = (text or "").strip(" \t\n\r\v\f")
|
||||||
if not s or not s.isascii() or not s.isdigit():
|
if not s or not s.isascii() or not s.isdigit():
|
||||||
return None
|
return None
|
||||||
|
s = s.lstrip("0") or "0"
|
||||||
|
if len(s) > 9: # like mam_int: bounded before int(), which refuses > 4300 digits
|
||||||
|
return None
|
||||||
n = int(s)
|
n = int(s)
|
||||||
return n if minimum <= n <= 999_999_999 else None
|
return n if n >= minimum else None
|
||||||
|
|
||||||
class LoopRunner:
|
class LoopRunner:
|
||||||
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ if [[ -z "$TARGET_ENV" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Issue #26: parsed by envfile.py (the Python loader's rules; environment wins),
|
# Issue #26: parsed by envfile.py (the Python loader's rules; environment wins),
|
||||||
# never sourced. Values come back as shlex-quoted exports over the pipe.
|
# never sourced. Values come back as shlex-quoted exports over the pipe. The
|
||||||
|
# variables this script has already set are handed over as environment, so
|
||||||
|
# the file cannot replace them either.
|
||||||
if [[ -n "$TARGET_ENV" && -f "$TARGET_ENV" ]]; then
|
if [[ -n "$TARGET_ENV" && -f "$TARGET_ENV" ]]; then
|
||||||
_env_exports="$("${DELEGATE_JOB_PYTHON:-python3}" -I "$SCRIPT_DIR/../lib_py/delegation/envfile.py" --shell "$TARGET_ENV")" \
|
_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 read $TARGET_ENV" >&2; exit 1; }
|
||||||
eval "$_env_exports"; unset _env_exports
|
eval "$_env_exports"; unset _env_exports
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ def test_a4_the_parser_command_line_carries_the_path_only():
|
|||||||
calls = [l for l in WRAPPER.read_text().splitlines() if "envfile.py" in l and not l.lstrip().startswith("#")]
|
calls = [l for l in WRAPPER.read_text().splitlines() if "envfile.py" in l and not l.lstrip().startswith("#")]
|
||||||
assert len(calls) == 1, calls
|
assert len(calls) == 1, calls
|
||||||
assert re.search(r'envfile\.py" --shell "\$TARGET_ENV"\)"', calls[0]), calls[0]
|
assert re.search(r'envfile\.py" --shell "\$TARGET_ENV"\)"', calls[0]), calls[0]
|
||||||
|
prefix = [l for l in WRAPPER.read_text().splitlines() if "_env_exports=" in l]
|
||||||
|
assert prefix == [' _env_exports="$(SCRIPT_DIR="$SCRIPT_DIR" REPO_ROOT="$REPO_ROOT" TARGET_ENV="$TARGET_ENV" \\'], prefix
|
||||||
src = WRAPPER.read_text()
|
src = WRAPPER.read_text()
|
||||||
assert not re.search(r'^\s*(source|\.)\s+"?\$TARGET_ENV', src, re.M), "the env file must never be sourced"
|
assert not re.search(r'^\s*(source|\.)\s+"?\$TARGET_ENV', src, re.M), "the env file must never be sourced"
|
||||||
assert "set -a" not in src
|
assert "set -a" not in src
|
||||||
@@ -251,3 +253,67 @@ def test_a5_shell_mode_exit_code_is_zero_for_absent_and_present_files(tmp_path,
|
|||||||
env={k: v for k, v in os.environ.items() if k != "A"}, timeout=30)
|
env={k: v for k, v in os.environ.items() if k != "A"}, timeout=30)
|
||||||
assert res.returncode == 0
|
assert res.returncode == 0
|
||||||
assert res.stdout == ("" if missing else "export A=1\n")
|
assert res.stdout == ("" if missing else "export A=1\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A6 - bash's own variables are never assigned from the file (review R1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# RANDOM/SECONDS/LINENO/OPTIND/SRANDOM are evaluated arithmetically on
|
||||||
|
# assignment, so even a shlex-quoted value runs its $(...) in the wrapper.
|
||||||
|
ARITH_SPECIALS = ["RANDOM", "SECONDS", "LINENO", "OPTIND", "SRANDOM"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ARITH_SPECIALS)
|
||||||
|
def test_a6_a_reserved_key_runs_nothing_in_the_real_wrapper(tmp_path, key):
|
||||||
|
f = tmp_path / "special.env"
|
||||||
|
f.write_text(f"{key}=BASH_VERSINFO[$(touch marker)0]\nSAFE=BASH_VERSINFO[$(touch marker2)0]\n")
|
||||||
|
env = _env(tmp_path, f)
|
||||||
|
env.pop(key, None)
|
||||||
|
res = subprocess.run(["bash", str(WRAPPER), "--help"], capture_output=True, text=True, env=env,
|
||||||
|
cwd=str(tmp_path), timeout=60)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert not (tmp_path / "marker").exists(), f"{key}= from the file ran shell"
|
||||||
|
assert not (tmp_path / "marker2").exists()
|
||||||
|
assert f"{f}:1: skipped (reserved shell variable {key})" in res.stderr, res.stderr
|
||||||
|
assert "touch" not in res.stderr, "the warning must not echo the value"
|
||||||
|
# the same value under an ordinary key stays literal (positive case)
|
||||||
|
got = _wrapper_values(env, ["SAFE"], tmp_path)
|
||||||
|
assert _split(got.stdout) == {"SAFE": "BASH_VERSINFO[$(touch marker2)0]"}
|
||||||
|
assert not (tmp_path / "marker").exists() and not (tmp_path / "marker2").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_both_loaders_skip_reserved_keys(tmp_path):
|
||||||
|
f = tmp_path / "reserved.env"
|
||||||
|
keys = ["BASH_ENV", "PS4", "IFS", "SHELLOPTS", "COMP_WORDBREAKS", "READLINE_LINE", "RANDOM"]
|
||||||
|
f.write_text("".join(f"{k}=x\n" for k in keys) + "PLAIN=ok\n")
|
||||||
|
env = {k: v for k, v in _env(tmp_path, f).items() if k not in keys}
|
||||||
|
python = _python_values(env, keys + ["PLAIN"])
|
||||||
|
assert python == {**{k: "<unset>" for k in keys}, "PLAIN": "ok"}
|
||||||
|
pairs, warnings = envfile.parse(str(f))
|
||||||
|
assert pairs == [("PLAIN", "ok")]
|
||||||
|
assert [w.rsplit("(", 1)[1] for w in warnings] == [f"reserved shell variable {k})" for k in keys]
|
||||||
|
res = subprocess.run([sys.executable, "-I", str(ENVFILE), "--shell", str(f)], capture_output=True,
|
||||||
|
text=True, env=env, timeout=30)
|
||||||
|
assert res.stdout == "export PLAIN=ok\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_the_reserved_set_covers_every_variable_a_clean_bash_defines():
|
||||||
|
res = subprocess.run(["env", "-i", "bash", "--norc", "--noprofile", "-c", "compgen -v"],
|
||||||
|
capture_output=True, text=True, check=True, timeout=30)
|
||||||
|
ordinary = {"PATH", "TERM"} # environment, not shell state; .mam.env may set them as before
|
||||||
|
uncovered = [v for v in res.stdout.split() if v not in ordinary and not envfile.is_reserved(v)]
|
||||||
|
assert not uncovered, f"bash defines these but envfile.RESERVED misses them: {uncovered}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_the_file_cannot_replace_the_wrappers_own_variables(tmp_path):
|
||||||
|
f = tmp_path / "own.env"
|
||||||
|
f.write_text(f"SCRIPT_DIR={tmp_path}/evil\nREPO_ROOT={tmp_path}/evil\nTARGET_ENV={tmp_path}/evil\n")
|
||||||
|
(tmp_path / "evil").mkdir()
|
||||||
|
(tmp_path / "evil" / "lib.sh").write_text("touch pwned-lib\n")
|
||||||
|
res = subprocess.run(["bash", str(WRAPPER), "--help"], capture_output=True, text=True,
|
||||||
|
env=_env(tmp_path, f), cwd=str(tmp_path), timeout=60)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert not (tmp_path / "pwned-lib").exists()
|
||||||
|
got = _wrapper_values(_env(tmp_path, f), ["SCRIPT_DIR", "TARGET_ENV"], tmp_path)
|
||||||
|
assert _split(got.stdout) == {"SCRIPT_DIR": str(WRAPPER.parent), "TARGET_ENV": str(f)}
|
||||||
@@ -41,6 +41,8 @@ INT_KNOBS = {
|
|||||||
"SKS_DIALOG_TIMEOUT": (0, "30"),
|
"SKS_DIALOG_TIMEOUT": (0, "30"),
|
||||||
"SKS_BUSY_TIMEOUT": (0, "120"),
|
"SKS_BUSY_TIMEOUT": (0, "120"),
|
||||||
"SKS_DELIVERY_GRACE_S": (0, "15"),
|
"SKS_DELIVERY_GRACE_S": (0, "15"),
|
||||||
|
# _pane_quiescent's `for ((i = 0; i < tries; i++))` (review R2); 0 keeps "headless" (rc 2)
|
||||||
|
"SKS_QUIESCENT_TRIES": (0, "20"),
|
||||||
# delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
|
# delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
|
||||||
"delegate_wait_started:ack_s": (0, "180"),
|
"delegate_wait_started:ack_s": (0, "180"),
|
||||||
# wrapper flags
|
# wrapper flags
|
||||||
@@ -51,15 +53,51 @@ INT_KNOBS = {
|
|||||||
"--ack-timeout": (1, None),
|
"--ack-timeout": (1, None),
|
||||||
}
|
}
|
||||||
|
|
||||||
INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030"]
|
# The last two are longer than Python's int() digit limit (4300): the rule must
|
||||||
|
# bound the significant digits before converting (review R3).
|
||||||
|
INPUTS = ["30", "0700", "0900", " 30 ", "0", "-5", "abc", "", "1e3", "9999999999", "000000000030",
|
||||||
|
"9" * 5000, "0" * 5000 + "30"]
|
||||||
|
|
||||||
|
# Integer-looking env defaults in the shell scripts that do NOT reach bash
|
||||||
|
# arithmetic, each with where the value goes. B5 fails on any `${MAM_/SKS_/
|
||||||
|
# DELEGATE_JOB_/RECONCILE_…:-<digit>…}` that is neither here nor normalised
|
||||||
|
# through INT_KNOBS, so a new knob has to be classified to land.
|
||||||
|
NOT_ARITHMETIC = {
|
||||||
|
"MAM_DELEGATE_FAIL_CLOSED": "string compare", "MAM_STALL_WATCH": "string compare",
|
||||||
|
"MAM_ALLOW_UNSIGNED_DISPATCH": "string compare", "MAM_DELEGATE_JOB_NO_MAIN": "string compare",
|
||||||
|
"MAM_NO_AUTOSTART": "string compare", "MAM_SHIM_DEBUG": "string compare",
|
||||||
|
"MAM_NO_SUBSCRIBER_DAEMON": "string compare", "MAM_DELEGATION_PY": "string compare",
|
||||||
|
"MAM_AUTO_TRUST": "string compare", "MAM_KILL_ON_READY_TIMEOUT": "string compare",
|
||||||
|
"MAM_LOOP_PY": "string compare", "MAM_LOOP_FREEZE": "string compare", "MAM_LOOP_NO_FREEZE": "string compare",
|
||||||
|
"MAM_LOOP_FREEZE_OWNED": "string compare", "MAM_AUTODETECT_FORCE_FAIL": "string compare",
|
||||||
|
"MAM_WS_LABEL_EXPLICIT": "passed to python", "SKS_IDLE_HANDSHAKE": "string compare",
|
||||||
|
"SKS_DIALOG_ESCAPE": "string compare", "SKS_PROMPT_WAIT": "string compare",
|
||||||
|
"MAM_MIN_PANE_COLS": "argv of python -m lib_py.layout", "MAM_MIN_PANE_ROWS": "argv of python -m lib_py.layout",
|
||||||
|
"MAM_MAX_PANE_COLS": "argv of python -m lib_py.layout", "MAM_MAX_PANE_ROWS": "argv of python -m lib_py.layout",
|
||||||
|
"MAM_BUFFER_GC_MINUTES": "find -mmin", "MAM_DIALOG_TAIL_LINES": "tail -n",
|
||||||
|
"SKS_IDLE_TIMEOUT_MS": "argv of the python idle watcher", "MAM_DIALOG_RESEND_S": "python int()",
|
||||||
|
"MAM_READY_EMPTY_GIVEUP": "[ -ge ] (decimal)", "SKS_EMPTY_GIVEUP": "[ -ge ] (decimal)",
|
||||||
|
"SKS_IDLE_RETRIES": "[ -le ] (decimal)", "SKS_IDLE_RETRY_BACKOFF_S": "sleep",
|
||||||
|
"SKS_QUIESCENT_INTERVAL": "sleep", "MAM_LOCK_STEAL_RETRIES": "[ -le ] (decimal)",
|
||||||
|
"MAM_DIFF_MAX_BYTES": "[ -gt ] (decimal)", "MAM_DIFF_MAX_LINES": "[ -gt ] (decimal)",
|
||||||
|
"RECONCILE_POLL_INTERVAL": "sleep",
|
||||||
|
"DELEGATE_JOB_ACK_S": "create_session.sh -> delegate_wait_started, which runs mam_int on it",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _expected(value: str, minimum: int):
|
def _expected(value: str, minimum: int):
|
||||||
s = value.strip()
|
s = value.strip()
|
||||||
if not s.isdigit():
|
if not s.isdigit():
|
||||||
return None
|
return None
|
||||||
|
s = s.lstrip("0") or "0"
|
||||||
|
if len(s) > 9:
|
||||||
|
return None
|
||||||
n = int(s)
|
n = int(s)
|
||||||
return n if minimum <= n <= 999_999_999 else None
|
return n if n >= minimum else None
|
||||||
|
|
||||||
|
|
||||||
|
def _id(value: str) -> str:
|
||||||
|
return repr(value) if len(value) <= 12 else f"{value[:3]}...x{len(value)}"
|
||||||
|
|
||||||
|
|
||||||
def _base_env(home: Path, extra=None) -> dict:
|
def _base_env(home: Path, extra=None) -> dict:
|
||||||
@@ -103,7 +141,7 @@ def _bash(snippet: str, home: Path, extra_env=None) -> subprocess.CompletedProce
|
|||||||
# B1 - one table, the real wrapper and the real runner agree
|
# B1 - one table, the real wrapper and the real runner agree
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", INPUTS)
|
@pytest.mark.parametrize("value", INPUTS, ids=_id)
|
||||||
def test_b1_wrapper_and_runner_give_the_same_budget(tmp_path, value):
|
def test_b1_wrapper_and_runner_give_the_same_budget(tmp_path, value):
|
||||||
minimum, default = INT_KNOBS["MAM_JOB_TIMEOUT_S"]
|
minimum, default = INT_KNOBS["MAM_JOB_TIMEOUT_S"]
|
||||||
want = _expected(value, minimum)
|
want = _expected(value, minimum)
|
||||||
@@ -121,7 +159,7 @@ def test_b1_wrapper_and_runner_give_the_same_budget(tmp_path, value):
|
|||||||
assert f"arith={budget}" in ares.stdout, (ares.stdout, ares.stderr)
|
assert f"arith={budget}" in ares.stdout, (ares.stdout, ares.stderr)
|
||||||
|
|
||||||
rres = _runner(tmp_path, {"MAM_JOB_TIMEOUT_S": value})
|
rres = _runner(tmp_path, {"MAM_JOB_TIMEOUT_S": value})
|
||||||
assert rres.returncode == 0, rres.stderr
|
assert rres.returncode == 0 and "Traceback" not in rres.stderr, rres.stderr[-500:]
|
||||||
got = json.loads(rres.stdout.strip().splitlines()[-1])
|
got = json.loads(rres.stdout.strip().splitlines()[-1])
|
||||||
assert got["budget"] == budget
|
assert got["budget"] == budget
|
||||||
assert got["contract"][:2] == ["--timeout", str(budget)]
|
assert got["contract"][:2] == ["--timeout", str(budget)]
|
||||||
@@ -166,10 +204,10 @@ def test_b2_wrapper_flags_are_decimal_or_refused(tmp_path):
|
|||||||
ok = _dry_submit(tmp_path, None, "--timeout", "30")
|
ok = _dry_submit(tmp_path, None, "--timeout", "30")
|
||||||
assert _record(tmp_path, ok)["timeout_sec"] == 30
|
assert _record(tmp_path, ok)["timeout_sec"] == 30
|
||||||
for flag in ("--timeout", "--idle-timeout", "--heartbeat", "--heartbeat-misses", "--ack-timeout"):
|
for flag in ("--timeout", "--idle-timeout", "--heartbeat", "--heartbeat-misses", "--ack-timeout"):
|
||||||
for bad in ("abc", "0", "-5", "9999999999"):
|
for bad in ("abc", "0", "-5", "9999999999", "9" * 5000):
|
||||||
res = _dry_submit(tmp_path, None, flag, bad)
|
res = _dry_submit(tmp_path, None, flag, bad)
|
||||||
assert res.returncode == 1, (flag, bad, res.stdout, res.stderr)
|
assert res.returncode == 1, (flag, bad[:20], res.stdout, res.stderr[-500:])
|
||||||
assert f"ERROR: {flag} {bad}: not a positive decimal integer" in res.stderr, res.stderr
|
assert f"ERROR: {flag} {bad}: not a positive decimal integer" in res.stderr, res.stderr[-500:]
|
||||||
assert "registered job" not in res.stdout
|
assert "registered job" not in res.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -177,10 +215,14 @@ def test_b2_runner_job_timeout_is_decimal_or_refused(tmp_path):
|
|||||||
res = _runner(tmp_path, None, "--job-timeout", "0700")
|
res = _runner(tmp_path, None, "--job-timeout", "0700")
|
||||||
assert res.returncode == 0, res.stderr
|
assert res.returncode == 0, res.stderr
|
||||||
assert json.loads(res.stdout.strip().splitlines()[-1])["budget"] == 700
|
assert json.loads(res.stdout.strip().splitlines()[-1])["budget"] == 700
|
||||||
for bad in ("0", "abc", "-5", "9999999999"):
|
for bad in ("0", "abc", "-5", "9999999999", "9" * 5000):
|
||||||
res = _runner(tmp_path, None, "--job-timeout", bad)
|
res = _runner(tmp_path, None, "--job-timeout", bad)
|
||||||
assert res.returncode == 1, (bad, res.stderr)
|
assert res.returncode == 1, (bad[:20], res.stderr[-500:])
|
||||||
assert "--job-timeout requires a positive decimal integer" in res.stderr
|
assert "--job-timeout requires a positive decimal integer" in res.stderr
|
||||||
|
assert "Traceback" not in res.stderr, res.stderr[-500:]
|
||||||
|
res = _runner(tmp_path, None, "--job-timeout", "0" * 5000 + "30")
|
||||||
|
assert res.returncode == 0 and "Traceback" not in res.stderr, res.stderr[-500:]
|
||||||
|
assert json.loads(res.stdout.strip().splitlines()[-1])["budget"] == 30
|
||||||
|
|
||||||
|
|
||||||
def test_b2_runner_passes_normalised_contract_flags(tmp_path):
|
def test_b2_runner_passes_normalised_contract_flags(tmp_path):
|
||||||
@@ -293,3 +335,34 @@ def test_b5_every_normalised_knob_uses_the_table_minimum():
|
|||||||
flags = {f.split(":")[0] for f in loop.group(1).split()}
|
flags = {f.split(":")[0] for f in loop.group(1).split()}
|
||||||
assert {f"--{f}" for f in flags} == {k for k in INT_KNOBS if k.startswith("--")}
|
assert {f"--{f}" for f in flags} == {k for k in INT_KNOBS if k.startswith("--")}
|
||||||
assert 'mam_int "${!_var}" 1' in loop.group(2)
|
assert 'mam_int "${!_var}" 1' in loop.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_b5_every_integer_env_default_is_classified():
|
||||||
|
"""Review R2: a knob can reach arithmetic through a function argument, which
|
||||||
|
the direct-pattern guard above cannot see. Every integer-looking env default
|
||||||
|
in the shell code is therefore either normalised (INT_KNOBS) or listed in
|
||||||
|
NOT_ARITHMETIC with where it goes."""
|
||||||
|
files = [WRAPPER, *sorted(SKILLS.glob("**/*.sh"))]
|
||||||
|
found = {}
|
||||||
|
for f in files:
|
||||||
|
for m in re.finditer(r"\$\{((?:MAM|SKS|DELEGATE_JOB|RECONCILE)_[A-Z0-9_]*):-[0-9]", f.read_text()):
|
||||||
|
found.setdefault(m.group(1), f.relative_to(REPO_ROOT))
|
||||||
|
raw_normalised = {k: v for k, v in found.items() if k in INT_KNOBS}
|
||||||
|
assert not raw_normalised, f"normalised knobs read raw (use mam_int_knob): {raw_normalised}"
|
||||||
|
unclassified = {k: str(v) for k, v in found.items() if k not in NOT_ARITHMETIC}
|
||||||
|
assert not unclassified, f"classify in INT_KNOBS or NOT_ARITHMETIC: {unclassified}"
|
||||||
|
stale = set(NOT_ARITHMETIC) - set(found)
|
||||||
|
assert not stale, f"no longer in the code: {stale}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,rc", [("900", 0), ("0900", 0), ("0", 2)])
|
||||||
|
def test_b5_pane_quiescent_reads_the_tries_knob_as_decimal(tmp_path, value, rc):
|
||||||
|
"""The consuming function with the call site's normalisation (review R2):
|
||||||
|
0900 used to raise 'value too great for base' and return 2 (headless)."""
|
||||||
|
line = next(l for l in LIB_SH.read_text().splitlines() if l.strip().startswith('_pane_quiescent "$sess"'))
|
||||||
|
assert '"$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)"' in line
|
||||||
|
res = _bash('_pane_capture() { echo stable; }; '
|
||||||
|
'_pane_quiescent s "$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)" 0; echo "rc=$?"',
|
||||||
|
tmp_path, {"SKS_QUIESCENT_TRIES": value})
|
||||||
|
assert f"rc={rc}" in res.stdout, (res.stdout, res.stderr)
|
||||||
|
assert "value too great" not in res.stderr and "ignored" not in res.stderr
|
||||||
Reference in new issue
Block a user