diff --git a/.chezmoiignore b/.chezmoiignore index f21b324..525b669 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -5,6 +5,8 @@ Library/LaunchAgents/com.mgm.aws-sso-login.plist {{ end }} {{ if ne .machine_type "mac-mini" }} .config/atuin-server/ +Library/LaunchAgents/dev.dotfiles.runtime-state.plist +.local/bin/configure-runtime-state {{ end }} {{ if eq .machine_type "lmsh" }} * diff --git a/docs/specs/shell_auth_startup/BACKLOG.md b/docs/specs/shell_auth_startup/BACKLOG.md index 53a2662..67950ef 100644 --- a/docs/specs/shell_auth_startup/BACKLOG.md +++ b/docs/specs/shell_auth_startup/BACKLOG.md @@ -14,3 +14,4 @@ | AUTH-010 | Deny-by-default lmsh source target boundary | 2026-07-25 | `4fdc25e` | | AUTH-010-session-credentials | Session-isolated GCP credentials with stale-file rematerialization | 2026-08-01 | `a8e0d9a` | | AUTH-011-cache-relocation | Routed supported CLI caches and Pulumi home through sentinel-guarded native paths; paused PyCharm, Prefect, and Codex | 2026-08-09 | `712e31e` | +| AUTH-013-runtime-state-relocation | Routed validated Prefect, Codex, and PyCharm state externally with team-safe defaults and runtime fallback | 2026-08-09 | `0e3e9dc` | diff --git a/docs/specs/shell_auth_startup/CHANGELOG.md b/docs/specs/shell_auth_startup/CHANGELOG.md index 21ebceb..a208cde 100644 --- a/docs/specs/shell_auth_startup/CHANGELOG.md +++ b/docs/specs/shell_auth_startup/CHANGELOG.md @@ -2,6 +2,20 @@ ## 2026-08-09 +- Routed validated Prefect and Codex homes through their native + controls and PyCharm system state through custom properties. All settings are + Mac-mini-only, sentinel-guarded, and excluded from teammate defaults. +- Preserved 802 Prefect runs; copied databases passed SQLite integrity checks, + Prefect server health, Codex GUI file-handle checks, and PyCharm control + restarts. A login controller reconciles GUI state at load, sentinel changes, + and 60-second intervals while preserving unrelated inherited values. +- Deployed the shell profile and LaunchAgent, then moved internal rollback copies + to `/Volumes/ext/state/archive/runtime-state-rollback-20260809`. Post-move + activation remained external and reclaimed about 12 GiB internally. +- Validation: 173 tests, Bash/Zsh syntax, plist/render checks, SQLite integrity, + launchd state, and post-move GUI/runtime smoke passed. Accepted risk: + `AUTH-013-AR1`. Implementation Gate Commit: `0e3e9dc`. + - Routed Playwright, uv, pre-commit, npm, and Pulumi through their native path controls only when the existing external-state sentinel is present. Inherited managed values clear on the fallback path without overriding unrelated values. diff --git a/docs/specs/shell_auth_startup/README.md b/docs/specs/shell_auth_startup/README.md index cec7713..dac6aca 100644 --- a/docs/specs/shell_auth_startup/README.md +++ b/docs/specs/shell_auth_startup/README.md @@ -335,10 +335,15 @@ Glossary: - **Invariant:** Playwright, uv, pre-commit, and npm use only their documented native path controls; shell-wide `XDG_CACHE_HOME` and cache symlinks are not used. -- **Invariant:** Pulumi uses its documented `PULUMI_HOME`; Prefect databases and - Codex session state remain internal until their installed runtimes can pass - migration integrity tests. PyCharm remains internal until its external paths - can fail back when the volume is unavailable. +- **Invariant:** Pulumi, Prefect, and Codex use their documented native home + controls only on the Mac mini with the external-state sentinel present. +- **Invariant:** Codex GUI processes receive the same native `CODEX_HOME` through + the Mac-mini-only login environment; missing-sentinel startup removes only the + managed value. +- **Invariant:** PyCharm settings and plugins remain internal; its regenerable + system directory uses `idea.system.path` only on the Mac mini. +- **Pre:** migrated Prefect and Codex SQLite files pass integrity and runtime + activation checks before internal rollback copies may be removed. - **Invariant:** `TerminalTargetAllowlist` denies all targets by default and re-includes only `.bash_profile`, `.bashrc`, `.common_profile`, `.config/atuin/config.toml`, `.config/starship.toml`, and @@ -371,6 +376,14 @@ Glossary: - **Post:** remote failure does not prevent local history search or capture. ### Accepted Risk +- `AUTH-013-AR1`: on 2026-08-09 the operator approved storing Prefect history and + Codex authentication/session state, and PyCharm Local History on the existing + unencrypted, `noowners` external volume after each exposure was reported. + Sentinel fallback, validated copies, and physical custody reduce availability + and migration risk but do not provide encryption or local ownership isolation. + Owner: operator. Review before the volume leaves trusted custody, another local + account gains access, or the storage policy changes; encrypt the volume when + operationally feasible. - `AUTH-011-AR1`: the operator approved moving Pulumi credentials and executable caches to the existing unencrypted, `noowners` external state volume after the trust limitation was reported. The state sentinel, retained internal rollback @@ -389,7 +402,12 @@ Glossary: - Shell syntax checks pass for edited scripts. - Rendered Mac mini shell startup selects external CLI caches only with the sentinel present; absent-sentinel startup retains local defaults. -- PyCharm custom properties remain absent after the attempted migration rollback. +- Prefect preserves 802 runs, passes SQLite `quick_check`, and serves a healthy + local API from the external home. +- Codex CLI and GUI use the external home; copied SQLite databases pass + `quick_check` and live file handles resolve externally. +- PyCharm control restart opens the external system directory without internal + cache file handles. - Static search confirms no Herdr profile auto-`secret` block remains. - Static search confirms Clockify poller has no `op read` call. - Static search confirms Databricks config has no `onepasswordRead` call. diff --git a/dot_common_profile.tmpl b/dot_common_profile.tmpl index a77a02f..6f70e72 100644 --- a/dot_common_profile.tmpl +++ b/dot_common_profile.tmpl @@ -106,12 +106,18 @@ if [ -f "/Volumes/ext/state/.dotfiles-ai-state" ]; then export PRE_COMMIT_HOME="$DOTFILES_CACHE_ROOT/pre-commit" export npm_config_cache="$DOTFILES_CACHE_ROOT/npm" export PULUMI_HOME="/Volumes/ext/state/pulumi" + export PREFECT_HOME="/Volumes/ext/state/prefect/seo-data-science" + export CODEX_HOME="/Volumes/ext/state/codex/home" + export PYCHARM_PROPERTIES="/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties" else [ "${PLAYWRIGHT_BROWSERS_PATH:-}" = "/Volumes/ext/state/cache/playwright" ] && unset PLAYWRIGHT_BROWSERS_PATH [ "${UV_CACHE_DIR:-}" = "/Volumes/ext/state/cache/uv" ] && unset UV_CACHE_DIR [ "${PRE_COMMIT_HOME:-}" = "/Volumes/ext/state/cache/pre-commit" ] && unset PRE_COMMIT_HOME [ "${npm_config_cache:-}" = "/Volumes/ext/state/cache/npm" ] && unset npm_config_cache [ "${PULUMI_HOME:-}" = "/Volumes/ext/state/pulumi" ] && unset PULUMI_HOME + [ "${PREFECT_HOME:-}" = "/Volumes/ext/state/prefect/seo-data-science" ] && unset PREFECT_HOME + [ "${CODEX_HOME:-}" = "/Volumes/ext/state/codex/home" ] && unset CODEX_HOME + [ "${PYCHARM_PROPERTIES:-}" = "/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties" ] && unset PYCHARM_PROPERTIES [ "${DOTFILES_CACHE_ROOT:-}" = "/Volumes/ext/state/cache" ] && unset DOTFILES_CACHE_ROOT fi {{ end -}} diff --git a/dot_local/bin/executable_configure-runtime-state b/dot_local/bin/executable_configure-runtime-state new file mode 100644 index 0000000..2df4a8b --- /dev/null +++ b/dot_local/bin/executable_configure-runtime-state @@ -0,0 +1,12 @@ +#!/bin/sh + +codex_home=/Volumes/ext/state/codex/home +pycharm_properties=/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties +if [ -f /Volumes/ext/state/.dotfiles-ai-state ]; then + /bin/launchctl setenv CODEX_HOME "$codex_home" + /bin/launchctl setenv PYCHARM_PROPERTIES "$pycharm_properties" +else + [ "$(/bin/launchctl getenv CODEX_HOME)" = "$codex_home" ] && /bin/launchctl unsetenv CODEX_HOME + [ "$(/bin/launchctl getenv PYCHARM_PROPERTIES)" = "$pycharm_properties" ] && /bin/launchctl unsetenv PYCHARM_PROPERTIES +fi +exit 0 diff --git a/private_Library/LaunchAgents/dev.dotfiles.runtime-state.plist.tmpl b/private_Library/LaunchAgents/dev.dotfiles.runtime-state.plist.tmpl new file mode 100644 index 0000000..9302244 --- /dev/null +++ b/private_Library/LaunchAgents/dev.dotfiles.runtime-state.plist.tmpl @@ -0,0 +1,20 @@ + + + + + Label + dev.dotfiles.runtime-state + ProgramArguments + + {{ .chezmoi.homeDir }}/.local/bin/configure-runtime-state + + RunAtLoad + + WatchPaths + + /Volumes/ext/state/.dotfiles-ai-state + + StartInterval + 60 + + diff --git a/tests/test_terminal_environment.py b/tests/test_terminal_environment.py index 28bfcc9..b44e96c 100644 --- a/tests/test_terminal_environment.py +++ b/tests/test_terminal_environment.py @@ -11,6 +11,9 @@ def text(path): def test_lmsh_profile_is_portable_and_excludes_credentials(): + assert '"machine_type" "Machine type (macbook/mac-mini/lmsh)" "macbook"' in text( + ".chezmoi.toml.tmpl" + ) assert "macbook/mac-mini/lmsh" in text(".chezmoi.toml.tmpl") assert "atuin_sync_address" in text(".chezmoi.toml.tmpl") ignored = text(".chezmoiignore") @@ -39,6 +42,9 @@ def test_mac_mini_uses_native_external_cache_paths(): 'PRE_COMMIT_HOME="$DOTFILES_CACHE_ROOT/pre-commit"', 'npm_config_cache="$DOTFILES_CACHE_ROOT/npm"', 'PULUMI_HOME="/Volumes/ext/state/pulumi"', + 'PREFECT_HOME="/Volumes/ext/state/prefect/seo-data-science"', + 'CODEX_HOME="/Volumes/ext/state/codex/home"', + 'PYCHARM_PROPERTIES="/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties"', ): assert setting in profile for variable in ( @@ -48,6 +54,9 @@ def test_mac_mini_uses_native_external_cache_paths(): "PRE_COMMIT_HOME", "npm_config_cache", "PULUMI_HOME", + "PREFECT_HOME", + "CODEX_HOME", + "PYCHARM_PROPERTIES", ): assert f'unset {variable}' in profile @@ -69,6 +78,9 @@ def test_external_cache_exports_and_fallback(tmp_path): "PRE_COMMIT_HOME": "/Volumes/ext/state/cache/pre-commit", "npm_config_cache": "/Volumes/ext/state/cache/npm", "PULUMI_HOME": "/Volumes/ext/state/pulumi", + "PREFECT_HOME": "/Volumes/ext/state/prefect/seo-data-science", + "CODEX_HOME": "/Volumes/ext/state/codex/home", + "PYCHARM_PROPERTIES": "/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties", } shells = [shell for shell in ("/bin/bash", "/bin/zsh") if Path(shell).exists()] @@ -91,6 +103,62 @@ def test_external_cache_exports_and_fallback(tmp_path): assert f"{name}=" not in fallback +def test_mac_mini_gui_state_paths_are_scoped_and_native(): + ignored = text(".chezmoiignore") + mac_mini = ignored.split('{{ if ne .machine_type "mac-mini" }}', 1)[1].split("{{ end }}", 1)[0] + assert "Library/LaunchAgents/dev.dotfiles.runtime-state.plist" in mac_mini + assert ".local/bin/configure-runtime-state" in mac_mini + runtime_state = text("dot_local/bin/executable_configure-runtime-state") + assert "/Volumes/ext/state/.dotfiles-ai-state" in runtime_state + assert "codex_home=/Volumes/ext/state/codex/home" in runtime_state + assert "pycharm_properties=/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties" in runtime_state + for variable in ("CODEX_HOME", "PYCHARM_PROPERTIES"): + assert f"/bin/launchctl setenv {variable}" in runtime_state + assert f"/bin/launchctl unsetenv {variable}" in runtime_state + launch_agent = text("private_Library/LaunchAgents/dev.dotfiles.runtime-state.plist.tmpl") + assert "WatchPaths" in launch_agent + assert "StartInterval" in launch_agent + + +def test_gui_state_controller_tracks_sentinel_without_overwriting_custom_values(tmp_path): + sentinel = tmp_path / "sentinel" + state = tmp_path / "launchctl-state" + state.mkdir() + launchctl = tmp_path / "launchctl" + launchctl.write_text( + """#!/bin/sh +case "$1" in + setenv) printf '%s' "$3" > "$LAUNCHCTL_STATE/$2" ;; + getenv) cat "$LAUNCHCTL_STATE/$2" 2>/dev/null ;; + unsetenv) rm -f "$LAUNCHCTL_STATE/$2" ;; +esac +""" + ) + launchctl.chmod(0o755) + script = ( + text("dot_local/bin/executable_configure-runtime-state") + .replace("/Volumes/ext/state/.dotfiles-ai-state", str(sentinel)) + .replace("/bin/launchctl", str(launchctl)) + ) + env = os.environ | {"LAUNCHCTL_STATE": str(state)} + + sentinel.touch() + subprocess.run(["/bin/sh", "-c", script], env=env, check=True) + assert (state / "CODEX_HOME").read_text() == "/Volumes/ext/state/codex/home" + assert (state / "PYCHARM_PROPERTIES").read_text() == ( + "/Volumes/ext/state/jetbrains/PyCharm2026.2/idea.properties" + ) + + sentinel.unlink() + subprocess.run(["/bin/sh", "-c", script], env=env, check=True) + assert not list(state.iterdir()) + + for variable in ("CODEX_HOME", "PYCHARM_PROPERTIES"): + (state / variable).write_text("/custom") + subprocess.run(["/bin/sh", "-c", script], env=env, check=True) + assert {path.read_text() for path in state.iterdir()} == {"/custom"} + + def test_lmsh_targets_are_deny_by_default(): ignored = text(".chezmoiignore") lmsh = ignored.split('{{ if eq .machine_type "lmsh" }}', 1)[1].split("{{ end }}", 1)[0]