railmux 0.4.0.dev9__tar.gz → 0.4.0.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. {railmux-0.4.0.dev9/src/railmux.egg-info → railmux-0.4.0.dev10}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev10/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/cli.py +16 -0
  4. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/codex_index.py +2 -2
  5. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/discovery.py +2 -1
  6. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_server.py +1 -1
  7. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/mouse_manager.py +1 -1
  8. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/orphan_marker.py +2 -2
  9. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/provider_paths.py +39 -0
  10. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/restart_state.py +8 -2
  11. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_binding_manager.py +1 -1
  12. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/app.py +21 -1
  13. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_msys2.py +4 -0
  14. railmux-0.4.0.dev10/src/railmux/windows_tmux_lifecycle.py +259 -0
  15. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10/src/railmux.egg-info}/PKG-INFO +1 -1
  16. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/SOURCES.txt +2 -0
  17. railmux-0.4.0.dev10/tests/test_provider_paths.py +88 -0
  18. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_restart_state.py +30 -0
  19. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_soft_quit.py +6 -0
  20. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_msys2.py +11 -5
  21. railmux-0.4.0.dev10/tests/test_windows_tmux_lifecycle.py +140 -0
  22. railmux-0.4.0.dev9/src/railmux/__init__.py +0 -1
  23. railmux-0.4.0.dev9/tests/test_provider_paths.py +0 -29
  24. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/LICENSE +0 -0
  25. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/MANIFEST.in +0 -0
  26. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/README.md +0 -0
  27. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/pyproject.toml +0 -0
  28. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/setup.cfg +0 -0
  29. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/__main__.py +0 -0
  30. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/atomic_file.py +0 -0
  31. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/background_index.py +0 -0
  32. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/config.py +0 -0
  33. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/config_cli.py +0 -0
  34. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/diagnostics.py +0 -0
  35. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/display_transport.py +0 -0
  36. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/entrypoint.py +0 -0
  37. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_client.py +0 -0
  38. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_history.py +0 -0
  39. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_input.py +0 -0
  40. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_protocol.py +0 -0
  41. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/favorites.py +0 -0
  42. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fuzzy.py +0 -0
  43. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/help_workspace.py +0 -0
  44. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/launcher.py +0 -0
  45. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/legacy_sessions.py +0 -0
  46. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/local_clipboard.py +0 -0
  47. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/local_open.py +0 -0
  48. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/models.py +0 -0
  49. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/modes.py +0 -0
  50. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/pane_surface.py +0 -0
  51. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/path_codec.py +0 -0
  52. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/remote_config.py +0 -0
  53. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/renames.py +0 -0
  54. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/runtime_config.py +0 -0
  55. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/scroll_agent.py +0 -0
  56. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/scroll_manager.py +0 -0
  57. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/selection_isolation.py +0 -0
  58. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/self_update.py +0 -0
  59. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/session_cache.py +0 -0
  60. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/session_index.py +0 -0
  61. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/setting_contracts.py +0 -0
  62. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/settings.py +0 -0
  63. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_args.py +0 -0
  64. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_compat.py +0 -0
  65. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_display_diagnostics.py +0 -0
  66. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_doctor.py +0 -0
  67. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/system_deps.py +0 -0
  68. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/terminal_status.py +0 -0
  69. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_ctl.py +0 -0
  70. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_health.py +0 -0
  71. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_server.py +0 -0
  72. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tool_panes.py +0 -0
  73. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/transcript.py +0 -0
  74. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/__init__.py +0 -0
  75. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/_widgets.py +0 -0
  76. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/keymap.py +0 -0
  77. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/modals.py +0 -0
  78. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/projects_pane.py +0 -0
  79. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/running_pane.py +0 -0
  80. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/sessions_pane.py +0 -0
  81. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/sidebar.py +0 -0
  82. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/statusbar.py +0 -0
  83. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/workspace.py +0 -0
  84. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_bootstrap.py +0 -0
  85. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_install_log.py +0 -0
  86. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_pacman.py +0 -0
  87. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/dependency_links.txt +0 -0
  88. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/entry_points.txt +0 -0
  89. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/requires.txt +0 -0
  90. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/top_level.txt +0 -0
  91. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_agent_workspace.py +0 -0
  92. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_atomic_file.py +0 -0
  93. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_background_index.py +0 -0
  94. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_cli.py +0 -0
  95. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_delete.py +0 -0
  96. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_index.py +0 -0
  97. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_lineage.py +0 -0
  98. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_transcript.py +0 -0
  99. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_config.py +0 -0
  100. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_config_cli.py +0 -0
  101. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_denested_transport_benchmark.py +0 -0
  102. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_diagnostics.py +0 -0
  103. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_discovery.py +0 -0
  104. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_display_transport.py +0 -0
  105. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fast_full_window.py +0 -0
  106. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_focus_highlight.py +0 -0
  107. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fullscreen_binding.py +0 -0
  108. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fuzzy.py +0 -0
  109. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_help_agent.py +0 -0
  110. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_help_workspace.py +0 -0
  111. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_history_cleanup.py +0 -0
  112. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_keymap.py +0 -0
  113. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_launcher.py +0 -0
  114. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_layout_profile.py +0 -0
  115. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_legacy_sessions.py +0 -0
  116. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_local_clipboard.py +0 -0
  117. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_local_open.py +0 -0
  118. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_modals.py +0 -0
  119. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_mode_selection.py +0 -0
  120. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_modes.py +0 -0
  121. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_mouse_manager.py +0 -0
  122. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_new_project.py +0 -0
  123. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_options.py +0 -0
  124. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_orphan_recovery.py +0 -0
  125. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_pane_surface.py +0 -0
  126. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_path_codec.py +0 -0
  127. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_projects_pane.py +0 -0
  128. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_release_notes.py +0 -0
  129. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_release_workflow.py +0 -0
  130. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_remote_config.py +0 -0
  131. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_renames.py +0 -0
  132. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_running_pane.py +0 -0
  133. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_running_sort.py +0 -0
  134. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_runtime_config.py +0 -0
  135. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_scroll_agent.py +0 -0
  136. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_scroll_manager.py +0 -0
  137. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_selection_isolation.py +0 -0
  138. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_self_update.py +0 -0
  139. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_session_cache.py +0 -0
  140. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_session_index.py +0 -0
  141. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_sessions_pane.py +0 -0
  142. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_setting_contracts.py +0 -0
  143. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_settings.py +0 -0
  144. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_sidebar.py +0 -0
  145. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_args.py +0 -0
  146. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_compat.py +0 -0
  147. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_display_benchmark.py +0 -0
  148. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_display_diagnostics.py +0 -0
  149. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_doctor.py +0 -0
  150. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_status.py +0 -0
  151. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_statusbar.py +0 -0
  152. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_system_deps.py +0 -0
  153. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_terminal_size.py +0 -0
  154. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_terminal_status.py +0 -0
  155. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_termux_local_touch.py +0 -0
  156. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_binding_manager.py +0 -0
  157. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_ctl.py +0 -0
  158. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_health.py +0 -0
  159. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_integration.py +0 -0
  160. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_server.py +0 -0
  161. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_status.py +0 -0
  162. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tool_panes.py +0 -0
  163. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_transcript.py +0 -0
  164. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_utf8_state.py +0 -0
  165. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_web_demo_recorder.py +0 -0
  166. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_wheel_smoke.py +0 -0
  167. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_widgets.py +0 -0
  168. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_bootstrap.py +0 -0
  169. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_install_log.py +0 -0
  170. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_pacman.py +0 -0
  171. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_wrapper_contract.py +0 -0
  172. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_yolo_prompt.py +0 -0
  173. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/denested_transport_benchmark.py +0 -0
  174. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/release_notes.py +0 -0
  175. {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev9
3
+ Version: 0.4.0.dev10
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev10"
@@ -20,6 +20,7 @@ from railmux.runtime_config import (
20
20
  )
21
21
  from railmux import tmux_health
22
22
  from railmux import tmux_server
23
+ from railmux import windows_tmux_lifecycle
23
24
  from railmux.system_deps import ensure_tmux_available
24
25
  from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
25
26
 
@@ -126,7 +127,16 @@ def _run_tmux_client_with_watchdog(
126
127
  file=sys.stderr,
127
128
  )
128
129
  return 2
130
+ # tmux may leave the client TTY in raw mode even after its process
131
+ # exits. Restore it before a bounded recovery probe or user message.
132
+ _restore_terminal(attributes)
129
133
  returncode = process.returncode or 0
134
+ if windows_tmux_lifecycle.recover_abandoned_socket():
135
+ print(
136
+ "info: cleaned an abandoned Windows tmux socket; Codex and "
137
+ "Claude session files were not changed",
138
+ file=sys.stderr,
139
+ )
130
140
  if returncode and expected_target is not None:
131
141
  try:
132
142
  current_target = tmux_server.discover_target(timeout=1.0)
@@ -367,6 +377,12 @@ def main(argv: list[str] | None = None) -> int:
367
377
  # Validate before any command can address a tmux server. ``tmux -V``
368
378
  # above is server-independent.
369
379
  tmux_server.socket_label()
380
+ if windows_tmux_lifecycle.recover_abandoned_socket():
381
+ print(
382
+ "info: cleaned an abandoned Windows tmux socket; Codex and "
383
+ "Claude session files were not changed",
384
+ file=sys.stderr,
385
+ )
370
386
  dedicated_target = tmux_server.discover_target()
371
387
  on_dedicated_server = tmux_server.is_current_server(dedicated_target)
372
388
  except tmux_server.TmuxServerError as exc:
@@ -25,7 +25,7 @@ from railmux.models import (
25
25
  Project,
26
26
  SessionMeta,
27
27
  )
28
- from railmux.provider_paths import provider_path
28
+ from railmux.provider_paths import private_mode_is_safe, provider_path
29
29
  from railmux.renames import Renames
30
30
 
31
31
  # Codex has no reliable provider-neutral signal that distinguishes a long
@@ -461,7 +461,7 @@ class CodexIndex:
461
461
  if (
462
462
  not stat.S_ISREG(info.st_mode)
463
463
  or info.st_uid != os.getuid()
464
- or info.st_mode & 0o077
464
+ or not private_mode_is_safe(info.st_mode)
465
465
  or info.st_size > _PERSISTENT_CACHE_MAX_BYTES
466
466
  ):
467
467
  return
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from railmux.atomic_file import atomic_write_text
11
11
  from railmux.models import Project
12
12
  from railmux.path_codec import decode, is_encoded_project_name
13
+ from railmux.provider_paths import private_mode_is_safe
13
14
  from railmux.session_index import _looks_like_uuid, _scan_session
14
15
 
15
16
 
@@ -55,7 +56,7 @@ def _load_persistent_validity(claude_home: Path) -> None:
55
56
  not stat.S_ISREG(info.st_mode)
56
57
  or info.st_uid != os.getuid()
57
58
  or info.st_size > _VALIDITY_CACHE_LIMIT
58
- or info.st_mode & 0o077
59
+ or not private_mode_is_safe(info.st_mode)
59
60
  ):
