From: Thomas Walker Lynch Date: Fri, 19 Sep 2025 06:28:07 +0000 (-0700) Subject: StageHand test_0 phase-2 passed X-Git-Url: https://git.reasoningtechnology.com/style/static/gitweb.css?a=commitdiff_plain;h=0ca3c205c8e2ccf3405d22b331bd414b455f8d47;p=subu StageHand test_0 phase-2 passed --- diff --git a/developer/source/DNS/Planner.py b/developer/source/DNS/Planner.py index 0782707..b1cf34f 100644 --- a/developer/source/DNS/Planner.py +++ b/developer/source/DNS/Planner.py @@ -231,9 +231,10 @@ class PlanProvenance: Runner-provided, read-only provenance for a single config script. """ __slots__ = ("stage_root_dpath","config_abs_fpath","config_rel_fpath", - "read_dir_dpath","read_fname") + "read_dir_dpath","read_fname","process_user") def __init__(self, *, stage_root: Path, config_path: Path): + import getpass self.stage_root_dpath = stage_root.resolve() self.config_abs_fpath = config_path.resolve() try: @@ -241,10 +242,8 @@ class PlanProvenance: except Exception: self.config_rel_fpath = Path(self.config_abs_fpath.name) - # Where the config file lives (used to anchor relative write dirs) self.read_dir_dpath = self.config_abs_fpath.parent - # “py-less” filename: strip .stage.py, else .py, else keep name name = self.config_abs_fpath.name if name.endswith(".stage.py"): self.read_fname = name[:-len(".stage.py")] @@ -253,23 +252,19 @@ class PlanProvenance: else: self.read_fname = name + # NEW: owner of the StageHand process + self.process_user = getpass.getuser() + def print(self, *, file=None) -> None: - """ - Given: optional file-like (defaults to stdout). - Does: print a readable, multi-line summary of provenance. - Returns: None. - """ if file is None: import sys as _sys file = _sys.stdout - print(f"Stage root: {self.stage_root_dpath}", file=file) print(f"Config (rel): {self.config_rel_fpath.as_posix()}", file=file) print(f"Config (abs): {self.config_abs_fpath}", file=file) print(f"Read dir: {self.read_dir_dpath}", file=file) print(f"Read fname: {self.read_fname}", file=file) - - + print(f"Process user: {self.process_user}", file=file) # NEW # ===== Admin-facing defaults carrier ===== @@ -287,27 +282,16 @@ class WriteFileMeta: def __init__(self ,* ,dpath="/" - ,fname=None # None or "." → let Planner resolve (provenance fallback) - ,owner="root" # "." → current process user (resolved by Planner) + ,fname=None # None → let Planner/provenance choose + ,owner="root" ,mode=0o444 ,content=None ): self.dpath_str = norm_dpath_str(dpath) - # keep "." as a sentinel; otherwise validate the filename - if fname == ".": - self.fname = "." - else: - self.fname = norm_fname_or_none(fname) - - # keep "." as a sentinel; otherwise normalize owner - if owner == ".": - self.owner_name_str = "." - else: - self.owner_name_str = norm_nonempty_owner(owner) - + self.fname = norm_fname_or_none(fname) # '.' no longer special → None + self.owner_name_str = norm_nonempty_owner(owner) # '.' rejected → None self.mode_int, self.mode_octal_str = parse_mode(mode) - # content_'bytes' due to UTF8 encoding - self.content_bytes = norm_content_bytes(content) + self.content_bytes = norm_content_bytes(content) def print(self, *, label: str | None = None, file=None) -> None: """ @@ -359,6 +343,11 @@ class Planner: # --- defaults management / access --- + # in Planner.py, inside class Planner + def set_provenance(self, prov: PlanProvenance) -> None: + """Switch the current provenance used for fallbacks & per-command provenance tagging.""" + self._prov = prov + def set_defaults(self ,defaults: WriteFileMeta)-> None: "Given WriteFileMeta. Does replace planner defaults. Returns None." self._defaults = defaults @@ -377,18 +366,15 @@ class Planner: "Given three sources. Does pick first non-None. Returns value or None." return kw if kw is not None else (meta_attr if meta_attr is not None else default_attr) - # Planner.py (inside Planner) def _resolve_write_file(self, wfm, dpath, fname) -> tuple[str|None, str|None]: - # normalize explicit kwargs (allow "." to pass through untouched) dpath_str = norm_dpath_str(dpath) if dpath is not None else None - if fname is not None and fname != ".": - fname = norm_fname_or_none(fname) + fname = norm_fname_or_none(fname) if fname is not None else None dpath_val = self._pick(dpath_str, (wfm.dpath_str if wfm else None), self._defaults.dpath_str) fname_val = self._pick(fname, (wfm.fname if wfm else None), self._defaults.fname) - # final fallback for filename: "." or None → derive from config name - if fname_val == "." or fname_val is None: + # final fallback for filename: derive from config name + if fname_val is None: fname_val = self._prov.read_fname # anchor relative dpaths against the config’s directory @@ -403,20 +389,15 @@ class Planner: ,mode: int|str|None ,content: bytes|str|None )-> tuple[str|None ,tuple[int|None ,str|None] ,bytes|None]: - owner_norm = norm_nonempty_owner(owner) if (owner is not None and owner != ".") else owner - mode_norm = parse_mode(mode) if mode is not None else (None, None) + owner_norm = norm_nonempty_owner(owner) if owner is not None else None + mode_norm = parse_mode(mode) if mode is not None else (None ,None) content_b = norm_content_bytes(content) if content is not None else None owner_v = self._pick(owner_norm, (wfm.owner_name_str if wfm else None), self._defaults.owner_name_str) - - # resolve "." → current process user - if owner_v == ".": - owner_v = getpass.getuser() - - mode_v = (mode_norm if mode_norm != (None, None) else - ((wfm.mode_int, wfm.mode_octal_str) if wfm else (self._defaults.mode_int, self._defaults.mode_octal_str))) - content_v = self._pick(content_b, (wfm.content_bytes if wfm else None), self._defaults.content_bytes) - return owner_v, mode_v, content_v + mode_v = (mode_norm if mode_norm != (None ,None) else + ((wfm.mode_int ,wfm.mode_octal_str) if wfm else (self._defaults.mode_int ,self._defaults.mode_octal_str))) + content_v = self._pick(content_b ,(wfm.content_bytes if wfm else None) ,self._defaults.content_bytes) + return owner_v ,mode_v ,content_v def print(self, *, show_journal: bool = True, file=None) -> None: """ diff --git a/developer/source/DNS/executor.py b/developer/source/DNS/executor.py index fffa490..1690f49 100755 --- a/developer/source/DNS/executor.py +++ b/developer/source/DNS/executor.py @@ -2,22 +2,19 @@ """ executor.py — StageHand outer/inner executor (MVP; UNPRIVILEGED for now) +Phase 0 (bootstrap): + - Ensure filter program exists (create default in CWD if --filter omitted) + - Validate --stage exists + - If --phase-0-then-stop: exit here (no scan, no execution) + Phase 1 (outer): - - Build a combined plan by executing each config's `configure(prov, planner, WriteFileMeta)`. - - Optionally print the plan via Planner.print(). - - Optionally stop. + - Discover every file under --stage; acceptance filter decides which to include + - Execute each config’s configure(prov, planner, WriteFileMeta) into ONE Planner + - Optionally print the planner; optionally stop Phase 2 (inner shim in same program for now; no privilege yet): - - Encode combined plan to CBOR and pass to inner path. - - Inner decodes back to a Journal and optionally prints it. - - Optionally stop. - -Discovery: - - --stage (default: ./stage) points at the stage directory root. - - By default, *every file* under --stage (recursively) is executed as a config, - regardless of extension. Editors can still use .py for highlighting; we strip - only a trailing ".py" to derive prov.read_fname. - + - Encode plan to CBOR and hand to inner path + - Inner decodes to a Journal and can print it """ from __future__ import annotations @@ -34,86 +31,153 @@ import tempfile import runpy import subprocess import datetime as _dt -import os, fnmatch, stat - +import stat # Local module: Planner.py (same directory) from Planner import ( Planner, PlanProvenance, WriteFileMeta, Journal, Command, ) +# -------- default filter template (written to CWD when --filter not provided) -------- + +DEFAULT_FILTER_FILENAME = "stagehand_filter.py" + +DEFAULT_FILTER_SOURCE = """# StageHand acceptance filter (default template) +# Return True to include a config file, False to skip it. +# You receive a PlanProvenance object named `prov`. +# +# prov fields commonly used here: +# prov.stage_root_dpath : Path → absolute path to the stage root +# prov.config_abs_fpath : Path → absolute path to the candidate file +# prov.config_rel_fpath : Path → path relative to the stage root +# prov.read_dir_dpath : Path → directory of the candidate file +# prov.read_fname : str → filename with trailing '.py' stripped (if present) +# +# Examples: +# +# 1) Accept everything (default behavior): +# def accept(prov): +# return True +# +# 2) Only accept configs in a 'dns/' namespace under the stage: +# def accept(prov): +# return prov.config_rel_fpath.as_posix().startswith("dns/") +# +# 3) Exclude editor backup files: +# def accept(prov): +# rel = prov.config_rel_fpath.as_posix() +# return not (rel.endswith("~") or rel.endswith(".swp")) +# +# 4) Only accept Python files + a few non-Python names: +# def accept(prov): +# name = prov.config_abs_fpath.name +# return name.endswith(".py") or name in {"hosts", "resolv.conf"} +# +# Choose ONE 'accept' definition. Below is the default: + +def accept(prov): + return True +""" + # -------- utilities -------- def iso_utc_now_str() -> str: return _dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") -def _split_globs(glob_arg: str) -> list[str]: - parts = [g.strip() for g in (glob_arg or "").split(",") if g.strip()] - # Default includes both deep and top-level files - return parts or ["**/*", "*"] - -def find_config_paths(stage_root: Path, glob_arg: str) -> list[Path]: - """ - Given stage root and comma-glob string, return sorted list of files (regular or symlink). - Defaults to match ALL files under stage, including top-level ones. - """ - root = stage_root.resolve() - patterns = _split_globs(glob_arg) - out: set[Path] = set() - - for dirpath, dirnames, filenames in os.walk(root, followlinks=False): - # (optional) prune symlinked dirs to avoid cycles; files can still be symlinks - dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))] - - for fname in filenames: - f_abs = Path(dirpath, fname) - rel = f_abs.relative_to(root).as_posix() - if any(fnmatch.fnmatch(rel, pat) for pat in patterns): - try: - st = f_abs.lstat() - if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode): - out.add(f_abs) - except Exception: - # unreadable/broken entries are skipped - pass - - return sorted(out, key=lambda p: p.as_posix()) - - - -def _run_one_config(config_path: Path, stage_root: Path) -> Planner: - """Execute a single config's `configure(prov, planner, WriteFileMeta)` and return that config's Planner.""" - prov = PlanProvenance(stage_root=stage_root, config_path=config_path) - per_planner = Planner(provenance=prov) # defaults derive from this file's provenance - env = runpy.run_path(str(config_path)) - fn = env.get("configure") +def _ensure_filter_file(filter_arg: str|None) -> Path: + """ + If --filter is provided, return that path (must exist). + Otherwise, create ./stagehand_filter.py in the CWD if missing (writing a helpful template), + and return its path. + """ + if filter_arg: + p = Path(filter_arg) + if not p.is_file(): + raise RuntimeError(f"--filter file not found: {p}") + return p + + p = Path.cwd() / DEFAULT_FILTER_FILENAME + if not p.exists(): + try: + p.write_text(DEFAULT_FILTER_SOURCE, encoding="utf-8") + print(f"(created default filter at {p})") + except Exception as e: + raise RuntimeError(f"failed to create default filter {p}: {e}") + return p + +def _load_accept_func(filter_path: Path): + env = runpy.run_path(str(filter_path)) + fn = env.get("accept") if not callable(fn): - raise RuntimeError(f"{config_path}: missing callable configure(prov, planner, WriteFileMeta)") - fn(prov, per_planner, WriteFileMeta) - return per_planner - -def _aggregate_into_master(stage_root: Path, planners: list[Planner]) -> Planner: - """Create a master Planner and copy all Commands from per-config planners into it.""" - # Synthetic provenance for the master planner (used only for display/meta) - fake_config = stage_root / "(aggregate).py" - master = Planner(PlanProvenance(stage_root=stage_root, config_path=fake_config)) - - # annotate meta - master.journal().set_meta( + raise RuntimeError(f"{filter_path}: missing callable 'accept(prov)'") + return fn + +def _walk_all_files(stage_root: Path): + """ + Yield every file (regular or symlink) under stage_root recursively. + We do not follow symlinked directories to avoid cycles. + """ + root = stage_root.resolve() + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + # prune symlinked dirs (files can still be symlinks) + dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))] + for fname in filenames: + p = Path(dirpath, fname) + try: + st = p.lstat() + if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode): + yield p.resolve() + except Exception: + # unreadable/broken entries skipped + continue + +def find_config_paths(stage_root: Path, accept_func) -> list[Path]: + """ + Return files accepted by the Python acceptance function: accept(prov) → True/False. + """ + out: list[Path] = [] + for p in _walk_all_files(stage_root): + prov = PlanProvenance(stage_root=stage_root, config_path=p) + try: + if accept_func(prov): + out.append(p) + except Exception as e: + raise RuntimeError(f"accept() failed on {prov.config_rel_fpath.as_posix()}: {e}") + return sorted(out, key=lambda q: q.as_posix()) + +# --- run all configs into ONE planner --- + +def _run_all_configs_into_single_planner(stage_root: Path, cfgs: list[Path]) -> Planner: + """ + Create a single Planner and execute each config's configure(prov, planner, WriteFileMeta) + against it. Returns that single Planner containing the entire plan. + """ + # seed with synthetic provenance; we overwrite per config before execution + aggregate_prov = PlanProvenance(stage_root=stage_root, config_path=stage_root / "(aggregate).py") + planner = Planner(provenance=aggregate_prov) + + for cfg in cfgs: + prov = PlanProvenance(stage_root=stage_root, config_path=cfg) + planner.set_provenance(prov) + + env = runpy.run_path(str(cfg)) + fn = env.get("configure") + if not callable(fn): + raise RuntimeError(f"{cfg}: missing callable configure(prov, planner, WriteFileMeta)") + + fn(prov, planner, WriteFileMeta) + + # annotate meta once, on the single planner's journal + j = planner.journal() + j.set_meta( generator_prog_str="executor.py", generated_at_utc_str=iso_utc_now_str(), user_name_str=getpass.getuser(), host_name_str=os.uname().nodename if hasattr(os, "uname") else "unknown", stage_root_dpath_str=str(stage_root.resolve()), - configs_list=[p._prov.config_rel_fpath.as_posix() for p in planners], + configs_list=[str(p.resolve().relative_to(stage_root.resolve())) for p in cfgs], ) - - # copy commands - out_j = master.journal() - for p in planners: - for cmd in p.journal().command_list: - out_j.append(cmd) # keep Command objects as-is - return master + return planner # ----- CBOR “matchbox” (simple wrapper kept local to executor) ----- @@ -164,30 +228,23 @@ def _inner_main(plan_path: Path, phase2_print: bool, phase2_then_stop: bool) -> # -------- outer executor (phase 1 & handoff) -------- -def _outer_main(args) -> int: - stage_root = Path(args.stage) +def _outer_main(stage_root: Path, accept_func, args) -> int: if not stage_root.is_dir(): print(f"error: --stage not a directory: {stage_root}", file=sys.stderr) return 2 - cfgs = find_config_paths(stage_root, args.glob) + cfgs = find_config_paths(stage_root, accept_func) if not cfgs: print("No configuration files found.") return 0 - # Execute each config into its own planner - per_planners: list[Planner] = [] - for cfg in cfgs: - try: - per_planners.append(_run_one_config(cfg, stage_root)) - except SystemExit: - raise - except Exception as e: - print(f"error: executing {cfg}: {e}", file=sys.stderr) - return 2 - - # Aggregate into a single master planner for printing/CBOR - master = _aggregate_into_master(stage_root, per_planners) + try: + master = _run_all_configs_into_single_planner(stage_root, cfgs) + except SystemExit: + raise + except Exception as e: + print(f"error: executing configs: {e}", file=sys.stderr) + return 2 if args.phase_1_print: master.print() @@ -233,14 +290,19 @@ def main(argv: list[str] | None = None) -> int: prog="executor.py", description="StageHand outer/inner executor (plan → CBOR → decode).", ) - ap.add_argument("--stage", default="stage", help="stage root directory (default: ./stage)") - - ap.add_argument("--glob", default="**/*", - help="glob for config scripts under --stage (default: '**/*' = all files)") - - # ap.add_argument("--glob", - # default="**/*", - # help="comma-separated globs under --stage (default: **/*; every file is a config)") + ap.add_argument("--stage", default="stage", + help="stage root directory (default: ./stage)") + ap.add_argument( + "--filter", + default="", + help=f"path to acceptance filter program exporting accept(prov) " + f"(default: ./{DEFAULT_FILTER_FILENAME}; created if missing)" + ) + ap.add_argument( + "--phase-0-then-stop", + action="store_true", + help="stop after arg checks & filter bootstrap (no stage scan)" + ) # Phase-1 (outer) controls ap.add_argument("--phase-1-print", action="store_true", help="print master planner (phase 1)") @@ -256,6 +318,7 @@ def main(argv: list[str] | None = None) -> int: args = ap.parse_args(argv) + # Inner path if args.inner: if not args.plan: print("error: --inner requires --plan ", file=sys.stderr) @@ -264,8 +327,33 @@ def main(argv: list[str] | None = None) -> int: phase2_print=args.phase_2_print, phase2_then_stop=args.phase_2_then_stop) - return _outer_main(args) + # Phase 0: bootstrap & stop (no scan) + stage_root = Path(args.stage) + try: + filter_path = _ensure_filter_file(args.filter or None) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + return 2 + + if not stage_root.exists(): + print(f"error: --stage not found: {stage_root}", file=sys.stderr) + return 2 + if not stage_root.is_dir(): + print(f"error: --stage is not a directory: {stage_root}", file=sys.stderr) + return 2 + + if args.phase_0_then_stop: + print(f"phase-0 OK: stage at {stage_root.resolve()} and filter at {filter_path}") + return 0 + + # Load acceptance function and proceed with outer + try: + accept_func = _load_accept_func(filter_path) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + return 2 + return _outer_main(stage_root, accept_func, args) if __name__ == "__main__": sys.exit(main()) diff --git a/developer/source/DNS/stage_test_0/unbound_conf.py b/developer/source/DNS/stage_test_0/unbound_conf.py index 4f57794..ff275b9 100644 --- a/developer/source/DNS/stage_test_0/unbound_conf.py +++ b/developer/source/DNS/stage_test_0/unbound_conf.py @@ -1,15 +1,10 @@ -# example unbound.conf -def configure(prov, planner, WriteFileMeta): - # use current user for owner, and use this script’s py-less name for the filename - - # owner defaults to root (this is a configuration file installer) - # owner "." means owner of the process running Stagehane - # owner "." is good for testing - - # fname "." means write file has the same name as read file (without .py if it has .py) - # fname "." is the default, so it is redundant here. "." still works in args, even when wfm changes the fname. +# unbound.conf (example) - wfm = WriteFileMeta(dpath="stage_test_0_out", fname=".", owner=".") +def configure(prov, planner, WriteFileMeta): + wfm = WriteFileMeta( + dpath="stage_test_0_out" + ,fname=prov.read_fname # write file name same as read file name + ,owner=prov.process_user + ) planner.displace(wfm) planner.copy(wfm, content="server:\n do-ip6: no\n") - diff --git a/developer/source/DNS/stagehand_filter.py b/developer/source/DNS/stagehand_filter.py new file mode 100644 index 0000000..6400684 --- /dev/null +++ b/developer/source/DNS/stagehand_filter.py @@ -0,0 +1,35 @@ +# StageHand acceptance filter (default template) +# Return True to include a config file, False to skip it. +# You receive a PlanProvenance object named `prov`. +# +# prov fields commonly used here: +# prov.stage_root_dpath : Path → absolute path to the stage root +# prov.config_abs_fpath : Path → absolute path to the candidate file +# prov.config_rel_fpath : Path → path relative to the stage root +# prov.read_dir_dpath : Path → directory of the candidate file +# prov.read_fname : str → filename with trailing '.py' stripped (if present) +# +# Examples: +# +# 1) Accept everything (default behavior): +# def accept(prov): +# return True +# +# 2) Only accept configs in a 'dns/' namespace under the stage: +# def accept(prov): +# return prov.config_rel_fpath.as_posix().startswith("dns/") +# +# 3) Exclude editor backup files: +# def accept(prov): +# rel = prov.config_rel_fpath.as_posix() +# return not (rel.endswith("~") or rel.endswith(".swp")) +# +# 4) Only accept Python files + a few non-Python names: +# def accept(prov): +# name = prov.config_abs_fpath.name +# return name.endswith(".py") or name in {"hosts", "resolv.conf"} +# +# Choose ONE 'accept' definition. Below is the default: + +def accept(prov): + return True