diff --git a/docs/config-schema.md b/docs/config-schema.md index 495a2f3..eb8172d 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -1515,6 +1515,11 @@ tmux: position: top # "top" or "bottom" format: "#{session_name} #{window_index}:#{window_name}#{window_flags}" clear_status_right: true # separately clear tmux's default clock+date status-right + focus_events: + enabled: true # set -g focus-events on — report terminal focus in/out + # events to programs running inside panes (some editors/ + # tools need this). A plain terminal-capability toggle, no + # interaction with the resurrect/continuum/tpm ordering. ``` | Key | Type | Default | Meaning | @@ -1543,6 +1548,7 @@ tmux: | `pane_titles.clear_status_right` | bool | `true` | when `pane_titles.enabled` is also true, additionally clear tmux's built-in `status-right` default — which is otherwise a clock+date (`%H:%M %d-%b-%y`), wasted space once the pane title carries the session/window context. A SEPARATE toggle from `enabled` (not independent of it): set this `false` to keep the border title while preserving an existing custom `status-right`. | | `login_shell.enabled` | bool | `true` | set a **login-shell** `default-command` so restored panes source `~/.zprofile`/PATH (resurrect otherwise restores a non-login shell with a broken env) | | `login_shell.shell` | str | `""` | login shell path. `""` resolves the user's `$SHELL` at apply (falling back to `/bin/zsh` then `/bin/sh`); a non-empty override **must be an absolute path** to the shell binary (a relative name or a command-with-args is rejected, so it can't silently produce a broken `default-command`) and is used verbatim. The path is **baked at generation** — NOT a tmux `${SHELL}` reference, because tmux rejects `${VAR:-default}` and would abort the whole config | +| `focus_events.enabled` | bool | `true` | `set -g focus-events` on/off — tmux reports terminal focus in/out events to programs running inside panes (needed by editors/tools that react to focus). A plain capability toggle: it carries none of the resurrect/continuum/tpm ordering constraints below (it's placed first in the generated file purely for readability, not because position is load-bearing here). Like every other modeled boolean, `false` emits an explicit `set -g focus-events off` rather than omitting the line. | **Apply mechanism — import-preferred, managed-block fallback.** @@ -1689,6 +1695,87 @@ boot-from-cold path can only be fully proven by an actual reboot. --- +## `env` + +Provisions **rig-managed shell environment variables** — a var like `COLORTERM` that must be +visible to **every** shell invocation on the machine, not just an interactive login shell: a +mosh/SSH non-interactive command shell (`ssh host 'some-command'`), a cron/launchd job, or +`zsh -l -c '...'`. This is **GLOBAL config** (the shell startup file rig sources into is +HOME-anchored, not repo-relative) — it belongs in `~/.config/rig/config.yaml`. + +Mirrors `tmux`'s "own a generated file, splice one import line" shape — rig owns +`/rig.env.sh` (wholesale rewrite each apply, one `export KEY=value` per `vars` +entry, sorted by key) and ensures **one** `source ''` line is present in +`rc_path` — every other line in that file is left untouched. Unlike `tmux` there is no dual +import/block mode and no neutralization of unrelated inline content: a plain exported var has +no equivalent of tmux's `@plugin`/`@continuum-*` declarations that need detecting and +superseding, so the splice is a simple idempotent "ensure this one line is present" append. + +**Position-tolerant — deliberately NOT end-anchored like `tmux`.** `tmux`'s import line is +always re-appended at the very END on every apply, dropping any existing copy first, because +tmux's ordering guarantee genuinely depends on position (continuum's `run-shell` init must be +LAST). A plain exported var has no such hazard, so `env` takes the opposite default: if rig's +CURRENT import line already appears **anywhere** in `rc_path` — including above the user's own +exports, e.g. so *their* values win — that line's POSITION is left exactly where the user put +it, and every OTHER line is untouched. This is NOT unconditionally "byte-for-byte untouched", +though: a coexisting STALE line (an old `generated_dir`, left over from before a config change) +is always dropped even when the current line is already present and correctly placed elsewhere +— position-tolerance for the CURRENT line must not become an excuse to leave an orphaned old +`rig.env.sh` silently sourced forever. Only when the current line is truly absent everywhere +(first apply, or only that stale copy existed) does `env` append the current line once, at the +end. `rig status` shares this exact predicate, so it can never disagree with what `rig apply` +would do. + +**No teardown on `enabled: false`.** Like every other rig-managed artifact ("`rig apply` NEVER +deletes on-disk extras"), turning `env` off stops rig from RECONCILING `rig.env.sh` and the +import line, but does not remove them — the vars keep applying to every shell until you delete +`/rig.env.sh` and the `source` line yourself. `rig status` DOES surface this one +specifically — unlike `tmux`, which has no equivalent — checking BOTH halves independently (a +leftover `rig.env.sh`, and separately a leftover `source` line in `rc_path`, since either can +survive without the other: a hand-deleted `rig.env.sh` with the source line still present makes +every shell invocation on the machine print a "no such file or directory" error at startup). +**This coverage fires ONLY for an explicit `env: { enabled: false }`** — REMOVING the `env:` +block entirely (the more natural way to "turn a feature off", and the one actually described +above as "the real leave-shell-env-vars-alone case") runs no scan at all, so the same +still-active artifacts would report as in sync in that case. Only `gitignore`/`git_hooks` +get an equivalent "still installed but now disabled" check elsewhere in rig; extending any of +them to also cover full block-removal is a known, tracked gap, not implemented here. + +**Why `~/.zshenv` by default, not `~/.zshrc`/`~/.zprofile`.** zsh's startup order makes +`~/.zshenv` the only file sourced **unconditionally** in every mode — login or not, interactive +or not (`~/.zshrc` is interactive-only; `~/.zprofile`/`~/.zlogin` are login-only). A var that +must reach `zsh -l -c '...'` (a login but **non-interactive** shell — `-c` means it never +becomes interactive, so `.zshrc` is never sourced) has to live in `.zshenv` or `.zprofile`; +`.zshenv` is the more universal of the two (it also reaches plain non-login non-interactive +invocations). Same reasoning as `tmux.login_shell` treating `~/.zprofile` as the login-env +source of truth, one file more universal. + +A PRESENT `env:` block opts in (mirrors `tmux`: even an explicit empty mapping `env: {}` is +accepted, yielding a harmless header-only generated file); only an ABSENT key or +`enabled: false` is a no-op. + +```yaml +env: + enabled: true # provision the rig-managed shell env vars (opt-in) + rc_path: ~/.zshenv # the shell startup file rig sources the generated file from + generated_dir: ~/.config/rig/env # where rig writes rig.env.sh + vars: + COLORTERM: truecolor # exported KEY=value pairs +``` + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `enabled` | bool | — (see below) | opts OUT when explicitly `false`. There is no fixed default for this key alone — a PRESENT `env:` block with `enabled` unset already opts IN (mirrors `tmux`); it is the BLOCK's presence, not this key's own default, that decides. An entirely ABSENT `env:` block is the real "leave shell env vars alone" case. | +| `rc_path` | path | `~/.zshenv` | the shell startup file rig sources the generated env file from | +| `generated_dir` | path | `~/.config/rig/env` | where rig writes `rig.env.sh` | +| `vars` | map[str,str] | `{}` | exported `KEY=value` pairs. Keys must be valid POSIX shell identifiers (`[A-Za-z_][A-Za-z0-9_]*` — they are interpolated UNQUOTED into `export {key}=...`, a file sourced by every shell invocation on the machine, so an invalid key is rejected at validate time rather than reaching that file). Values are shell-quoted at render time, safe for spaces/quotes/`$`/backticks/anything. | + +Idempotent: a re-apply that finds both the generated file and the import line already current is +a `skipped` no-op. `rig status` flags drift on the generated file's content and the import line's +presence — never on any other line in `rc_path` (that file is otherwise entirely the user's own). + +--- + ## `gitignore` Maintains a **rig-managed block** in git's **GLOBAL excludes file** (`core.excludesfile`) so diff --git a/riglib/actions/runner.py b/riglib/actions/runner.py index 7a673ae..191d493 100644 --- a/riglib/actions/runner.py +++ b/riglib/actions/runner.py @@ -3168,6 +3168,7 @@ def tmux_plan_from_action(action: Action): login_shell=dict(opts.get("login_shell", {}) or {}), autosave=dict(opts.get("autosave", {}) or {}), pane_titles=dict(opts.get("pane_titles", {}) or {}), + focus_events=dict(opts.get("focus_events", {}) or {}), ) @@ -6516,6 +6517,152 @@ def _resolve_excludes_target(action: Action) -> tuple[Path, bool, str | None]: return expand_user_path(xdg_default), True, xdg_default +def env_plan_from_action(action: Action): + """Rebuild the pure :class:`~riglib.shell_env.ShellEnvPlan` an action describes. + + Shared by the install handler and the drift check so both agree on the exact desired + artifacts from the action's options. ``Path.home()`` is the resolved HOME at apply time (a + test monkeypatches it to a tmp HOME). Lazy import keeps the actions package import-light. + + UNLIKE ``tmux_plan_from_action``, ``rc_path``/``generated_dir`` are REQUIRED here — a + ``KeyError`` for a missing one is a programmer error, not a runtime condition to paper over. + ``env`` is a brand-new action kind (unlike ``tmux``, which has genuinely pre-dated some of + its options across real upgrades), so there is no real "action persisted by an older rig" + scenario for a bare-default fallback to serve; ``_build_env`` (``riglib/plan.py``) is the + ONE place that resolves the defaults (including the ``~/.config`` -> ``$XDG_CONFIG_HOME`` + special case) and it always writes both keys into ``Action.options`` — so a duplicate + fallback default here would only ever be exercised by a caller NOT going through + ``_build_env``, and would then risk resolving differently than the plan builder did (review + finding, discovered via exactly that divergence). Failing loudly beats resolving quietly + wrong. + """ + from ..shell_env import build_shell_env + + opts = action.options + return build_shell_env( + repo_home=Path.home(), + rc_path=str(opts["rc_path"]), + generated_dir=str(opts["generated_dir"]), + vars=dict(opts.get("vars", {}) or {}), + ) + + +def _do_provision_env(action: Action, on_conflict: str) -> ActionResult: + """Generate the rig-managed shell-env-vars file and ensure it is sourced from ``rc_path``. + + What it writes: + - ``/rig.env.sh`` — the rig-owned file (wholesale rewrite; one + ``export KEY=value`` line per configured var). + - ``rc_path`` (default ``~/.zshenv``) — POSITION-TOLERANT: if rig's CURRENT import line + already appears anywhere, ``rc_path`` is untouched (even a user-relocated line is left + where they put it); otherwise it is appended at the end, dropping any STALE copy (an + old ``generated_dir``) first. Nothing but rig's own recognizable line is ever touched — + see :func:`riglib.shell_env.desired_rc_text` for the exact predicate, shared verbatim + with the drift check so apply and status can never disagree. + + Both writes go through :func:`riglib.actions.fsutil.write_file` — same ``on_conflict`` + policy, same backup-on-replace, same error-to-``ActionResult`` conversion for BOTH files. + ``rc_path`` is the user's own, possibly irreplaceable file (unlike the freely-regenerable + ``rig.env.sh``), so it gets the SAME safety treatment, not less (review finding: an earlier + version wrote it with a bare ``write_text`` — no backup, and only ``OSError`` was caught, + so a non-UTF-8 ``rc_path`` raised ``UnicodeDecodeError`` — a ``ValueError`` — right through + apply uncaught). + + Idempotent: a re-apply that finds both artifacts already current is a ``skipped`` no-op. + """ + from ..shell_env import desired_rc_text + + plan = env_plan_from_action(action) + + changed = False + details: list[str] = [] + # conflict-skipped rc_path splice (on_conflict=skip left it untouched though it needed + # reconciling) — surfaced in the result detail but NOT counted as a change (nothing was + # written): unresolved drift, matching tmux's `skipped_conflicts` convention. + unresolved: list[str] = [] + backup: Path | None = None + + # 1) the generated rig.env.sh (wholesale, idempotent on identical bytes). + # + # `render_env_file()`'s key check should never actually raise — `config.validate` already + # rejects an invalid var key before a plan is ever built — but it is a defense-in-depth + # invariant, not a caller contract every path is guaranteed to have gone through (review), + # so a violation is still reported as a clean ActionResult error rather than an uncaught + # ValueError. Same for the mkdir: a file blocking `generated_dir` (or a permissions issue) + # must not propagate raw either. + try: + plan.generated_dir.mkdir(parents=True, exist_ok=True) + rendered = plan.render_env_file() + except (OSError, ValueError) as exc: + return ActionResult(action, "error", f"env: failed to prepare {plan.generated_file_path}: {exc}") + env_out = fsutil.write_file(plan.generated_file_path, rendered, on_conflict) + if env_out.status == "error": + return ActionResult(action, "error", f"env: {env_out.detail}") + if env_out.backup: + backup = env_out.backup + if env_out.status != "skipped": + changed = True + details.append(f"generated {plan.generated_file_path.name}") + elif not env_out.detail.startswith("identical"): + # `on_conflict=skip` left a HAND-EDITED (or otherwise differing) rig.env.sh untouched — + # the SAME unresolved-conflict class as the rc_path branch below, so it needs the SAME + # surfacing: without this, `changed` would stay False here while the file genuinely + # differs from the desired render, and a run where nothing else changed either would + # report "already current" — asserting currency that isn't true, while `rig status` + # simultaneously (and correctly) reports it as modified (review finding: the rc_path + # side got this disambiguation, the generated-file side did not). + unresolved.append( + f"{plan.generated_file_path.name} differs and on_conflict=skip — NOT regenerated" + ) + + # 2) ensure the import line in rc_path. The read (to compute `desired`) can fail the same + # ways the write can — a vanished/unreadable file, or non-UTF-8 content (`UnicodeDecodeError` + # is a `ValueError`, not an `OSError` — both are caught so neither propagates raw). + try: + existing = plan.rc_path.read_text(encoding="utf-8") if plan.rc_path.is_file() else "" + except (OSError, UnicodeDecodeError) as exc: + return ActionResult(action, "error", f"env: failed to read {plan.rc_path}: {exc}", backup) + + desired = desired_rc_text(existing, plan) + if desired != existing: + try: + plan.rc_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return ActionResult( + action, "error", f"env: failed to create {plan.rc_path.parent}: {exc}", backup + ) + rc_out = fsutil.write_file(plan.rc_path, desired, on_conflict) + if rc_out.status == "error": + return ActionResult(action, "error", f"env: {rc_out.detail}", backup) + if rc_out.backup: + # `ActionResult.backup` is a single slot; if BOTH artifacts got backed up this run, + # rc_path's backup deliberately wins (overwrites the generated-file one) — rc_path + # is the irreplaceable file, the generated one is trivially regenerable from config. + # Both backups still exist on disk either way; only the headline pointer is singular. + backup = rc_out.backup + if rc_out.status != "skipped": + changed = True + details.append(f"added import line to {plan.rc_path}") + elif not rc_out.detail.startswith("identical"): + # `on_conflict=skip` left rc_path UNCHANGED even though `desired != existing` — the + # import line was NOT added, so `rig.env.sh` exists but nothing sources it yet: the + # vars silently never take effect. Must not be indistinguishable from a full, + # successful "updated"/"skipped: already current" (review finding — SURFACE it + # explicitly; `changed` correctly stays False here since nothing was WRITTEN, but + # the unresolved-conflict text must still reach the caller, not be discarded by the + # `not changed` branch below returning a hardcoded "already current"). + unresolved.append( + f"import line NOT added to {plan.rc_path} (on_conflict=skip) — the " + "generated vars are not yet sourced; re-run with backup/overwrite to wire it" + ) + + if not changed: + if unresolved: + return ActionResult(action, "skipped", "env: " + "; ".join(unresolved), backup) + return ActionResult(action, "skipped", "env: already current", backup) + return ActionResult(action, "updated", "env: " + "; ".join(details + unresolved), backup) + + def _do_provision_global_excludes(action: Action, on_conflict: str) -> ActionResult: """Provision/reconcile rig's managed block in the GLOBAL git ``core.excludesfile``. @@ -6664,6 +6811,7 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult: "provision_github_actions": _do_provision_github_actions, "provision_github_browser": _do_provision_github_browser, "provision_tmux": _do_provision_tmux, + "provision_env": _do_provision_env, "provision_global_excludes": _do_provision_global_excludes, "provision_spotlight": _do_provision_spotlight, "provision_tools": _do_provision_tools, diff --git a/riglib/areas.py b/riglib/areas.py index 0f0caaa..4c012d6 100644 --- a/riglib/areas.py +++ b/riglib/areas.py @@ -73,6 +73,7 @@ class Area: Area("permissions", "harness permissions (allow / deny / ask)", GLOBAL, ("permissions",)), Area("mode", "agent operating mode", GLOBAL, ("mode",)), Area("tmux", "tmux config", GLOBAL, ("tmux",)), + Area("env", "shell environment variables", GLOBAL, ("env",)), Area("models", "model-freshness cron", GLOBAL, ("models",)), Area("tg_ctl", "tg-ctl inbound daemon", GLOBAL, ("tg_ctl",)), Area("tools", "personal CLI ecosystem (tg/review/task/draw/…)", GLOBAL, ("tools",)), diff --git a/riglib/cli.py b/riglib/cli.py index 10e7241..941bc99 100644 --- a/riglib/cli.py +++ b/riglib/cli.py @@ -1607,6 +1607,32 @@ def cmd_status(args: argparse.Namespace) -> int: ), report, ) + # disabled-but-installed shell env vars: config opted the env category out, but a prior + # apply may have left rig.env.sh + its live source line in rc_path. apply won't remove + # them, so surface as disk→config drift (mirrors the disabled-global-excludes scan above; + # also a GLOBAL, machine-wide artifact). A higher-consequence orphan than a stale tmux conf + # — the vars keep applying to every shell, not just tmux sessions — so worth the extra scan + # even though tmux itself has no equivalent check. + env_cfg = loaded.data.get("env") + if isinstance(env_cfg, dict) and env_cfg.get("enabled") is False: + from .drift import check_disabled_env + from .plan import env_options_from_config + + env_disabled_options = env_options_from_config(env_cfg, loaded.repo_root) + check_disabled_env( + Action( + kind="provision_env", + category="env", + item="vars", + source=loaded.repo_root, + # matches `_build_env`'s own target (rc_path) — the scan derives everything + # from `options` regardless, but keeping the two constructions of this action + # kind identical avoids a needless divergence (review). + target=Path(env_disabled_options["rc_path"]), + options=env_disabled_options, + ), + report, + ) # AREA SUMMARY — the headline: every reconciled area (grouped by layer) with its in-sync vs # drift counts, so the user sees the FULL picture of what rig manages, not a skill-dominated # wall of drift lines. Printed in BOTH the in-sync and drift cases (the per-item drift dump diff --git a/riglib/config.py b/riglib/config.py index 621d0aa..36c9a08 100644 --- a/riglib/config.py +++ b/riglib/config.py @@ -40,6 +40,7 @@ SERENA_KEYS, SVERKLO_KEYS, ) +from .shell_env import ENV_VAR_KEY_PATTERN as _ENV_VAR_KEY_PATTERN CONFIG_FILENAME = "rig.yaml" @@ -62,6 +63,7 @@ "agents_md", "github", "tmux", + "env", "gitignore", "spotlight", "tools", @@ -599,6 +601,7 @@ def validate(data: dict[str, Any]) -> None: _validate_agents_md(data.get("agents_md", {})) _validate_github(data.get("github", {})) _validate_tmux(data.get("tmux", {})) + _validate_env(data.get("env", {})) _validate_gitignore(data.get("gitignore", {})) _validate_spotlight(data.get("spotlight", {})) _validate_tools(data.get("tools", {})) @@ -1860,6 +1863,90 @@ def _validate_project_tools(pt: dict[str, Any]) -> None: _check_bool(sverklo, key, f"project_tools.sverklo.{key}") +def _validate_env(e: dict[str, Any]) -> None: + """Validate the ``env`` block — rig-managed shell environment variables (GLOBAL, machine-wide). + + rig owns a GENERATED file (``/rig.env.sh``, one ``export KEY=value`` line per + ``vars`` entry) and ensures ONE ``source ''`` line is present in ``rc_path`` + (default ``~/.zshenv`` — sourced by EVERY zsh invocation, login or not, interactive or not). + A PRESENT block opts in (mirrors ``tmux``: even an empty ``env: {}`` is accepted, yielding a + harmless header-only generated file); only an ABSENT key or ``enabled: false`` is a no-op. + Fail-closed, consistent with every other block, on: a non-mapping block, an unknown key (typo + guard), a non-bool ``enabled``, a non-string ``rc_path``/``generated_dir``, a non-mapping + ``vars``, a non-string value inside ``vars``, and a ``vars`` key that is not a valid POSIX + shell identifier (``_ENV_VAR_KEY_PATTERN`` — required because the key is interpolated + UNQUOTED into ``rig.env.sh``, a file ``source``d by every zsh invocation on the machine; see + that constant's comment). + """ + if not isinstance(e, dict): + raise ConfigError("env must be a mapping", schema_path="env") + if not e: + return + _reject_unknown_keys(e, "env") + enabled = e.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + raise ConfigError(f"env.enabled must be a bool, got {enabled!r}", schema_path="env.enabled") + for pathkey in ("rc_path", "generated_dir"): + if pathkey not in e: + continue + pathval = e[pathkey] + if not isinstance(pathval, str): + raise ConfigError( + f"env.{pathkey} must be a string, got {pathval!r}", schema_path=f"env.{pathkey}" + ) + if not pathval: + # an empty string is technically a str (passes the isinstance check above) but + # resolves to `Path(".")` — for `generated_dir` that writes rig.env.sh into the + # resolved repo-root/CWD instead of the intended machine-wide location, and for + # `rc_path` it errors at apply. A machine-wide GLOBAL artifact should reject this + # footgun at validate time rather than let it reach `Path("")` (review finding). + raise ConfigError( + f"env.{pathkey} must not be empty", schema_path=f"env.{pathkey}" + ) + if "\n" in pathval or "\r" in pathval: + # same threat model as the single-quote hardening in `ShellEnvPlan.import_line` + # (repo-committed rig.yaml is treated as a possibly-adversarial source for these + # values) — `shlex.quote` keeps a newline literal (no shell-injection), but the + # splice/drift layer is LINE-oriented: `desired_rc_text`'s `current_present` check + # compares against a single physical line, so a multi-line `import_line()` can + # never match any one line — every apply appends a fresh copy, forever, and + # `is_rig_env_import_line`'s `shlex.split` on the (now unterminated-quote) first + # physical line raises internally and returns False, so the stale-drop pass can + # never recognize or remove the accumulating copies either (review finding: a + # correctness bug — non-idempotency — not an injection, but real). + raise ConfigError( + f"env.{pathkey} must not contain a newline", schema_path=f"env.{pathkey}" + ) + vars_block = e.get("vars") + if vars_block is not None: + if not isinstance(vars_block, dict): + raise ConfigError("env.vars must be a mapping", schema_path="env.vars") + for var_key, var_value in vars_block.items(): + if not isinstance(var_key, str) or not var_key: + raise ConfigError( + f"env.vars keys must be non-empty strings, got {var_key!r}", + schema_path="env.vars", + ) + if not _ENV_VAR_KEY_PATTERN.match(var_key): + raise ConfigError( + f"env.vars key {var_key!r} is not a valid shell identifier", + why=( + "the key is written UNQUOTED into `export {key}=...` in the generated " + "rig.env.sh, which is sourced by every zsh invocation on the machine — " + "an invalid key (a space, `;`, `=`, `$(...)`, a newline, …) would inject " + "arbitrary shell text into that file" + ), + fix="use only letters, digits, and underscores; the first character must " + "not be a digit (e.g. COLORTERM, MY_FLAG)", + schema_path=f"env.vars.{var_key}", + ) + if not isinstance(var_value, str): + raise ConfigError( + f"env.vars.{var_key} must be a string, got {var_value!r}", + schema_path=f"env.vars.{var_key}", + ) + + def _validate_gitignore(gi: dict[str, Any]) -> None: """Validate the ``gitignore`` block — rig's managed block in the GLOBAL git excludes file. @@ -2120,6 +2207,7 @@ def _validate_github_subblock( "login_shell", "autosave", "pane_titles", + "focus_events", } _TMUX_SUBKEYS = { "resurrect": {"processes", "capture_pane_contents"}, @@ -2131,6 +2219,7 @@ def _validate_github_subblock( "login_shell": {"enabled", "shell"}, "autosave": {"enabled", "label", "stale_after"}, "pane_titles": {"enabled", "position", "format", "clear_status_right"}, + "focus_events": {"enabled"}, } @@ -2213,7 +2302,16 @@ def _validate_tmux(t: dict[str, Any]) -> None: f"tmux.continuum.save_interval must be an int >= 1, got {interval!r}" ) - for sub in ("moshi", "cc_restore", "anti_sprawl", "boot", "login_shell", "autosave", "pane_titles"): + for sub in ( + "moshi", + "cc_restore", + "anti_sprawl", + "boot", + "login_shell", + "autosave", + "pane_titles", + "focus_events", + ): block = t.get(sub, {}) if isinstance(block, dict): value = block.get("enabled") diff --git a/riglib/config_schema.py b/riglib/config_schema.py index 0f9d585..04931c9 100644 --- a/riglib/config_schema.py +++ b/riglib/config_schema.py @@ -810,6 +810,31 @@ def to_node(self) -> dict[str, Any]: "clear_status_right": Leaf("boolean", "when `enabled` is on, also clear tmux's default clock+date status-right; a SEPARATE toggle (nested under `enabled`) so status-right can be left alone while keeping the border title", default=True), }, ), + "focus_events": Block( + doc="report terminal focus in/out events to programs running inside panes (tmux's `focus-events` option) — needed by editors/tools that react to focus.", + leaves={"enabled": Leaf("boolean", "tmux's focus-events option (set -g focus-events on/off)", default=True)}, + ), + }, +) + +_ENV_BLOCK = Block( + doc=( + "rig-managed shell environment variables (GLOBAL, machine-wide): a generated file " + "(`/rig.env.sh`, one `export KEY=value` per `vars` entry) sourced by ONE " + "spliced line in `rc_path`. Default `rc_path` is `~/.zshenv`, sourced by EVERY zsh " + "invocation (login or not, interactive or not) so a var reaches non-interactive shells too." + ), + leaves={ + # No `default=` here (deliberately, review): the effective default isn't a fixed + # boolean — it depends on BLOCK PRESENCE. An ABSENT `env:` block is a no-op; a + # PRESENT block with `enabled` unset opts in (true); only an explicit `enabled: false` + # opts out. That three-way behavior can't be represented as one scalar default without + # misleading whichever reading is wrong — see the prose above and in + # `docs/config-schema.md#env` for the actual rule. + "enabled": Leaf("boolean", "provision the rig-managed shell env vars — see the block's own doc for the opt-in rule (block PRESENCE, not this key's default, decides)"), + "rc_path": Leaf("string", "the shell startup file rig sources the generated env file from", default="~/.zshenv"), + "generated_dir": Leaf("string", "where rig writes rig.env.sh", default="~/.config/rig/env"), + "vars": Leaf("object", "exported KEY=value pairs", additional_properties_type="string"), }, ) @@ -991,6 +1016,7 @@ def to_node(self) -> dict[str, Any]: "agents_md": _AGENTS_MD_BLOCK, "github": _GITHUB_BLOCK, "tmux": _TMUX_BLOCK, + "env": _ENV_BLOCK, "gitignore": _GITIGNORE_BLOCK, "spotlight": _SPOTLIGHT_BLOCK, "tools": _TOOLS_BLOCK, diff --git a/riglib/drift.py b/riglib/drift.py index 31c6cb9..7a8590f 100644 --- a/riglib/drift.py +++ b/riglib/drift.py @@ -78,6 +78,7 @@ skill_harness_link_target, tg_ctl_plan_from_action, tmux_plan_from_action, + env_plan_from_action, ) from .config import GITIGNORE_BEGIN_MARKER, linter_path_escapes_repo from .github_ruleset import DEFAULT_RULESET_NAME @@ -198,6 +199,8 @@ def detect( pass # the agent-browser backend has no cheap read-back; status doesn't probe the UI elif action.kind == "provision_tmux": _check_tmux(action, report) + elif action.kind == "provision_env": + _check_env(action, report) elif action.kind == "provision_global_excludes": _check_global_excludes(action, report) elif action.kind == "provision_tools": @@ -1875,6 +1878,186 @@ def _check_spotlight(action: Action, report: DriftReport) -> None: ) +def _check_env(action: Action, report: DriftReport) -> None: + """Flag drift on the rig-managed shell-env-vars artifacts (never the user's other rc lines). + + missing — the generated ``rig.env.sh`` is absent, OR rig's ``source `` + import line is entirely absent from ``rc_path`` (whether never installed, or + only a STALE copy from an old ``generated_dir`` lingers there). + modified — the generated ``rig.env.sh`` on disk differs from the desired render (a hand + edit of rig's own file, or the configured ``vars`` changed), OR the current + import line IS present but a STALE copy also coexists with it and needs + dropping. + + The ``rc_path`` check reuses :func:`riglib.shell_env.desired_rc_text` — apply's EXACT, + POSITION-TOLERANT predicate — rather than a separate one. A user who relocated rig's own, + still byte-identical, import line (e.g. above their own exports) is genuinely IN SYNC: apply + leaves a relocated line alone too (see that function's docstring), so status must not flag + it either. Sharing one predicate makes divergence between apply and status impossible by + construction (review finding: an earlier end-anchoring version disagreed with a looser + drift check here — this one instead shares the exact rule apply itself follows). + + Both file reads (and the defense-in-depth key check inside ``render_env_file``) are guarded + LOCALLY: a non-UTF-8 or concurrently-removed file, or an invalid var key that somehow + bypassed ``config.validate``, is reported as a ``modified`` drift item rather than crashing + the whole `rig status` command (review finding — apply already had this hardening; status + did not). + """ + from .shell_env import desired_rc_text + + plan = env_plan_from_action(action) + render_failed = False + try: + desired_file: str | None = plan.render_env_file() + except ValueError as exc: + # nothing to COMPARE the generated file against, but the rc_path check below (step 2) + # doesn't depend on this render at all (only on `plan.import_line()`) — so it still + # runs; only the generated-file comparison itself is skipped (review: an earlier + # version `return`ed here, silently skipping rc_path too). + report.items.append( + DriftItem("modified", "env", action.item, plan.generated_file_path, + f"configured vars cannot be rendered: {exc}") + ) + desired_file = None + render_failed = True + + # 1) the generated rig.env.sh. Existence is checked regardless of whether the render + # succeeded (a render failure means "can't compare CONTENT", not "can't tell if it exists") + # — UNLESS the render already failed, in which case that single item is enough; reporting + # BOTH "cannot be rendered" AND "not installed" for the SAME target double-counts one + # artifact in the drift totals (review finding). + if render_failed: + pass + elif not plan.generated_file_path.is_file(): + report.items.append( + DriftItem("missing", "env", action.item, plan.generated_file_path, + "generated rig.env.sh not installed") + ) + elif desired_file is not None: + try: + on_disk = plan.generated_file_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + report.items.append( + DriftItem("modified", "env", action.item, plan.generated_file_path, + f"could not read: {exc}") + ) + on_disk = None + if on_disk is not None and on_disk != desired_file: + report.items.append( + DriftItem("modified", "env", action.item, plan.generated_file_path, + "generated rig.env.sh differs from configured vars") + ) + + # 2) rc_path — the SAME predicate `_do_provision_env` uses to decide whether to write. + # Position-tolerance means a differing text has TWO distinct causes, reported distinctly + # (review finding: an earlier version always said "missing" even when the line was actually + # present alongside a stale copy — the opposite of the truth): + # missing — the current import line is absent everywhere (never installed, or only a + # stale copy from an old generated_dir exists). + # modified — the current line IS present, but a STALE copy (old generated_dir) coexists + # with it and still needs dropping. + # KNOWN LIMITATION (accepted, review round 9): "present" here is an EXACT string match + # against `plan.import_line()`, same as `desired_rc_text`'s own `current_present` check. + # A line that is functionally equivalent but not byte-identical — hand-quoted differently + # (`source "..."` vs the canonical bare/`'...'` form `shlex.quote` produces), or written + # with `$HOME` instead of the literal path — reads as "missing" here even though the file + # IS being sourced. Only reachable by hand-editing an already-correct line (rig's own + # output is deterministic and always canonical); apply self-heals it to the canonical form + # on the next run, so this is a one-time inaccurate STATUS MESSAGE, never a functional bug + # or a perpetual-drift loop. Narrowing this to semantic (full-path, not just the basename + # `is_rig_env_import_line` matches) equivalence was judged not worth the added complexity + # for a hand-edit-only, self-healing edge case. + if not plan.rc_path.is_file(): + rc_text: str | None = "" + else: + try: + rc_text = plan.rc_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + report.items.append( + DriftItem("modified", "env", action.item, plan.rc_path, f"could not read: {exc}") + ) + rc_text = None + if rc_text is not None and desired_rc_text(rc_text, plan) != rc_text: + import_line = plan.import_line() + if any(ln.strip() == import_line for ln in rc_text.splitlines()): + report.items.append( + DriftItem("modified", "env", action.item, plan.rc_path, + f"the current import line is present in {plan.rc_path}, but a STALE " + "copy (an old generated_dir) also lingers there and needs dropping") + ) + else: + report.items.append( + DriftItem("missing", "env", action.item, plan.rc_path, + f"source import line missing from {plan.rc_path}") + ) + + +def check_disabled_env(action: Action, report: DriftReport) -> None: + """Flag a still-installed ``rig.env.sh`` and/or a still-live ``rc_path`` import line when + the config disables the ``env`` category (only fires for an EXPLICIT ``enabled: false`` — + see the note at the end). + + apply never deletes; so a machine that previously provisioned shell env vars keeps some or + all of ``rig.env.sh`` / the live ``source`` line in ``rc_path`` even after the config turns + the category off. With the action gone from the plan, ``_check_env`` never runs — so without + this scan the leftover would report as "in sync" (mirrors + :func:`check_disabled_global_excludes`). A HIGHER-consequence orphan than a stale tmux conf + (env vars keep silently applying to every shell invocation on the machine, not just tmux + sessions), so — unlike tmux, which has no equivalent check — this one is worth the extra + scan (review, raised across three rounds). + + Checks BOTH halves of the pair independently — a review finding on an earlier version that + checked only the generated file: the INVERSE orphan (the generated file was deleted by hand + but a stale ``source`` line survives in ``rc_path``) is arguably worse, since every zsh + invocation on the machine then prints a "no such file or directory" error at startup, and + was previously invisible to this scan. + + Known gap (documented, not fixed here — see ``docs/config-schema.md#env``): this fires ONLY + for an explicit ``env: {enabled: false}``. Removing the ``env:`` block ENTIRELY — arguably + the more natural way to "turn a feature off" — runs no scan at all here (``cmd_status``'s + caller only triggers this on ``enabled is False``), so the same still-active artifacts would + report as in sync in that case. Probing a fixed default path with no config presence at all + would need a broader design change (no other disabled-check in this codebase does that + either); left as a known limitation rather than special-cased here. + """ + from .shell_env import is_rig_env_import_line + + plan = env_plan_from_action(action) + generated_exists = plan.generated_file_path.is_file() + if generated_exists: + report.items.append( + DriftItem("extra", "env", action.item, plan.generated_file_path, + f"env disabled in config but rig.env.sh is still present and (if " + f"{plan.rc_path} still sources it) its vars keep applying to every " + f"shell — remove {plan.generated_file_path} and its source line to " + "fully turn it off") + ) + + if not plan.rc_path.is_file(): + return + try: + rc_text = plan.rc_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + # a non-UTF-8/permission-denied/concurrently-removed rc_path must not crash `rig + # status` (review finding — the same hardening this diff already applies to the three + # OTHER rc_path reads in `_do_provision_env`/`_check_env`; this fourth one was missed). + # The generated-file check above already ran and reported independently; silently + # skipping the rc_path half here is the same "can't inspect it, don't crash" choice + # `_check_env` makes (there it reports a `modified` item instead — this scan has none + # of that item's target-uniqueness bookkeeping to hang a matching item off, so it just + # skips, consistent with returning early on a missing file just above). + return + generated_name = plan.generated_file_path.name + if any(is_rig_env_import_line(ln, plan.import_line(), generated_name) for ln in rc_text.splitlines()): + detail = ( + f"env disabled in config but {plan.rc_path} still sources rig.env.sh" + + ("" if generated_exists else f" — {plan.generated_file_path} is GONE, so every " + "shell invocation now errors at startup ('no such file or directory')") + + f" — remove the source line from {plan.rc_path} to fully turn it off" + ) + report.items.append(DriftItem("extra", "env", action.item, plan.rc_path, detail)) + + def _check_tmux(action: Action, report: DriftReport) -> None: """Flag drift on the rig-MANAGED tmux region only (never the user's hand-written lines). diff --git a/riglib/layers.py b/riglib/layers.py index 3b4dd16..2a419ee 100644 --- a/riglib/layers.py +++ b/riglib/layers.py @@ -39,6 +39,7 @@ "gitignore": GLOBAL, "spotlight": GLOBAL, "tmux": GLOBAL, + "env": GLOBAL, "tg_ctl": GLOBAL, "tools": GLOBAL, # the machine-level agent-tools/env file (AGENT_TOOLS_ROOT). The CHECK runs from the diff --git a/riglib/plan.py b/riglib/plan.py index 28191b6..63fb6f8 100644 --- a/riglib/plan.py +++ b/riglib/plan.py @@ -860,6 +860,9 @@ def build(config: LoadedConfig, catalog: Catalog, *, project_type: str = "unknow # ── tmux (rig-managed tmux configuration) ────────────────────────────────────── _build_tmux(config, plan) + # ── env (rig-managed shell environment variables) ────────────────────────────── + _build_env(config, plan) + # ── gitignore (rig-managed block in the GLOBAL git excludes file) ────────────── _build_global_excludes(config, plan) @@ -2175,11 +2178,65 @@ def _build_tmux(config: LoadedConfig, plan: InstallPlan) -> None: "login_shell": login_shell, "autosave": autosave, "pane_titles": dict(t.get("pane_titles", {}) or {}), + "focus_events": dict(t.get("focus_events", {}) or {}), }, ) ) +def env_options_from_config(e: dict[str, Any], repo_root: Path) -> dict[str, Any]: + """Resolve an ``env:`` config block into the exact ``options`` dict a ``provision_env`` + ``Action`` carries (``rc_path``/``generated_dir`` expanded + baked absolute, ``vars`` + stringified). The ONE place this resolution happens — ``_build_env`` below AND the + disabled-env status scan (``cli.py``, which needs the same resolved paths to know WHERE to + look for a leftover ``rig.env.sh`` when the config has since turned the block off) both call + this instead of each re-deriving the same defaults/``_expand`` calls independently, which + would risk silently diverging if this resolution ever changes (review finding). + """ + from .shell_env import DEFAULT_GENERATED_DIR, DEFAULT_RC_PATH + + rc_path = _expand(str(e.get("rc_path", DEFAULT_RC_PATH)), repo_root) + generated_dir = _expand(str(e.get("generated_dir", DEFAULT_GENERATED_DIR)), repo_root) + vars_block = dict(e.get("vars", {}) or {}) + return { + "rc_path": str(rc_path), + "generated_dir": str(generated_dir), + "vars": {str(k): str(v) for k, v in vars_block.items()}, + } + + +def _build_env(config: LoadedConfig, plan: InstallPlan) -> None: + """Plan the rig-managed shell-environment-variables provisioning, if an ``env`` block enables it. + + Mirrors ``_build_tmux``'s opt-in shape exactly: a PRESENT ``env:`` block opts in (including an + explicit empty mapping ``env: {}``, which yields a harmless header-only generated file); only an + ABSENT key or ``enabled: false`` is a no-op. This is a per-MACHINE concern (the shell startup + file rig sources into is HOME-anchored, not repo-relative), so the block lives in the GLOBAL + layer (``~/.config/rig/config.yaml``) — but it cascades into the merged config the same way. + + ``rc_path``/``generated_dir`` are resolved by :func:`env_options_from_config` against the + repo root (mirrors every other action's target resolution — never re-resolved against CWD by + the runner) so a relative path stays anchored to the ``-C`` repo and ``~/`` stays home-anchored. + """ + e = config.data.get("env") + if e is None or not isinstance(e, dict): + return + if e.get("enabled") is False: + return + + options = env_options_from_config(e, config.repo_root) + plan.actions.append( + Action( + kind="provision_env", + category="env", + item="vars", + source=config.repo_root, # no carrier; rig generates the file itself + target=Path(options["rc_path"]), + options=options, + ) + ) + + def _build_spotlight(config: LoadedConfig, plan: InstallPlan) -> None: """Plan the macOS Spotlight-exclude provisioning, if a ``spotlight`` block enables it. diff --git a/riglib/shell_env.py b/riglib/shell_env.py new file mode 100644 index 0000000..8073caf --- /dev/null +++ b/riglib/shell_env.py @@ -0,0 +1,248 @@ +"""Shell environment variables — rig-managed exported vars, GLOBAL (machine-wide). + +What this is +------------ +Some vars (``COLORTERM``, a proxy, a tool flag) need to be visible to EVERY shell +invocation on the machine — not just an interactive login shell, but also a +mosh/SSH non-interactive command shell (``ssh host 'some-command'``), a cron/launchd +job, or ``zsh -l -c '...'``. zsh's own startup order makes ``~/.zshenv`` the only file +sourced unconditionally in every mode (login or not, interactive or not) — ``~/.zshrc`` +is interactive-only and ``~/.zprofile``/``~/.zlogin`` are login-only — so that is the +default ``rc_path`` here. A hand-written ``~/.zshenv`` commonly carries a comment to +this effect for Homebrew's own PATH setup: "mosh/SSH non-interactive command shells +only source .zshenv". + +Two artifacts, mirroring ``riglib.tmux``'s "own a generated file, splice one import +line" shape (the SAME idiom, deliberately — see ``docs/config-schema.md#env``): + +- ``/rig.env.sh`` — the rig-owned file (wholesale rewrite each apply), + one ``export KEY=value`` line per ``vars`` entry, sorted by key for a stable diff. +- ``rc_path`` — carries exactly ONE ``source ''`` line, appended if + absent; every other line already in the file (a user's own Homebrew/cargo/bun setup, + say) is left untouched. Unlike tmux, there is no dual import/block mode and no + neutralization of unrelated inline content — a single exported var has no equivalent + of tmux's plugin/continuum ``@`` declarations that need detecting and superseding. + +All rendering here is stdlib-only + effect-free; the effectful write lives in +``actions/runner.py`` (``_do_provision_env``), and ``drift.py`` (``_check_env``) diffs +the desired artifacts against disk — the same three-consumer split as ``riglib.tmux``. +""" + +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass, field +from pathlib import Path + +DEFAULT_RC_PATH = "~/.zshenv" +DEFAULT_GENERATED_DIR = "~/.config/rig/env" +GENERATED_FILE_NAME = "rig.env.sh" + +# A POSIX shell identifier. The ONE canonical definition — ``config.py`` imports THIS constant +# (rather than keeping its own byte-identical copy, review finding: two copies risk drifting +# apart) for its `config.validate`-time rejection; `render_env_file` below re-asserts it again +# on every key at RENDER time — the defense-in-depth re-check that matters is WHERE the check +# runs (this module is the one that actually writes the unquoted key into executable shell +# text, so its own safety must not depend on every caller having gone through +# `config.validate` first), not where the regex literal lives. +ENV_VAR_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +_HEADER = ( + "# rig-managed shell environment — GENERATED, do not hand-edit; `rig apply` rewrites\n" + "# this file wholesale. Edit the `env:` block in ~/.config/rig/config.yaml instead (this\n" + "# is GLOBAL, machine-wide config — not a per-repo rig.yaml), then re-apply. (rig owns\n" + "# this file; your shell rc sources it.)\n" +) + + +@dataclass(frozen=True) +class ShellEnvPlan: + """The desired shell-env-managed state, fully resolved. Pure data, no I/O. + + Unlike ``riglib.tmux.TmuxPlan`` (which keeps ``home`` because several of its artifact + paths — resurrect/plugins dirs — are HOME-anchored independently of ``rc_path``/ + ``generated_dir``), this plan has no artifact that needs HOME after ``rc_path`` and + ``generated_dir`` are already resolved, so there is no ``home`` field to keep in sync. + """ + + rc_path: Path + generated_dir: Path + vars: dict[str, str] = field(default_factory=dict) + + @property + def generated_file_path(self) -> Path: + return self.generated_dir / GENERATED_FILE_NAME + + def render_env_file(self) -> str: + """The rig-owned generated file body — one ``export KEY=value`` per var. + + Keys are sorted for a deterministic, diff-friendly render (``rig apply``/``rig + status`` must agree byte-for-byte regardless of dict insertion order). Values are + shell-quoted (``shlex.quote``) so a value containing spaces/quotes/``$`` is safe — + the generated file is ``source``d as a real shell script, not just read as text. + + Keys are interpolated UNQUOTED (a shell identifier needs no quoting) — ``key`` is + therefore checked against ``ENV_VAR_KEY_PATTERN`` FIRST. ``config.validate`` already + rejects an invalid key before a plan is ever built, but this is the module that + actually emits executable shell text, so it re-asserts its own invariant rather than + trusting every possible caller (defense in depth, per review) — raises ``ValueError`` + rather than silently emitting a key that could inject arbitrary shell. + """ + lines = [_HEADER.rstrip("\n"), ""] + for key in sorted(self.vars): + if not ENV_VAR_KEY_PATTERN.match(key): + raise ValueError( + f"env var key {key!r} is not a valid shell identifier " + "(refusing to interpolate it unquoted into rig.env.sh)" + ) + lines.append(f"export {key}={shlex.quote(self.vars[key])}") + lines.append("") # trailing newline + return "\n".join(lines) + + def import_line(self) -> str: + """The single ``source `` line ``rc_path`` carries. + + ``shlex.quote``d — NOT hand-placed single quotes. ``generated_dir`` (and HOME) may + contain a space, and for an ordinary path ``shlex.quote`` renders it bare (no quoting + needed); for anything containing a space it wraps in single quotes; for a path that + itself contains a single quote (an edge case, but ``generated_dir`` is configurable — + including, per review, from a repo's own committed ``rig.yaml``) hand-placed quotes + would let it break OUT of the quoting and inject arbitrary shell text into the most + privileged startup file on the machine. ``shlex.quote`` handles every case correctly; + hand-placed quoting does not (review finding — this diverges from ``riglib.tmux``'s + ``TmuxPlan.import_line``, which still hand-quotes and shares the same latent hole, + out of scope to fix here). + """ + return f"source {shlex.quote(str(self.generated_file_path))}" + + +def build_shell_env( + *, + repo_home: Path, + rc_path: str | Path = DEFAULT_RC_PATH, + generated_dir: str | Path = DEFAULT_GENERATED_DIR, + vars: dict | None = None, +) -> ShellEnvPlan: + """Resolve the desired :class:`ShellEnvPlan` from the (already-validated) ``env`` config block. + + ``repo_home`` is the resolved HOME (the caller passes ``Path.home()`` or a test tmp HOME). + HOME-relative ``rc_path``/``generated_dir`` are expanded against it — a bare ``~``/``~/...`` + expansion ONLY, deliberately NOT ``$XDG_CONFIG_HOME``-aware like plan.py's own + ``expand_user_path`` (an earlier version tried to match that special case here too, so a + caller falling back to bare defaults would resolve identically to the plan builder — but + that made ``repo_home`` no longer fully control resolution, since the AMBIENT + ``XDG_CONFIG_HOME`` env var would silently override it even in a pure unit test explicitly + passing an unrelated ``repo_home``; reverted). The plan builder (``_build_env`` in + ``riglib/plan.py``) is the ONE place that resolves ``~/.config`` -> ``$XDG_CONFIG_HOME`` and + bakes the ABSOLUTE result into ``Action.options`` — see ``env_plan_from_action`` in + ``riglib/actions/runner.py``, which REQUIRES those options rather than re-deriving a default + here, so there is no second resolver to disagree with the first (review finding). + + An empty/absent ``vars`` mapping yields a plan that renders a header-only generated file and + a harmless import line — never an error (mirrors ``tmux.build_tmux``'s "empty block -> safe + defaults"). + """ + vars = vars or {} + + def _expand(p: str | Path) -> Path: + s = str(p) + if s == "~": + return repo_home + if s.startswith("~/"): + return repo_home / s[2:] + return Path(s) + + return ShellEnvPlan( + rc_path=_expand(rc_path), + generated_dir=_expand(generated_dir), + vars={str(k): str(v) for k, v in vars.items()}, + ) + + +def is_rig_env_import_line(line: str, import_line: str, generated_name: str) -> bool: + """True if ``line`` is rig's OWN ``source `` import (current, or a stale one + pointing at an old ``generated_dir``) — so it can be recognized/dropped. A comment or an + unrelated line that merely mentions the path is NOT matched (the line must actually BE a + ``source``/``.`` directive naming the generated file). + + Parsed with ``shlex.split(..., comments=True)`` — real shell tokenizing, not a naive + ``.split()`` + quote-strip — so a QUOTED path (containing a space, or even a quote + character; see :meth:`ShellEnvPlan.import_line`) is parsed correctly, and a trailing + comment (``source '.../rig.env.sh' # rig``) does not fool the argument extraction into + treating the comment text as part of the path (review finding: the naive parser saw the + comment as part of the arg, so `Path(arg).name` was never the bare generated filename and + a stale line with a trailing comment silently survived every cleanup pass forever). + + KNOWN LIMITATION (accepted, matches rig's own model — mirrors the identical basename/suffix + tradeoff `riglib.tmux` already accepts for its own plugin-init matching): the match is on + ``generated_name`` (the bare filename, ``rig.env.sh``) ALONE, not the full ``generated_dir`` + path. A user's own, unrelated ``source ~/mystuff/rig.env.sh`` — a coincidentally same-named + file rig never wrote — would be (mis)classified as a stale rig import and dropped. Given the + filename is rig-specific and the collision requires a user to independently choose that + exact name, this is treated as an acceptable, documented tradeoff rather than a bug to chase + (review, round 8) — narrowing the match to `generated_dir`-relative paths would also then + fail to recognize genuinely stale lines from an OLD `generated_dir` (the case this function + exists to catch in the first place), which is the more common and more consequential miss. + """ + s = line.strip() + if s == import_line: + return True + if not s or s.startswith("#"): + return False + try: + parts = shlex.split(s, comments=True) + except ValueError: + return False # unbalanced quote or similar malformed shell syntax — not our line + if len(parts) == 2 and parts[0] in ("source", "."): + return Path(parts[1]).name == generated_name + return False + + +def desired_rc_text(existing: str, plan: ShellEnvPlan) -> str: + """The desired ``rc_path`` text — POSITION-TOLERANT, and byte-preserving of every line + rig does not own. + + If the CURRENT import line already appears anywhere in ``existing`` (not necessarily at + the end — a user may have moved it above their own exports on purpose, e.g. so their own + values win), that line's POSITION is left exactly where the user put it. Unlike tmux's + import-line splice — which always re-appends at the very end, because tmux's ordering + guarantee genuinely depends on position (continuum's ``run-shell`` must be LAST) — a plain + exported var has no such ordering hazard, so rig has no reason to fight the user's + placement (review: an earlier end-anchoring version silently moved a user-relocated line + back on every apply, forever, with no way to opt out). + + A STALE copy (an old ``generated_dir``) is ALWAYS dropped, even when the current line is + ALSO already present elsewhere — position-tolerance for the current line must not become an + excuse to leave an orphaned old ``rig.env.sh`` silently sourced forever (review finding: an + earlier version returned ``existing`` verbatim the moment ANY current-line match was found, + before the stale-drop ran, so a stale-plus-current combination never got cleaned up). + + When the current line is absent everywhere (first apply, or only a stale copy existed), + the current import is appended once at the end — and every OTHER line is preserved with its + ORIGINAL bytes: ``splitlines(keepends=True)`` keeps each line's own terminator (so a CRLF + file stays CRLF, and a trailing blank line survives), rather than a lossy + split-on-``\\n``-then-rejoin-with-``\\n`` round-trip that would silently convert line + endings and drop a trailing blank line (review finding). + + Pure + idempotent: calling this again on its own output is a no-op. + """ + import_line = plan.import_line() + generated_name = plan.generated_file_path.name + lines = existing.splitlines(keepends=True) if existing else [] + current_present = any(ln.strip() == import_line for ln in lines) + + if current_present: + # keep the current line's position; drop only a coexisting STALE copy, if any. + kept = [ + ln for ln in lines + if ln.strip() == import_line + or not is_rig_env_import_line(ln, import_line, generated_name) + ] + return "".join(kept) + + kept = [ln for ln in lines if not is_rig_env_import_line(ln, import_line, generated_name)] + body = "".join(kept) + if body and not body.endswith(("\n", "\r")): + body += "\n" + return body + import_line + "\n" diff --git a/riglib/tmux.py b/riglib/tmux.py index 4fdce5e..288e3da 100644 --- a/riglib/tmux.py +++ b/riglib/tmux.py @@ -264,6 +264,10 @@ class TmuxPlan: autosave_enabled: bool autosave_label: str autosave_stale_after: int # minutes; the freshness threshold rig doctor/status checks. + # tmux's `focus-events` option — reports terminal focus in/out to programs running inside + # panes (needed by editors/tools that react to focus). A plain terminal-capability toggle, + # independent of the resurrect/continuum/tpm ordering surface. Default-on (Alex, 2026-08-05). + focus_events_enabled: bool # ── resolved artifact paths ────────────────────────────────────────────────────── @property @@ -374,6 +378,9 @@ def render_rig_conf(self) -> str: """The rig-owned ``rig.tmux.conf`` — generated with GUARANTEED ordering. Section order is load-bearing: + 0. basic terminal options (``focus-events``) — a plain capability toggle, independent + of the resurrect/continuum/tpm surface below, so it carries no ordering constraint; + placed first purely for readability 1. plugin DECLARATIONS (tpm + resurrect + continuum) 2. resurrect/continuum OPTIONS (processes incl. claude, capture-pane, restore, save-interval, boot) @@ -398,6 +405,12 @@ def render_rig_conf(self) -> str: "# `rig apply` rewrites this file wholesale. Edit the `tmux:` block in rig.yaml", "# instead, then re-apply. (rig owns this file; your ~/.tmux.conf sources it.)", "", + "# ── basic terminal options ───────────────────────────────────────────────────", + # Explicit on/off (never just-omit-when-false) — same rationale as every other + # modeled boolean below: the generated tail must OVERRIDE a preserved inline value + # from a migrated conf, not merely fail to re-assert it. + f"set -g focus-events {'on' if self.focus_events_enabled else 'off'}", + "", "# ── plugins (tpm + resurrect + continuum) ─────────────────────────────────", "set -g @plugin 'tmux-plugins/tpm'", "set -g @plugin 'tmux-plugins/tmux-resurrect'", @@ -1130,6 +1143,7 @@ def build_tmux( login_shell: dict | None = None, autosave: dict | None = None, pane_titles: dict | None = None, + focus_events: dict | None = None, ) -> TmuxPlan: """Resolve the desired :class:`TmuxPlan` from the (already-validated) tmux config block. @@ -1137,7 +1151,7 @@ def build_tmux( HOME-relative ``conf_path`` / ``generated_dir`` are expanded against it. Every nested knob defaults to the safe, root-cause-fixing value; an empty block yields the full default config (claude in resurrect, capture-pane on, continuum restore+boot, Moshi off, cc-restore - on, anti-sprawl on, boot on, login-shell default-command on). + on, anti-sprawl on, boot on, login-shell default-command on, focus-events on). """ resurrect = resurrect or {} continuum = continuum or {} @@ -1146,6 +1160,7 @@ def build_tmux( anti_sprawl = anti_sprawl or {} boot = boot or {} login_shell = login_shell or {} + focus_events = focus_events or {} autosave = autosave or {} pane_titles = pane_titles or {} @@ -1199,6 +1214,7 @@ def _knob(block: dict, key: str, default): pane_titles_position=_resolve_pane_titles_position(pane_titles), pane_titles_format=_resolve_pane_titles_format(pane_titles), pane_titles_clear_status_right=bool(_knob(pane_titles, "clear_status_right", True)), + focus_events_enabled=bool(_knob(focus_events, "enabled", True)), ) diff --git a/schema/rig.schema.json b/schema/rig.schema.json index b07750d..28a8273 100644 --- a/schema/rig.schema.json +++ b/schema/rig.schema.json @@ -1260,6 +1260,46 @@ } }, "additionalProperties": false + }, + "focus_events": { + "type": "object", + "description": "report terminal focus in/out events to programs running inside panes (tmux's `focus-events` option) — needed by editors/tools that react to focus.", + "properties": { + "enabled": { + "type": "boolean", + "description": "tmux's focus-events option (set -g focus-events on/off)", + "default": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "env": { + "type": "object", + "description": "rig-managed shell environment variables (GLOBAL, machine-wide): a generated file (`/rig.env.sh`, one `export KEY=value` per `vars` entry) sourced by ONE spliced line in `rc_path`. Default `rc_path` is `~/.zshenv`, sourced by EVERY zsh invocation (login or not, interactive or not) so a var reaches non-interactive shells too.", + "properties": { + "enabled": { + "type": "boolean", + "description": "provision the rig-managed shell env vars — see the block's own doc for the opt-in rule (block PRESENCE, not this key's default, decides)" + }, + "rc_path": { + "type": "string", + "description": "the shell startup file rig sources the generated env file from", + "default": "~/.zshenv" + }, + "generated_dir": { + "type": "string", + "description": "where rig writes rig.env.sh", + "default": "~/.config/rig/env" + }, + "vars": { + "type": "object", + "description": "exported KEY=value pairs", + "additionalProperties": { + "type": "string" + } } }, "additionalProperties": false diff --git a/tests/test_shell_env.py b/tests/test_shell_env.py new file mode 100644 index 0000000..4396472 --- /dev/null +++ b/tests/test_shell_env.py @@ -0,0 +1,1021 @@ +"""Tests for the ``env`` block — rig-managed shell environment variables. + +Mirrors ``tests/test_tmux.py``'s structure at a scale matching the feature's own size (one +generated file + one spliced import line, no dual apply-mode, no inline-content neutralization): +config validation, pure rendering (``riglib.shell_env``), plan building, and the install/drift +round trip through ``riglib.actions.runner``/``riglib.drift``. +""" + +from __future__ import annotations + +import shlex +from pathlib import Path + +import pytest + +from riglib import shell_env +from riglib.config import ConfigError, validate + + +# ── config validation ─────────────────────────────────────────────────────────────────── +def test_env_block_accepted(): + validate({"version": 1, "env": {"enabled": True}}) + + +def test_env_block_empty_ok(): + validate({"version": 1, "env": {}}) + + +def test_env_full_block_accepted(): + validate( + { + "version": 1, + "env": { + "enabled": True, + "rc_path": "~/.zshenv", + "generated_dir": "~/.config/rig/env", + "vars": {"COLORTERM": "truecolor", "MY_FLAG": "1"}, + }, + } + ) + + +def test_env_unknown_key_rejected(): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"nope": 1}}) + + +def test_env_enabled_must_be_bool(): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"enabled": "yes"}}) + + +@pytest.mark.parametrize("pathkey", ["rc_path", "generated_dir"]) +def test_env_path_keys_must_be_string(pathkey): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {pathkey: 123}}) + + +@pytest.mark.parametrize("pathkey", ["rc_path", "generated_dir"]) +def test_env_path_keys_must_not_be_empty(pathkey): + """An empty string passes the `isinstance(..., str)` check but resolves to `Path(".")` — + for `generated_dir` that silently writes rig.env.sh into the resolved repo-root/CWD + instead of the intended machine-wide location; for `rc_path` it errors at apply. A + machine-wide GLOBAL artifact should reject this footgun at validate time (review finding).""" + with pytest.raises(ConfigError): + validate({"version": 1, "env": {pathkey: ""}}) + + +@pytest.mark.parametrize("pathkey", ["rc_path", "generated_dir"]) +@pytest.mark.parametrize("bad", ["~/foo\nbar", "~/foo\rbar"]) +def test_env_path_keys_must_not_contain_a_newline(pathkey, bad): + """A newline breaks the LINE-oriented splice/drift layer's idempotency (not an injection — + `shlex.quote` keeps it literal — but every `rig apply` would append a fresh, never-matched + copy forever, since `desired_rc_text`'s `current_present` check compares against a single + physical line). Same adversarial-config threat model as the single-quote hardening in + `ShellEnvPlan.import_line` (review finding).""" + with pytest.raises(ConfigError): + validate({"version": 1, "env": {pathkey: bad}}) + + +def test_env_vars_must_be_a_mapping(): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"vars": ["COLORTERM=truecolor"]}}) + + +def test_env_vars_keys_must_be_strings(): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"vars": {1: "truecolor"}}}) + + +def test_env_vars_values_must_be_strings(): + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"vars": {"COLORTERM": 1}}}) + + +@pytest.mark.parametrize( + "bad_key", + [ + "MY VAR", # space + "MY;VAR", # command separator + "MY=VAR", # would terminate the export's own assignment early + "$(rm -rf ~)", # command substitution + "1VAR", # leading digit — not a valid shell identifier + "MY-VAR", # hyphen is not a valid identifier character + "", # empty (also covered by the non-empty-string check, belt and braces) + ], +) +def test_env_vars_key_must_be_a_valid_shell_identifier(bad_key): + """A `vars` key is interpolated UNQUOTED into `export {key}=...` in rig.env.sh, which is + sourced by every zsh invocation on the machine — an invalid key must be rejected at + validate time rather than reaching the generated, machine-wide-executed file (review + finding: shell injection through an unvalidated var key).""" + with pytest.raises(ConfigError): + validate({"version": 1, "env": {"vars": {bad_key: "x"}}}) + + +@pytest.mark.parametrize("good_key", ["COLORTERM", "MY_FLAG", "_LEADING_UNDERSCORE", "A1B2"]) +def test_env_vars_key_valid_shell_identifiers_accepted(good_key): + validate({"version": 1, "env": {"vars": {good_key: "x"}}}) + + +# ── pure rendering (riglib.shell_env) ─────────────────────────────────────────────────── +def _plan(**over): + """A ShellEnvPlan with sensible defaults, overridable per-test.""" + return shell_env.build_shell_env(repo_home=Path("/home/u"), **over) + + +def test_defaults_are_zshenv_and_rig_config_dir(): + plan = _plan() + assert plan.rc_path == Path("/home/u/.zshenv") + assert plan.generated_dir == Path("/home/u/.config/rig/env") + assert plan.generated_file_path == Path("/home/u/.config/rig/env/rig.env.sh") + + +def test_render_env_file_empty_vars_is_header_only(): + body = _plan(vars={}).render_env_file() + assert "export" not in body + assert "rig-managed shell environment" in body + + +def test_render_env_file_one_var(): + body = _plan(vars={"COLORTERM": "truecolor"}).render_env_file() + assert "export COLORTERM=truecolor" in body + + +def test_render_env_file_sorted_by_key_deterministic(): + body = _plan(vars={"ZVAR": "1", "AVAR": "2"}).render_env_file() + assert body.index("export AVAR=2") < body.index("export ZVAR=1") + + +def test_render_env_file_shell_quotes_values_with_special_chars(): + body = _plan(vars={"MSG": "hello world"}).render_env_file() + assert "export MSG='hello world'" in body + + +def test_render_is_deterministic(): + a = _plan(vars={"COLORTERM": "truecolor"}).render_env_file() + b = _plan(vars={"COLORTERM": "truecolor"}).render_env_file() + assert a == b + + +def test_import_line_shlex_quoted(): + """`shlex.quote` leaves an ordinary path (no shell metacharacters) BARE — no quotes are + needed for it to be safe, unlike hand-placed single quotes which would always wrap it.""" + line = _plan().import_line() + assert line == "source /home/u/.config/rig/env/rig.env.sh" + + +def test_import_line_quotes_a_path_containing_a_space(): + plan = shell_env.build_shell_env(repo_home=Path("/home/u"), generated_dir="/tmp/my dir") + line = plan.import_line() + assert line == "source '/tmp/my dir/rig.env.sh'" + + +def test_import_line_safely_quotes_a_path_containing_a_single_quote(): + """A `generated_dir` containing a single quote — a genuinely adversarial value, and per + the review's own repo-level-config test this is reachable from a repo's committed + rig.yaml, not just the operator's global config — must not let a hand-placed quote be + broken out of. `shlex.quote` (not string-formatted single quotes) makes this safe: the + quote character is escaped, never terminates the shell string early (review finding).""" + plan = shell_env.build_shell_env(repo_home=Path("/home/u"), generated_dir="/tmp/x'; rm -rf ~; '") + line = plan.import_line() + # the ENTIRE malicious path is a single shlex-safe token — never a bare `;` outside quotes. + assert shlex.split(line) == ["source", "/tmp/x'; rm -rf ~; '/rig.env.sh"] + assert "; rm -rf ~;" not in line.split("'", 1)[0] # nothing escapes before the first quote + + +def test_home_relative_paths_expand_against_repo_home(): + plan = shell_env.build_shell_env( + repo_home=Path("/home/u"), rc_path="~/.zshenv", generated_dir="~/.config/rig/env" + ) + assert plan.rc_path == Path("/home/u/.zshenv") + + +def test_absolute_override_paths_are_used_verbatim(): + plan = shell_env.build_shell_env( + repo_home=Path("/home/u"), rc_path="/etc/zshenv", generated_dir="/opt/rig-env" + ) + assert plan.rc_path == Path("/etc/zshenv") + assert plan.generated_dir == Path("/opt/rig-env") + + +# ── desired_rc_text (the idempotent import-line splice) ──────────────────────────────── +def test_desired_rc_text_appends_when_absent(): + plan = _plan() + out = shell_env.desired_rc_text("eval brew shellenv\n", plan) + assert out == "eval brew shellenv\n" + plan.import_line() + "\n" + + +def test_desired_rc_text_empty_file(): + plan = _plan() + assert shell_env.desired_rc_text("", plan) == plan.import_line() + "\n" + + +def test_desired_rc_text_idempotent(): + plan = _plan() + once = shell_env.desired_rc_text("eval brew shellenv\n", plan) + twice = shell_env.desired_rc_text(once, plan) + assert once == twice + + +def test_desired_rc_text_preserves_unrelated_lines_verbatim(): + plan = _plan() + existing = "# a comment\nexport PATH=$HOME/bin:$PATH\n" + out = shell_env.desired_rc_text(existing, plan) + assert "# a comment" in out + assert "export PATH=$HOME/bin:$PATH" in out + assert out.endswith(plan.import_line() + "\n") + + +def test_desired_rc_text_drops_stale_import_before_reappending(): + """A previous `generated_dir` leaves a STALE `source '/rig.env.sh'` line — the + desired text drops it and re-appends the CURRENT import exactly once (mirrors tmux's + `_desired_tmux_conf_text` stale-import handling).""" + plan = _plan(generated_dir="~/.config/rig/env") + stale = "source '/home/u/.config/rig/OLD/rig.env.sh'\n" + out = shell_env.desired_rc_text(stale, plan) + assert out == plan.import_line() + "\n" + assert out.count("rig.env.sh") == 1 + + +def test_desired_rc_text_comment_mentioning_path_is_not_matched_as_import(): + plan = _plan() + existing = f"# see {plan.generated_file_path} for details\n" + out = shell_env.desired_rc_text(existing, plan) + assert existing.strip() in out + assert out.count(plan.import_line()) == 1 + + +def test_desired_rc_text_preserves_trailing_blank_lines_on_first_append(): + """A lossy `splitlines()` + `\\n`.join() + `.rstrip("\\n")` round-trip would silently drop + the user's trailing blank line(s) even though nothing about them needed reconciling + (review finding).""" + plan = _plan() + existing = "export PATH=$HOME/bin:$PATH\n\n\n" # two trailing blank lines + out = shell_env.desired_rc_text(existing, plan) + assert out == existing + plan.import_line() + "\n" + + +def test_desired_rc_text_preserves_crlf_line_endings_on_first_append(): + """Every OTHER line's original terminator must survive byte-for-byte — a file using CRLF + must not be silently converted to LF (review finding).""" + plan = _plan() + existing = "export PATH=$HOME/bin:$PATH\r\n" + out = shell_env.desired_rc_text(existing, plan) + assert out == existing + plan.import_line() + "\n" + assert "\r\n" in out # the user's own line kept its CRLF terminator + + +def test_desired_rc_text_current_line_already_present_is_byte_identical_noop(): + """When the current import line is already present ANYWHERE, `existing` is returned + completely unchanged — including any trailing blank lines / CRLF elsewhere in the file + that a rebuild-from-scratch would otherwise risk normalizing away.""" + plan = _plan() + existing = plan.import_line() + "\r\nexport OTHER=1\r\n\r\n" + assert shell_env.desired_rc_text(existing, plan) == existing + + +def test_desired_rc_text_a_duplicated_current_line_is_left_as_is(): + """Two copies of the CURRENT (already-correct) import line are tolerated, not deduped — + sourcing the same file twice is a functional no-op, and `desired_rc_text` returns + `existing` verbatim whenever the current line is present at all (documents the deliberate + design choice: position-tolerance takes priority over exactly-once enforcement once the + line is already correct anywhere).""" + plan = _plan() + line = plan.import_line() + existing = f"{line}\nexport X=1\n{line}\n" + assert shell_env.desired_rc_text(existing, plan) == existing + + +def test_desired_rc_text_stale_only_duplicated_collapses_to_one_current_line(): + plan = _plan(generated_dir="~/.config/rig/env") + stale = "source '/home/u/.config/rig/OLD/rig.env.sh'\n" * 2 + out = shell_env.desired_rc_text(stale, plan) + assert out == plan.import_line() + "\n" + assert out.count("rig.env.sh") == 1 + + +# ── render_env_file: value/key safety ─────────────────────────────────────────────────── +def test_render_env_file_value_with_shell_metacharacters_is_inert(): + """The VALUE side is the actual injection surface once keys are identifier-restricted — + `shlex.quote` must neutralize command substitution, backticks, and embedded single quotes.""" + plan = _plan(vars={"MSG": "$(rm -rf ~) `whoami` it's-fine"}) + body = plan.render_env_file() + line = next(ln for ln in body.splitlines() if ln.startswith("export MSG=")) + # shlex.quote wraps the whole thing in single quotes and escapes the embedded one; the + # rendered line is never a bare, shell-interpretable `$(...)`/backtick sequence. + assert not line.startswith("export MSG=$(") + assert "'" in line # shlex.quote's own quoting is present + + +def test_render_env_file_rejects_an_invalid_key_even_if_it_bypassed_config_validate(): + """Defense in depth: `render_env_file` re-asserts the shell-identifier invariant itself + rather than trusting every possible caller to have gone through `config.validate` first.""" + plan = shell_env.build_shell_env(repo_home=Path("/home/u"), vars={"1 BAD; KEY": "x"}) + with pytest.raises(ValueError): + plan.render_env_file() + + +# ── plan building ──────────────────────────────────────────────────────────────────────── +def _cfg(data, repo_root): + from riglib.config import LoadedConfig + + return LoadedConfig(data=data, repo_root=repo_root) + + +def _build(data, repo_root, fake_agent_tools): + from riglib.catalog import Catalog + from riglib.plan import build + + data = {"agent_tools_source": str(fake_agent_tools), **data} + cat = Catalog.scan(str(fake_agent_tools)) + return build(_cfg(data, repo_root), cat, project_type="unknown") + + +def test_plan_no_env_when_absent(fake_agent_tools, tmp_path): + plan = _build({}, tmp_path, fake_agent_tools) + assert not [a for a in plan.actions if a.kind == "provision_env"] + + +def test_plan_no_env_when_disabled(fake_agent_tools, tmp_path): + plan = _build({"env": {"enabled": False}}, tmp_path, fake_agent_tools) + assert not [a for a in plan.actions if a.kind == "provision_env"] + + +def test_plan_emits_env_action_on_empty_block(fake_agent_tools, tmp_path): + """A present, empty `env: {}` block opts in — mirrors tmux's own "empty block still + provisions (with safe/empty defaults)" contract.""" + plan = _build({"env": {}}, tmp_path, fake_agent_tools) + acts = [a for a in plan.actions if a.kind == "provision_env"] + assert len(acts) == 1 + assert acts[0].category == "env" and acts[0].item == "vars" + + +def test_plan_carries_vars(fake_agent_tools, tmp_path): + plan = _build( + {"env": {"enabled": True, "vars": {"COLORTERM": "truecolor"}}}, tmp_path, fake_agent_tools + ) + a = [a for a in plan.actions if a.kind == "provision_env"][0] + assert a.options["vars"] == {"COLORTERM": "truecolor"} + + +def test_plan_defaults_vars_to_empty_dict_when_absent(fake_agent_tools, tmp_path): + plan = _build({"env": {"enabled": True}}, tmp_path, fake_agent_tools) + a = [a for a in plan.actions if a.kind == "provision_env"][0] + assert a.options["vars"] == {} + + +def test_env_from_a_repo_level_config_is_honored_same_trust_model_as_tmux(fake_agent_tools, tmp_path): + """DOCUMENTS the current trust model (a review question, not a gap this feature introduces): + ``LoadedConfig`` carries one already-MERGED ``data`` dict with no per-key record of which + layer (global vs a repo's own ``./rig.yaml``) contributed it — the global/repo split is a + CASCADE at load time and a LABEL for `rig status` display (`riglib.layers`), never an + enforcement boundary in `config.validate`/`_build_env`. So a repo's own committed + ``rig.yaml`` CAN declare an ``env:`` block and have it honored by `rig apply` run from that + repo, exactly like `tmux:`/`gitignore:`/`spotlight:` already can (verified: none of those + reject a repo-level occurrence either). This is a pre-existing, system-wide characteristic + of every GLOBAL-labeled block, not something `env` introduces or could unilaterally close + without doing the same for its siblings — the operator running `rig apply` against a repo + is trusting that repo's config, the same trust already extended to a Makefile/npm script.""" + plan = _build( + {"env": {"enabled": True, "vars": {"COLORTERM": "truecolor"}}}, tmp_path, fake_agent_tools + ) + env_actions = [a for a in plan.actions if a.kind == "provision_env"] + assert len(env_actions) == 1 + + # the SAME is true of tmux, its closest sibling in shape — pinning the parity explicitly. + tmux_plan = _build({"tmux": {"enabled": True}}, tmp_path, fake_agent_tools) + assert [a for a in tmux_plan.actions if a.kind == "provision_tmux"] + + +# ── install (runner) + drift — real filesystem, isolated $HOME ───────────────────────── +def test_apply_writes_generated_file_and_splices_import(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + (home / ".zshenv").write_text("eval brew shellenv\n", encoding="utf-8") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + result = runner._do_provision_env(action, "backup") + assert result.status == "updated" + + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + assert generated.is_file() + assert "export COLORTERM=truecolor" in generated.read_text(encoding="utf-8") + + rc_text = (home / ".zshenv").read_text(encoding="utf-8") + assert "eval brew shellenv" in rc_text # the user's own line is untouched + plan = runner.env_plan_from_action(action) + assert plan.import_line() in rc_text + + +def test_apply_is_idempotent(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + first = runner._do_provision_env(action, "backup") + second = runner._do_provision_env(action, "backup") + assert first.status == "updated" + assert second.status == "skipped" + # pin the DETAIL text too, not just status: the idempotent-skip vs conflict-skip split is + # decided by sniffing `WriteOutcome.detail`'s "identical" prefix (review finding) — if that + # wording ever changed, an ORDINARY re-apply could start emitting the misleading + # conflict-skip "NOT regenerated" text while still reporting `skipped`, silently. + assert second.detail == "env: already current" + + +def test_apply_updates_generated_file_when_vars_change(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + changed = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor", "OTHER": "1"}}, + ) + result = runner._do_provision_env(changed, "backup") + assert result.status == "updated" + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + assert "export OTHER=1" in generated.read_text(encoding="utf-8") + + +def test_apply_on_conflict_skip_leaves_a_hand_edited_generated_file_untouched(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + generated.write_text("export HAND_EDITED=1\n", encoding="utf-8") + + result = runner._do_provision_env(action, "skip") + assert generated.read_text(encoding="utf-8") == "export HAND_EDITED=1\n" + # the import line is already correct (from the prior apply) and stays untouched either + # way (position-tolerant), so the generated-file conflict is the ONLY thing `skip` left + # unresolved — nothing was WRITTEN this run, so the precise status is `skipped`, and the + # detail text must say so explicitly rather than the misleading "already current" a bare + # `changed`-only check would have produced (review finding). + assert result.status == "skipped" + assert "on_conflict=skip" in result.detail + assert result.detail != "env: already current" + + +def test_apply_on_conflict_skip_surfaces_unresolved_splice_not_a_silent_updated(tmp_path, monkeypatch): + """A FRESH `rc_path` (real pre-existing content, never touched by rig before) under + `on_conflict=skip`: the generated file gets CREATED (no prior conflict there) while the + splice into `rc_path` is skipped — that combination must not read as a plain, fully + successful `updated`; the still-unresolved splice must be visible in the detail text + (review finding: the far weaker `status in ("skipped", "updated")` assertion on the + hand-edited-generated-file test above left this exact ambiguity untested).""" + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + (home / ".zshenv").write_text("export MY_OWN_VAR=1\n", encoding="utf-8") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + + result = runner._do_provision_env(action, "skip") + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + assert generated.is_file() # the generated file WAS created — no conflict there + rc_text = (home / ".zshenv").read_text(encoding="utf-8") + assert rc_text == "export MY_OWN_VAR=1\n" # the splice did NOT happen + assert "NOT added" in result.detail # the unresolved splice is visible, not silently lost + assert result.status == "updated" # the generated file DID change this run + + +def test_apply_on_conflict_overwrite_replaces_a_hand_edited_generated_file(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + generated.write_text("export HAND_EDITED=1\n", encoding="utf-8") + + result = runner._do_provision_env(action, "overwrite") + assert result.status == "updated" + body = generated.read_text(encoding="utf-8") + assert "export COLORTERM=truecolor" in body + assert "HAND_EDITED" not in body + + +def test_drift_missing_when_nothing_applied(tmp_path, monkeypatch): + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + report = DriftReport() + _check_env(action, report) + directions = {(d.direction, d.category) for d in report.items} + assert ("missing", "env") in directions + assert len([d for d in report.items if d.category == "env"]) == 2 # file + import line + + +def test_drift_clean_after_apply(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + report = DriftReport() + _check_env(action, report) + assert not [d for d in report.items if d.category == "env"] + + +def test_drift_modified_when_generated_file_hand_edited(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + generated.write_text("export HAND_EDITED=1\n", encoding="utf-8") + report = DriftReport() + _check_env(action, report) + modified = [d for d in report.items if d.category == "env" and d.direction == "modified"] + assert len(modified) == 1 + + +def test_drift_missing_import_line_when_rc_path_lost_it(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + (home / ".zshenv").write_text("# the import line got removed\n", encoding="utf-8") + report = DriftReport() + _check_env(action, report) + missing = [d for d in report.items if d.category == "env" and d.direction == "missing"] + assert len(missing) == 1 + + +def test_drift_stays_clean_when_user_relocated_the_import_line(tmp_path, monkeypatch): + """`env` is deliberately POSITION-TOLERANT (unlike tmux): a user who moves rig's still + byte-identical import line above their own exports (so their values win) is genuinely IN + SYNC. Neither drift nor a re-apply should move it back — both must agree on that (review + finding: an earlier end-anchoring version disagreed with a looser "present anywhere" drift + check; the fix taken here is to make apply itself position-tolerant instead, so the two + share one predicate by construction rather than by careful separate maintenance).""" + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + plan = runner.env_plan_from_action(action) + relocated = plan.import_line() + "\nexport MY_OWN_VAR=1\n" + (home / ".zshenv").write_text(relocated, encoding="utf-8") + + report = DriftReport() + _check_env(action, report) + assert not [d for d in report.items if d.category == "env"] + + # and apply agrees: re-applying is a true no-op, the relocated line stays exactly where the + # user put it. + result = runner._do_provision_env(action, "skip") + assert result.status == "skipped" + assert (home / ".zshenv").read_text(encoding="utf-8") == relocated + + +def test_apply_drops_a_stale_import_even_when_the_current_line_is_also_present(tmp_path, monkeypatch): + """Position-tolerance for the CURRENT line must not become an excuse to leave an orphaned + STALE `rig.env.sh` (an old `generated_dir`) silently sourced forever — the two must not + coexist (review finding).""" + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + plan = runner.env_plan_from_action(action) + stale = "source '/home/u/.config/rig/OLD/rig.env.sh'\n" + both = stale + plan.import_line() + "\n" + (home / ".zshenv").write_text(both, encoding="utf-8") + + result = runner._do_provision_env(action, "backup") + assert result.status == "updated" + rc_text = (home / ".zshenv").read_text(encoding="utf-8") + assert "OLD/rig.env.sh" not in rc_text + assert rc_text.count(plan.import_line()) == 1 + + +def test_drift_flags_a_stale_import_coexisting_with_the_current_one(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + plan = runner.env_plan_from_action(action) + stale = "source '/home/u/.config/rig/OLD/rig.env.sh'\n" + (home / ".zshenv").write_text(stale + plan.import_line() + "\n", encoding="utf-8") + # the generated rig.env.sh was never applied in this test -- that's a SEPARATE, expected + # "missing" item; only the rc_path item is what this test is pinning. + plan.generated_dir.mkdir(parents=True, exist_ok=True) + plan.generated_file_path.write_text(plan.render_env_file(), encoding="utf-8") + + report = DriftReport() + _check_env(action, report) + rc_items = [d for d in report.items if d.category == "env" and d.target == plan.rc_path] + assert len(rc_items) == 1 + # the current line IS present -- reported as `modified` (needs reconciling), never the + # misleading `missing` a naive predicate would have said (review finding). + assert rc_items[0].direction == "modified" + + +def test_env_plan_from_action_requires_rc_path_and_generated_dir(tmp_path, monkeypatch): + """UNLIKE tmux's `tmux_plan_from_action` (which defaults a genuinely pre-dated option), + `env_plan_from_action` REQUIRES `rc_path`/`generated_dir` in `action.options` — `env` is a + brand-new action kind with no real "persisted by an older rig" scenario to serve, and + `_build_env` (`riglib/plan.py`) always writes both keys, so a caller reaching this WITHOUT + them is a bug, not a legitimate replay. Failing loudly (`KeyError`) beats a bare-default + fallback silently resolving differently than the plan builder would have (review finding, + discovered via exactly that divergence — see `riglib/shell_env.py`'s `build_shell_env` + docstring for the fuller story).""" + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"vars": {"COLORTERM": "truecolor"}}, # no "rc_path"/"generated_dir" at all + ) + with pytest.raises(KeyError): + runner.env_plan_from_action(action) + + +def test_env_plan_from_action_resolves_exactly_what_options_carry(tmp_path, monkeypatch): + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={ + "rc_path": str(home / ".zshenv"), + "generated_dir": str(home / ".config" / "rig" / "env"), + "vars": {"COLORTERM": "truecolor"}, + }, + ) + plan = runner.env_plan_from_action(action) + assert plan.rc_path == home / ".zshenv" + assert plan.generated_dir == home / ".config" / "rig" / "env" + + result = runner._do_provision_env(action, "backup") + assert result.status == "updated" + assert (home / ".zshenv").is_file() + assert (home / ".config" / "rig" / "env" / "rig.env.sh").is_file() + + +def test_apply_reports_error_not_exception_on_non_utf8_rc_path(tmp_path, monkeypatch): + """The docstring's stated `(OSError, UnicodeDecodeError)` catch on the `rc_path` read must + actually fire for the failure mode it names — a genuinely non-UTF-8 file — not just the + `NotADirectoryError` case already covered.""" + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + (home / ".zshenv").write_bytes(b"\xff\xfe not valid utf-8 \x80\x81") + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + result = runner._do_provision_env(action, "backup") + assert result.status == "error" + + +def test_drift_reports_modified_not_exception_on_non_utf8_rc_path(tmp_path, monkeypatch): + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + (home / ".zshenv").write_bytes(b"\xff\xfe not valid utf-8 \x80\x81") + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + report = DriftReport() + _check_env(action, report) # must not raise + assert [d for d in report.items if d.category == "env"] + + +def test_drift_still_checks_rc_path_when_generated_file_cannot_be_rendered(tmp_path, monkeypatch): + """A render failure (an invalid var key that somehow bypassed `config.validate` and + reached a persisted Action directly) must not ALSO silently skip the rc_path check — that + check doesn't depend on the render at all (review finding: an earlier version `return`ed + immediately on the render `ValueError`, so a genuinely missing/stale import line went + unreported alongside the render-failure item).""" + from riglib.drift import _check_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={ + "rc_path": str(home / ".zshenv"), + "generated_dir": str(home / ".config" / "rig" / "env"), + "vars": {"1 BAD; KEY": "x"}, # invalid identifier — bypasses config.validate here + }, + ) + report = DriftReport() + _check_env(action, report) # must not raise + targets = {d.target for d in report.items if d.category == "env"} + assert home / ".config" / "rig" / "env" / "rig.env.sh" in targets # the render-failure item + assert home / ".zshenv" in targets # the rc_path check STILL ran (import line is missing) + + +def test_drift_dispatches_through_detect(tmp_path, monkeypatch): + """The `elif action.kind == "provision_env"` wiring in `drift.detect()` itself is exercised + (not just a direct `_check_env` call) — a typo'd/missing dispatch entry would silently drop + every env drift item from `rig status`.""" + from riglib import drift as drift_mod + from riglib.actions import runner + from riglib.plan import Action, InstallPlan + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + report = drift_mod.detect(InstallPlan(actions=[action])) + assert any(d.category == "env" for d in report.items) + + runner._do_provision_env(action, "backup") + clean_report = drift_mod.detect(InstallPlan(actions=[action])) + assert not [d for d in clean_report.items if d.category == "env"] + + +def test_check_disabled_env_flags_a_leftover_generated_file(tmp_path, monkeypatch): + """`apply` never deletes — so turning `env.enabled` to `false` after a prior apply leaves + `rig.env.sh` (and its live source line) fully active. With no `provision_env` action in + the plan, `_check_env` never runs; `check_disabled_env` is the separate scan that catches + this specific leftover (mirrors `check_disabled_global_excludes`).""" + from riglib.actions import runner + from riglib.drift import check_disabled_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") # simulate a prior apply while enabled + + report = DriftReport() + check_disabled_env(action, report) + # BOTH halves are still live (the generated file AND rc_path's source line) -- checked + # independently, so both are reported (review finding: an earlier version checked only the + # generated file). + items = [d for d in report.items if d.category == "env"] + assert len(items) == 2 + assert {d.direction for d in items} == {"extra"} + assert {d.target for d in items} == { + (home / ".config" / "rig" / "env" / "rig.env.sh"), + (home / ".zshenv"), + } + + +def test_check_disabled_env_flags_the_inverse_orphan_deleted_generated_file(tmp_path, monkeypatch): + """The INVERSE orphan (review finding): the generated file was deleted by hand, but a + stale `source` line survives in `rc_path` — every zsh invocation on the machine then + errors at startup ('no such file or directory'). This must be caught even though the + generated file itself is gone (the OTHER half of the pair is fine).""" + from riglib.actions import runner + from riglib.drift import check_disabled_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + (home / ".config" / "rig" / "env" / "rig.env.sh").unlink() # hand-deleted; source line stays + + report = DriftReport() + check_disabled_env(action, report) + items = [d for d in report.items if d.category == "env"] + assert len(items) == 1 + assert items[0].target == home / ".zshenv" + assert "GONE" in items[0].detail + + +def test_check_disabled_env_reports_no_exception_on_non_utf8_rc_path(tmp_path, monkeypatch): + """`check_disabled_env`'s rc_path read must not crash `rig status` any more than + `_do_provision_env`'s/`_check_env`'s reads do (review finding: this fourth read had been + missed by the earlier hardening pass).""" + from riglib.actions import runner + from riglib.drift import check_disabled_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + runner._do_provision_env(action, "backup") + (home / ".zshenv").write_bytes(b"\xff\xfe not valid utf-8 \x80\x81") + + report = DriftReport() + check_disabled_env(action, report) # must not raise + # the generated-file half still reports independently (its own read is unaffected). + assert any(d.target == home / ".config" / "rig" / "env" / "rig.env.sh" for d in report.items) + + +def test_check_disabled_env_silent_when_nothing_was_ever_installed(tmp_path, monkeypatch): + from riglib.drift import check_disabled_env, DriftReport + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=home / ".zshenv", + options={"rc_path": str(home / ".zshenv"), "generated_dir": str(home / ".config" / "rig" / "env"), "vars": {"COLORTERM": "truecolor"}}, + ) + report = DriftReport() + check_disabled_env(action, report) + assert not [d for d in report.items if d.category == "env"] + + +def test_apply_reports_error_not_exception_when_rc_path_write_fails(tmp_path, monkeypatch): + """`_do_provision_env` must convert an `OSError` writing `rc_path` into an `ActionResult` + error, not let it propagate raw — `rc_path` is the user's OWN, possibly irreplaceable file + (review finding: it previously had no error-handling parity with the generated file).""" + from riglib.actions import runner + from riglib.plan import Action + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + # rc_path's PARENT is a FILE, not a dir — `.parent.mkdir()` / `.write_text()` both raise + # NotADirectoryError (an OSError subclass) rather than silently succeeding. + blocker = home / "blocker" + blocker.write_text("not a directory", encoding="utf-8") + action = Action( + kind="provision_env", category="env", item="vars", + source=tmp_path, target=blocker / "zshenv", + options={ + "rc_path": str(blocker / "zshenv"), + "generated_dir": str(home / ".config" / "rig" / "env"), + "vars": {"COLORTERM": "truecolor"}, + }, + ) + result = runner._do_provision_env(action, "backup") + assert result.status == "error" + assert "blocker" in result.detail + + +def test_plan_to_apply_to_drift_round_trip(fake_agent_tools, tmp_path, monkeypatch): + """rig.yaml -> plan action -> runner install -> drift check, end to end. + + ``_build_env`` (plan.py) resolves the default ``~/.zshenv`` via ``os.path.expanduser`` (the + ``HOME`` env var), but ``~/.config/rig/env`` goes through ``expand_user_path``'s ``~/.config`` + -> ``$XDG_CONFIG_HOME`` special case — while the runner/drift side (``env_plan_from_action``) + resolves via ``Path.home()``. All three must point at the SAME dir for this real-file-I/O + test, so set them explicitly rather than relying on the autouse ``_isolate_home`` fixture's + own private tmp dir (which sets ``HOME``/``XDG_CONFIG_HOME`` to a DIFFERENT throwaway dir than + the one this test wants to assert against). + """ + from riglib.actions import runner + from riglib.drift import _check_env, DriftReport + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + + plan = _build({"env": {"enabled": True, "vars": {"COLORTERM": "truecolor"}}}, tmp_path, fake_agent_tools) + a = next(act for act in plan.actions if act.kind == "provision_env") + result = runner._do_provision_env(a, "backup") + assert result.status == "updated" + + report = DriftReport() + _check_env(a, report) + assert not [d for d in report.items if d.category == "env"] + + generated = home / ".config" / "rig" / "env" / "rig.env.sh" + assert "export COLORTERM=truecolor" in generated.read_text(encoding="utf-8") diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 71021b6..591673d 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -59,6 +59,7 @@ def test_tmux_full_block_accepted(): "cc_restore": {"enabled": True}, "anti_sprawl": {"enabled": True, "session": "main"}, "boot": {"enabled": True}, + "focus_events": {"enabled": True}, }, } ) @@ -111,6 +112,21 @@ def test_tmux_unknown_nested_key_rejected(): validate({"version": 1, "tmux": {"moshi": {"enable": True}}}) # typo: enable +def test_tmux_focus_events_block_accepted(): + validate({"version": 1, "tmux": {"focus_events": {"enabled": True}}}) + validate({"version": 1, "tmux": {"focus_events": {"enabled": False}}}) + + +def test_tmux_focus_events_enabled_must_be_bool(): + with pytest.raises(ConfigError): + validate({"version": 1, "tmux": {"focus_events": {"enabled": "yes"}}}) + + +def test_tmux_focus_events_unknown_key_rejected(): + with pytest.raises(ConfigError): + validate({"version": 1, "tmux": {"focus_events": {"enable": True}}}) # typo: enable + + def test_tmux_boot_label_must_be_string(): with pytest.raises(ConfigError): validate({"version": 1, "tmux": {"boot": {"label": 123}}}) @@ -396,6 +412,23 @@ def test_render_disabled_booleans_emit_explicit_off(): assert "set -g @resurrect-capture-pane-contents 'off'" in conf +def test_render_focus_events_default_on(): + """focus-events is a plain terminal-capability toggle, default-on (Alex, 2026-08-05): + absent a `tmux.focus_events` block, the generated conf must report focus in/out events.""" + conf = _plan().render_rig_conf() + assert "set -g focus-events on" in conf + + +def test_render_focus_events_off_is_explicit(): + """Same explicit-off contract as every other modeled boolean: `enabled: false` must emit + 'off' (not omit the line), so it overrides a preserved inline value from a migrated conf.""" + conf = tmux.build_tmux( + repo_home=Path("/home/u"), focus_events={"enabled": False} + ).render_rig_conf() + assert "set -g focus-events off" in conf + assert "set -g focus-events on" not in conf + + def test_cc_save_avoids_ls_head_pipe(): """cc-save must NOT use `ls -t … | head -n1` (SIGPIPE under pipefail drops the pane) — it takes the first line of captured ls output instead (codex P2).""" @@ -897,6 +930,29 @@ def test_tmux_plan_from_action_on_pre_upgrade_action_defaults_to_new_pane_titles assert "set -g status-right ''" in conf +def test_tmux_plan_from_action_on_pre_upgrade_action_defaults_to_focus_events_on(): + """Same drift-check-replay contract as pane_titles above, for `focus_events`: a persisted + Action from before this feature existed (no `focus_events` key) must still render + `focus-events on` — the same default a freshly-built plan gets.""" + from riglib.actions.runner import tmux_plan_from_action + from riglib.plan import Action + + action = Action( + kind="provision_tmux", + category="tmux", + item="config", + source=Path("/repo"), + target=Path("/home/u/.tmux.conf"), + options={ + "conf_path": "/home/u/.tmux.conf", + "generated_dir": "/home/u/.config/rig/tmux", + # no "focus_events" key — a pre-upgrade persisted action. + }, + ) + conf = tmux_plan_from_action(action).render_rig_conf() + assert "set -g focus-events on" in conf + + def test_render_moshi_on_guards_under_moshi_client(): """The Moshi tweak must be gated on $MOSHI_CLIENT (it only applies on the iOS client).""" conf = tmux.build_tmux(repo_home=Path("/home/u"), moshi={"enabled": True}).render_rig_conf() @@ -2048,6 +2104,39 @@ def test_plan_to_render_round_trip_carries_pane_titles_format_and_clear_status_r assert "status-right ''" not in conf +def test_plan_carries_focus_events_block(fake_agent_tools, tmp_path): + plan = _build( + {"tmux": {"enabled": True, "focus_events": {"enabled": False}}}, tmp_path, fake_agent_tools + ) + a = [a for a in plan.actions if a.kind == "provision_tmux"][0] + assert a.options["focus_events"] == {"enabled": False} + + +def test_plan_defaults_focus_events_to_empty_dict_when_absent(fake_agent_tools, tmp_path): + plan = _build({"tmux": {"enabled": True}}, tmp_path, fake_agent_tools) + a = [a for a in plan.actions if a.kind == "provision_tmux"][0] + assert a.options["focus_events"] == {} + + +def test_plan_to_render_round_trip_carries_focus_events_disabled(fake_agent_tools, tmp_path): + """rig.yaml -> plan action -> runner's tmux_plan_from_action -> render, end to end. + + Exercises the FULL pass-through chain (`plan.py`'s options dict, then + `runner.tmux_plan_from_action`'s kwarg) for the disabled case specifically — the other + focus-events tests either call `tmux.build_tmux` directly (bypassing `plan.py`/`runner.py`) + or only assert the default-on value, so a typo'd/missing pass-through in either layer + would silently keep rendering 'on' and slip past every other test here undetected.""" + from riglib.actions.runner import tmux_plan_from_action + + plan = _build( + {"tmux": {"enabled": True, "focus_events": {"enabled": False}}}, tmp_path, fake_agent_tools + ) + a = next(act for act in plan.actions if act.kind == "provision_tmux") + conf = tmux_plan_from_action(a).render_rig_conf() + assert "set -g focus-events off" in conf + assert "set -g focus-events on" not in conf + + def test_plan_disables_autosave_off_darwin(fake_agent_tools, tmp_path, monkeypatch): """The autosave LaunchAgent is macOS-only — on a non-darwin host the plan forces autosave.enabled False so the generated config keeps continuum's own save (never disabling it