60
61
  return
61
62
  raw = json.loads(path.read_text(encoding="utf-8"))
@@ -1493,7 +1493,7 @@ def _acquire_display_lock(
1493
1493
  if (
1494
1494
  not stat.S_ISREG(info.st_mode)
1495
1495
  or info.st_uid != os.getuid()
1496
- or info.st_mode & 0o077
1496
+ or not restart_state.private_mode_is_safe(info.st_mode)
1497
1497
  ):
1498
1498
  raise OSError("unsafe display lock")
1499
1499
  deadline = time.monotonic() + max(0.0, timeout)
@@ -60,7 +60,7 @@ class RootWheelForwardingManager:
60
60
  info = self._state_path.lstat()
61
61
  if (not stat.S_ISREG(info.st_mode)
62
62
  or info.st_uid != os.getuid()
63
- or info.st_mode & 0o077
63
+ or not restart_state.private_mode_is_safe(info.st_mode)
64
64
  or info.st_size > _MAX_STATE_BYTES):
65
65
  return None
66
66
  raw = json.loads(self._state_path.read_text(encoding="utf-8"))
@@ -231,7 +231,7 @@ def claim_owner(
231
231
  parent = lock_path.parent.stat()
232
232
  if (not stat.S_ISDIR(parent.st_mode)
233
233
  or parent.st_uid != os.getuid()
234
- or parent.st_mode & 0o077):
234
+ or not restart_state.private_mode_is_safe(parent.st_mode)):
235
235
  return None
236
236
  flags = os.O_CREAT | os.O_RDWR
237
237
  if hasattr(os, "O_NOFOLLOW"):
@@ -243,7 +243,7 @@ def claim_owner(
243
243
  info = os.fstat(fd)
244
244
  if (not stat.S_ISREG(info.st_mode)
245
245
  or info.st_uid != os.getuid()
246
- or info.st_mode & 0o077):
246
+ or not restart_state.private_mode_is_safe(info.st_mode)):
247
247
  return None
248
248
  try:
249
249
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -1,13 +1,17 @@
1
1
  """Translate provider-owned native Windows paths inside the MSYS2 preview."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import os
5
6
  import re
7
+ import stat
8
+ import sys
6
9
  from collections.abc import Mapping
7
10
  from pathlib import Path
8
11
 
9
12
 
10
13
  _DRIVE_PATH = re.compile(r"^(?:\\\\\?\\)?([A-Za-z]):[\\/](.*)\Z")
14
+ _MANAGED_RUNTIME_MARKER = Path("/railmux-runtime.json")
11
15
 
12
16
 
13
17
  def running_in_windows_wrapper(environ: Mapping[str, str] = os.environ) -> bool:
@@ -15,6 +19,41 @@ def running_in_windows_wrapper(environ: Mapping[str, str] = os.environ) -> bool:
15
19
  return environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
16
20
 
17
21
 
22
+ def running_in_managed_windows_wrapper(
23
+ environ: Mapping[str, str] = os.environ,
24
+ ) -> bool:
25
+ """Verify the installed MSYS2 runtime, not merely its environment hint."""
26
+ if sys.platform != "cygwin" or not running_in_windows_wrapper(environ):
27
+ return False
28
+ marker = _MANAGED_RUNTIME_MARKER
29
+ try:
30
+ info = marker.lstat()
31
+ if (
32
+ not stat.S_ISREG(info.st_mode)
33
+ or info.st_uid != os.getuid()
34
+ or info.st_mode & 0o022
35
+ or info.st_size > 4096
36
+ ):
37
+ return False
38
+ payload = json.loads(marker.read_text(encoding="utf-8"))
39
+ except (OSError, UnicodeError, json.JSONDecodeError):
40
+ return False
41
+ from railmux import __version__
42
+ runtime_id = environ.get("RAILMUX_MSYS2_RUNTIME_ID")
43
+ return isinstance(runtime_id, str) and bool(runtime_id) and payload == {
44
+ "schema": 1,
45
+ "runtime": runtime_id,
46
+ "railmux": __version__,
47
+ }
48
+
49
+
50
+ def private_mode_is_safe(mode: int) -> bool:
51
+ """Accept strict POSIX modes or the verified MSYS2 noacl projection."""
52
+ return not mode & 0o077 or (
53
+ running_in_managed_windows_wrapper() and not mode & 0o022
54
+ )
55
+
56
+
18
57
  def provider_path(
19
58
  raw: str,
20
59
  *,
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from railmux import tmux_ctl
18
18
  from railmux.atomic_file import atomic_write_text
19
19
  from railmux.config import default_config_path
20
+ from railmux.provider_paths import private_mode_is_safe
20
21
 
21
22
 
22
23
  SCHEMA_VERSION = 1
@@ -164,10 +165,15 @@ def legacy_state_path() -> Path:
164
165
  def _ensure_private_dir(path: Path) -> None:
165
166
  """Create and verify a Railmux-owned 0700 runtime directory."""
166
167
  path.mkdir(mode=0o700, parents=True, exist_ok=True)
167
- info = path.stat()
168
+ info = path.lstat()
169
+ # MSYS2's default noacl projection reports user-owned NTFS directories as
170
+ # 0755 even after chmod(0700). The managed runtime itself lives below the
171
+ # Windows user's LocalAppData ACL. Accept that projection only on the real
172
+ # Cygwin/MSYS Python platform, and still reject symlinks, foreign owners,
173
+ # and any group/world-writable directory. POSIX platforms retain 0700.
168
174
  if (not stat.S_ISDIR(info.st_mode)
169
175
  or info.st_uid != os.getuid()
170
- or info.st_mode & 0o077):
176
+ or not private_mode_is_safe(info.st_mode)):
171
177
  raise OSError("Railmux runtime state directory is not private")
172
178
 
173
179
 
@@ -85,7 +85,7 @@ class SharedTmuxBindingManager:
85
85
  info = self._state_path.lstat()
86
86
  if (not stat.S_ISREG(info.st_mode)
87
87
  or info.st_uid != os.getuid()
88
- or info.st_mode & 0o077
88
+ or not restart_state.private_mode_is_safe(info.st_mode)
89
89
  or info.st_size > _MAX_STATE_BYTES):
90
90
  return None
91
91
  raw = json.loads(self._state_path.read_text(encoding="utf-8"))
@@ -25,6 +25,7 @@ from railmux import (
25
25
  tmux_ctl,
26
26
  tmux_health,
27
27
  tmux_server,
28
+ windows_tmux_lifecycle,
28
29
  )
29
30
  from railmux.atomic_file import atomic_write_text
30
31
  from railmux.background_index import BackgroundCodexIndex
@@ -713,6 +714,9 @@ class App:
713
714
  # digest plus immutable pane id namespaces local recovery state across
714
715
  # windows, sessions, and private tmux servers.
715
716
  self._restart_identity = restart_state.capture_outer_identity()
717
+ # A newly live UI revokes any cleanup proof from the preceding
718
+ # lifecycle before it can create provider sessions.
719
+ windows_tmux_lifecycle.clear_empty_server_exit()
716
720
  self._claude_home = claude_home
717
721
  self._config = config
718
722
  self._auto_launched = auto_launched
@@ -8061,10 +8065,25 @@ class App:
8061
8065
  if defer_outer or getattr(self, "_outer_teardown_done", False):
8062
8066
  return
8063
8067
  self._outer_teardown_done = True
8068
+ identity = getattr(self, "_restart_identity", None)
8069
+ if (
8070
+ self._auto_launched
8071
+ and identity is not None
8072
+ and running_in_windows_wrapper()
8073
+ and tmux_ctl.current_session_name() == "railmux"
8074
+ and tmux_ctl.current_session_id() == identity.session_id
8075
+ ):
8076
+ # MSYS2 can retain an AF_UNIX pathname after its last session
8077
+ # exits. Arm cleanup only when the server-wide immutable-ID
8078
+ # inventory proves that no provider or unknown session exists.
8079
+ windows_tmux_lifecycle.arm_empty_server_exit(
8080
+ server_pid=identity.server_pid,
8081
+ session_id=identity.session_id,
8082
+ pane_id=identity.pane_id,
8083
+ )
8064
8084
  if not self._soft_quit_flag and self._auto_launched:
8065
8085
  session_name = tmux_ctl.current_session_name()
8066
8086
  if session_name == "railmux":
8067
- identity = getattr(self, "_restart_identity", None)
8068
8087
  current_id = tmux_ctl.current_session_id()
8069
8088
  intent = bool(
8070
8089
  identity is not None
@@ -8080,6 +8099,7 @@ class App:
8080
8099
  killed = False
8081
8100
  if intent and not killed:
8082
8101
  tmux_health.clear_clean_exit()
8102
+ windows_tmux_lifecycle.clear_empty_server_exit()
8083
8103
 
8084
8104
  def _enter_filter_mode(self) -> None:
8085
8105
  # Borrow the button row (footer index 1) for a filter Edit — both are a
@@ -414,6 +414,10 @@ class Msys2Runtime:
414
414
  # providers, whose POSIX cwd arguments do require MSYS conversion.
415
415
  child["MSYS2_ARG_CONV_EXCL"] = "*"
416
416
  child["RAILMUX_WINDOWS_RUNTIME"] = "msys2"
417
+ if self.managed:
418
+ # The MSYS-side privacy exception independently verifies the
419
+ # matching on-disk runtime marker before trusting this identifier.
420
+ child["RAILMUX_MSYS2_RUNTIME_ID"] = MSYS2_RUNTIME_ID
417
421
  # Native PowerShell commonly has no TERM; OpenSSH-hosted PowerShell
418
422
  # can explicitly inherit ``dumb``, which makes tmux reject attach.
419
423
  if not child.get("TERM") or child.get("TERM") == "dumb":
@@ -0,0 +1,259 @@
1
+ """Fail-closed cleanup for abandoned MSYS2 tmux socket files.
2
+
3
+ MSYS2 tmux can leave its AF_UNIX socket behind after the last session exits.
4
+ The next tmux client then waits on an endpoint with no listener. Cleanup is
5
+ authorized only while the live server proves that Railmux's outer UI is its
6
+ sole session; provider histories are never opened or modified here.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import errno
11
+ import json
12
+ import os
13
+ import socket
14
+ import stat
15
+ import time
16
+ from dataclasses import asdict, dataclass
17
+ from pathlib import Path
18
+
19
+ from railmux import restart_state, tmux_server
20
+ from railmux.atomic_file import atomic_write_text
21
+ from railmux.provider_paths import running_in_windows_wrapper
22
+
23
+
24
+ _SCHEMA_VERSION = 1
25
+ _KIND = "windows-empty-tmux-exit"
26
+ _EXIT_SETTLE_NS = 500_000_000
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _SocketIdentity:
31
+ socket_dev: int
32
+ socket_ino: int
33
+ socket_ctime_ns: int
34
+ parent_dev: int
35
+ parent_ino: int
36
+
37
+
38
+ def _marker_name(label: str) -> str:
39
+ return f"windows-empty-tmux-exit-{label}.json"
40
+
41
+
42
+ def _expected_socket_path(label: str) -> Path | None:
43
+ root = Path(os.environ.get("TMUX_TMPDIR", "/tmp"))
44
+ if not root.is_absolute() or ".." in root.parts:
45
+ return None
46
+ return root / f"tmux-{os.getuid()}" / label
47
+
48
+
49
+ def _socket_identity(path: Path) -> _SocketIdentity | None:
50
+ """Describe one same-user socket without following either final path."""
51
+ try:
52
+ parent = path.parent.lstat()
53
+ endpoint = path.lstat()
54
+ except OSError:
55
+ return None
56
+ if (
57
+ not stat.S_ISDIR(parent.st_mode)
58
+ or stat.S_ISLNK(parent.st_mode)
59
+ or parent.st_uid != os.getuid()
60
+ or parent.st_mode & 0o022
61
+ or not stat.S_ISSOCK(endpoint.st_mode)
62
+ or endpoint.st_uid != os.getuid()
63
+ or endpoint.st_mode & 0o022
64
+ ):
65
+ return None
66
+ return _SocketIdentity(
67
+ socket_dev=endpoint.st_dev,
68
+ socket_ino=endpoint.st_ino,
69
+ socket_ctime_ns=endpoint.st_ctime_ns,
70
+ parent_dev=parent.st_dev,
71
+ parent_ino=parent.st_ino,
72
+ )
73
+
74
+
75
+ def arm_empty_server_exit(
76
+ *, server_pid: int, session_id: str, pane_id: str,
77
+ ) -> bool:
78
+ """Authorize later stale-socket removal after an exact empty-server proof."""
79
+ if not running_in_windows_wrapper() or server_pid <= 0:
80
+ return False
81
+ if not session_id.startswith("$") or not session_id[1:].isdigit():
82
+ return False
83
+ if not pane_id.startswith("%") or not pane_id[1:].isdigit():
84
+ return False
85
+ target = tmux_server.current_target()
86
+ if target is None or target.server_pid != server_pid:
87
+ return False
88
+
89
+ # Failure to enumerate every server-wide session denies cleanup.
90
+ from railmux import tmux_ctl
91
+
92
+ snapshot = tmux_ctl.server_snapshot()
93
+ if (
94
+ os.environ.get("TMUX_PANE") != pane_id
95
+ or tmux_ctl.current_session_id() != session_id
96
+ or tmux_ctl.session_ids() != frozenset({session_id})
97
+ or snapshot is None
98
+ or snapshot.sessions != frozenset({"railmux"})
99
+ or snapshot.panes != frozenset({pane_id})
100
+ ):
101
+ return False
102
+ try:
103
+ label = tmux_server.socket_label()
104
+ except tmux_server.TmuxServerError:
105
+ return False
106
+ expected = _expected_socket_path(label)
107
+ if expected is None or Path(target.socket_path) != expected:
108
+ return False
109
+ identity = _socket_identity(expected)
110
+ if identity is None:
111
+ return False
112
+ payload = {
113
+ "schema_version": _SCHEMA_VERSION,
114
+ "kind": _KIND,
115
+ "label": label,
116
+ "server_pid": server_pid,
117
+ "session_id": session_id,
118
+ "pane_id": pane_id,
119
+ "recorded_at_ns": time.time_ns(),
120
+ "socket_path": str(expected),
121
+ **asdict(identity),
122
+ }
123
+ try:
124
+ atomic_write_text(
125
+ restart_state.runtime_state_dir() / _marker_name(label),
126
+ json.dumps(payload, separators=(",", ":"), sort_keys=True),
127
+ encoding="utf-8",
128
+ )
129
+ return True
130
+ except OSError:
131
+ return False
132
+
133
+
134
+ def clear_empty_server_exit() -> None:
135
+ """Revoke an older exit proof once a Railmux UI is live again."""
136
+ if not running_in_windows_wrapper():
137
+ return
138
+ try:
139
+ label = tmux_server.socket_label()
140
+ marker = restart_state.runtime_state_dir() / _marker_name(label)
141
+ marker.unlink(missing_ok=True)
142
+ except (OSError, tmux_server.TmuxServerError):
143
+ pass
144
+
145
+
146
+ def _server_pid_is_gone(server_pid: int) -> bool:
147
+ """Return true only when the recorded MSYS2 process no longer exists."""
148
+ try:
149
+ os.kill(server_pid, 0)
150
+ except ProcessLookupError:
151
+ return True
152
+ except (OSError, ValueError):
153
+ # Permission errors and unsupported/ambiguous pid probes deny cleanup.
154
+ return False
155
+ return False
156
+
157
+
158
+ def recover_abandoned_socket() -> bool:
159
+ """Remove one proven-dead MSYS2 socket; never act on a live endpoint."""
160
+ if not running_in_windows_wrapper() or os.environ.get("TMUX"):
161
+ return False
162
+ try:
163
+ label = tmux_server.socket_label()
164
+ except tmux_server.TmuxServerError:
165
+ return False
166
+ try:
167
+ marker = restart_state.runtime_state_dir() / _marker_name(label)
168
+ except OSError:
169
+ return False
170
+ payload = restart_state.read_json_object(marker)
171
+ required = {
172
+ "schema_version", "kind", "label", "server_pid", "session_id",
173
+ "pane_id", "recorded_at_ns", "socket_path", "socket_dev",
174
+ "socket_ino", "socket_ctime_ns", "parent_dev", "parent_ino",
175
+ }
176
+ if not isinstance(payload, dict) or set(payload) != required:
177
+ return False
178
+ session_id = payload.get("session_id")
179
+ pane_id = payload.get("pane_id")
180
+ identity_fields = (
181
+ "socket_dev", "socket_ino", "socket_ctime_ns", "parent_dev",
182
+ "parent_ino",
183
+ )
184
+ integer_fields = ("server_pid", "recorded_at_ns", *identity_fields)
185
+ if (
186
+ payload.get("schema_version") != _SCHEMA_VERSION
187
+ or payload.get("kind") != _KIND
188
+ or payload.get("label") != label
189
+ or not isinstance(session_id, str)
190
+ or not session_id.startswith("$")
191
+ or not session_id[1:].isdigit()
192
+ or not isinstance(pane_id, str)
193
+ or not pane_id.startswith("%")
194
+ or not pane_id[1:].isdigit()
195
+ or any(
196
+ not isinstance(payload.get(name), int)
197
+ or isinstance(payload.get(name), bool)
198
+ or payload[name] < 0
199
+ for name in integer_fields
200
+ )
201
+ or payload["server_pid"] <= 0
202
+ ):
203
+ return False
204
+ expected = _expected_socket_path(label)
205
+ if expected is None or payload.get("socket_path") != str(expected):
206
+ return False
207
+ recorded = _SocketIdentity(**{
208
+ name: payload[name] for name in identity_fields
209
+ })
210
+ if _socket_identity(expected) != recorded:
211
+ return False
212
+
213
+ def endpoint_state() -> bool | None:
214
+ """True is live, False is absent/unanswered, None is indeterminate."""
215
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
216
+ client.settimeout(0.25)
217
+ try:
218
+ client.connect(str(expected))
219
+ except (socket.timeout, TimeoutError):
220
+ # A stale MSYS2 AF_UNIX pathname times out instead of returning
221
+ # ECONNREFUSED. The prior sole-pane proof is the cleanup authority.
222
+ return False
223
+ except OSError as exc:
224
+ if exc.errno in {errno.ECONNREFUSED, errno.ENOENT}:
225
+ return False
226
+ return None
227
+ else:
228
+ return True
229
+ finally:
230
+ client.close()
231
+
232
+ if endpoint_state() is not False:
233
+ return False
234
+ now_ns = time.time_ns()
235
+ recorded_at_ns = payload["recorded_at_ns"]
236
+ if recorded_at_ns > now_ns:
237
+ return False
238
+ remaining_ns = _EXIT_SETTLE_NS - (now_ns - recorded_at_ns)
239
+ if remaining_ns > 0:
240
+ time.sleep(remaining_ns / 1_000_000_000)
241
+ if (
242
+ _socket_identity(expected) != recorded
243
+ or endpoint_state() is not False
244
+ or not _server_pid_is_gone(payload["server_pid"])
245
+ ):
246
+ return False
247
+
248
+ # Narrow the check/unlink race against pathname replacement. Every normal
249
+ # Railmux launcher runs this recovery before starting tmux, and a changed
250
+ # endpoint is retained. An adversarial same-user process can ignore that
251
+ # convention, but cannot redirect this fixed pathname toward provider data.
252
+ if _socket_identity(expected) != recorded:
253
+ return False
254
+ try:
255
+ expected.unlink()
256
+ marker.unlink(missing_ok=True)
257
+ except OSError:
258
+ return False
259
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev9
3
+ Version: 0.4.0.dev10
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -61,6 +61,7 @@ src/railmux/windows_bootstrap.py
61
61
  src/railmux/windows_install_log.py
62
62
  src/railmux/windows_msys2.py
63
63
  src/railmux/windows_pacman.py
64
+ src/railmux/windows_tmux_lifecycle.py
64
65
  src/railmux.egg-info/PKG-INFO
65
66
  src/railmux.egg-info/SOURCES.txt
66
67
  src/railmux.egg-info/dependency_links.txt
@@ -162,6 +163,7 @@ tests/test_windows_bootstrap.py
162
163
  tests/test_windows_install_log.py
163
164
  tests/test_windows_msys2.py
164
165
  tests/test_windows_pacman.py
166
+ tests/test_windows_tmux_lifecycle.py
165
167
  tests/test_windows_wrapper_contract.py
166
168
  tests/test_yolo_prompt.py
167
169
  tools/denested_transport_benchmark.py
@@ -0,0 +1,88 @@
1
+ import json
2
+ import stat
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+
6
+ from railmux import __version__, provider_paths
7
+ from railmux.provider_paths import (
8
+ private_mode_is_safe,
9
+ provider_path,
10
+ running_in_managed_windows_wrapper,
11
+ )
12
+
13
+
14
+ def test_windows_drive_path_is_visible_through_msys_mount():
15
+ assert provider_path(
16
+ r"C:\Users\用户\project",
17
+ environ={"RAILMUX_WINDOWS_RUNTIME": "msys2"},
18
+ ) == Path("/c/Users/用户/project")
19
+
20
+
21
+ def test_extended_windows_drive_path_is_normalized():
22
+ assert provider_path(
23
+ r"\\?\D:\work\repo",
24
+ environ={"RAILMUX_WINDOWS_RUNTIME": "msys2"},
25
+ ) == Path("/d/work/repo")
26
+
27
+
28
+ def test_posix_runtime_preserves_existing_path_behavior():
29
+ raw = r"C:\Users\Alice\project"
30
+ assert provider_path(raw, environ={}) == Path(raw)
31
+
32
+
33
+ def test_forward_slash_windows_drive_path_is_normalized():
34
+ assert provider_path(
35
+ "E:/workspace/project",
36
+ environ={"RAILMUX_WINDOWS_RUNTIME": "msys2"},
37
+ ) == Path("/e/workspace/project")
38
+
39
+
40
+ def test_noacl_mode_requires_exact_managed_runtime_marker(monkeypatch):
41
+ payload = json.dumps({
42
+ "schema": 1,
43
+ "runtime": "msys2-test",
44
+ "railmux": __version__,
45
+ })
46
+ marker = SimpleNamespace(
47
+ lstat=lambda: SimpleNamespace(
48
+ st_mode=stat.S_IFREG | 0o644,
49
+ st_uid=42,
50
+ st_size=len(payload),
51
+ ),
52
+ read_text=lambda **_kwargs: payload,
53
+ )
54
+ monkeypatch.setattr(provider_paths, "_MANAGED_RUNTIME_MARKER", marker)
55
+ monkeypatch.setattr(provider_paths.sys, "platform", "cygwin")
56
+ monkeypatch.setattr(
57
+ provider_paths.os, "getuid", lambda: 42, raising=False,
58
+ )
59
+ monkeypatch.setenv("RAILMUX_WINDOWS_RUNTIME", "msys2")
60
+ monkeypatch.setenv("RAILMUX_MSYS2_RUNTIME_ID", "msys2-test")
61
+
62
+ assert running_in_managed_windows_wrapper()
63
+ assert private_mode_is_safe(0o100644)
64
+ assert not private_mode_is_safe(0o100666)
65
+
66
+ monkeypatch.setenv("RAILMUX_MSYS2_RUNTIME_ID", "other-runtime")
67
+ assert not running_in_managed_windows_wrapper()
68
+ assert not private_mode_is_safe(0o100644)
69
+
70
+
71
+ def test_managed_runtime_marker_must_be_same_owner(monkeypatch):
72
+ marker = SimpleNamespace(
73
+ lstat=lambda: SimpleNamespace(
74
+ st_mode=stat.S_IFREG | 0o644,
75
+ st_uid=43,
76
+ st_size=2,
77
+ ),
78
+ read_text=lambda **_kwargs: "{}",
79
+ )
80
+ monkeypatch.setattr(provider_paths, "_MANAGED_RUNTIME_MARKER", marker)
81
+ monkeypatch.setattr(provider_paths.sys, "platform", "cygwin")
82
+ monkeypatch.setattr(
83
+ provider_paths.os, "getuid", lambda: 42, raising=False,
84
+ )
85
+ monkeypatch.setenv("RAILMUX_WINDOWS_RUNTIME", "msys2")
86
+ monkeypatch.setenv("RAILMUX_MSYS2_RUNTIME_ID", "msys2-test")
87
+
88
+ assert not running_in_managed_windows_wrapper()