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.
- {railmux-0.4.0.dev9/src/railmux.egg-info → railmux-0.4.0.dev10}/PKG-INFO +1 -1
- railmux-0.4.0.dev10/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/cli.py +16 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/codex_index.py +2 -2
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/discovery.py +2 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_server.py +1 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/mouse_manager.py +1 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/orphan_marker.py +2 -2
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/provider_paths.py +39 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/restart_state.py +8 -2
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_binding_manager.py +1 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/app.py +21 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_msys2.py +4 -0
- railmux-0.4.0.dev10/src/railmux/windows_tmux_lifecycle.py +259 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/SOURCES.txt +2 -0
- railmux-0.4.0.dev10/tests/test_provider_paths.py +88 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_restart_state.py +30 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_soft_quit.py +6 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_msys2.py +11 -5
- railmux-0.4.0.dev10/tests/test_windows_tmux_lifecycle.py +140 -0
- railmux-0.4.0.dev9/src/railmux/__init__.py +0 -1
- railmux-0.4.0.dev9/tests/test_provider_paths.py +0 -29
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/LICENSE +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/README.md +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/pyproject.toml +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/setup.cfg +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_install_log.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_integration.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_install_log.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev9 → railmux-0.4.0.dev10}/tools/wheel_smoke.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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()
|