shell-session-manager 2.2.0__tar.gz → 2.2.1__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 (33) hide show
  1. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/PKG-INFO +1 -1
  2. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/pyproject.toml +1 -1
  3. shell_session_manager-2.2.1/src/shell_session_manager/shellctl/sanitize_pty.py +39 -0
  4. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -2
  5. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/artifacts.py +10 -2
  6. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/cli.py +7 -15
  7. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/config.py +12 -2
  8. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/service.py +81 -12
  9. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/tmux.py +7 -4
  10. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_service.py +158 -16
  11. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/LICENSE +0 -0
  12. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/README.md +0 -0
  13. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py +0 -0
  14. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed +0 -0
  15. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py +0 -0
  16. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
  17. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  18. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
  19. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  20. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
  21. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
  22. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  23. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  24. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  25. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  26. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  27. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  28. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
  29. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json +0 -0
  30. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py +0 -0
  31. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_manager.py +0 -0
  32. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_client.py +0 -0
  33. {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shell-session-manager
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
5
5
  Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
6
6
  License-Expression: Apache-2.0
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "shell-session-manager"
9
- version = "2.2.0"
9
+ version = "2.2.1"
10
10
  description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
11
11
  authors = [
12
12
  { name = "Yanli 盐粒", email = "yanli@mail.one" },
@@ -0,0 +1,39 @@
1
+ """Minimal PTY sanitizer entrypoint used by tmux `pipe-pane`.
2
+
3
+ This module intentionally stays isolated from the FastAPI/SQLite server stack
4
+ so the tmux-side subprocess can touch its ready-file quickly and emit useful
5
+ stderr when startup fails before any PTY output is drained.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from shell_session_manager.shellctl.shared.sanitize import sanitize_pty_stream
13
+
14
+
15
+ def parse_args():
16
+ """Parse the tiny CLI contract used by tmux `pipe-pane`."""
17
+
18
+ parser = argparse.ArgumentParser(description=__doc__)
19
+ parser.add_argument("--ready-file", type=Path)
20
+ return parser.parse_args()
21
+
22
+
23
+ def run_sanitize_pty(ready_file: Path | None) -> None:
24
+ """Touch the ready file, then sanitize stdin into stdout."""
25
+
26
+ if ready_file is not None:
27
+ ready_file.touch()
28
+ sanitize_pty_stream(sys.stdin.buffer, sys.stdout.buffer)
29
+
30
+
31
+ def main() -> None:
32
+ """Run the standalone PTY sanitizer module."""
33
+
34
+ args = parse_args()
35
+ run_sanitize_pty(args.ready_file)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -11,7 +11,6 @@ from shell_session_manager.shellctl.server.cli import (
11
11
  cli,
12
12
  main,
13
13
  runner_exit_command,
14
- sanitize_pty_command,
15
14
  serve_command,
16
15
  )
17
16
  from shell_session_manager.shellctl.server.config import ShellctlConfig
@@ -28,6 +27,5 @@ __all__ = [
28
27
  "create_app",
29
28
  "main",
30
29
  "runner_exit_command",
31
- "sanitize_pty_command",
32
30
  "serve_command",
33
31
  ]
@@ -5,8 +5,9 @@ Normal job completion is coordinated through small marker files inside each
5
5
  SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
6
6
  drained into `output.log`. The same artifact directory also stores the request's
7
7
  environment overlay so the runner can merge arbitrary key/value pairs without
8
- shell-escaping them into the generated script. A separate failure marker is used
9
- so an unsuccessful sanitizer run does not masquerade as a drained normal exit.
8
+ shell-escaping them into the generated script. Separate failure markers and a
9
+ dedicated `pipe-error.log` stderr capture keep startup diagnostics available
10
+ when the sanitizer never reaches its ready-file handshake.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -18,6 +19,7 @@ RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
18
19
  JOB_ENV_FILENAME = ".job-env.json"
19
20
  PIPE_DRAINED_FILENAME = ".pipe-drained"
20
21
  PIPE_FAILED_FILENAME = ".pipe-failed"
22
+ PIPE_ERROR_LOG_FILENAME = "pipe-error.log"
21
23
 
22
24
 
23
25
  def runner_exit_code_path(job_dir: Path) -> Path:
@@ -40,14 +42,20 @@ def pipe_failed_path(job_dir: Path) -> Path:
40
42
  return job_dir / PIPE_FAILED_FILENAME
41
43
 
42
44
 
45
+ def pipe_error_log_path(job_dir: Path) -> Path:
46
+ return job_dir / PIPE_ERROR_LOG_FILENAME
47
+
48
+
43
49
  __all__ = [
44
50
  "JOB_ENV_FILENAME",
45
51
  "PIPE_DRAINED_FILENAME",
52
+ "PIPE_ERROR_LOG_FILENAME",
46
53
  "PIPE_FAILED_FILENAME",
47
54
  "RUNNER_ENDED_AT_FILENAME",
48
55
  "RUNNER_EXIT_CODE_FILENAME",
49
56
  "job_env_path",
50
57
  "pipe_drained_path",
58
+ "pipe_error_log_path",
51
59
  "pipe_failed_path",
52
60
  "runner_ended_at_path",
53
61
  "runner_exit_code_path",
@@ -1,8 +1,13 @@
1
- """Typer CLI entrypoints for shellctl server package."""
1
+ """Typer CLI entrypoints for the shellctl server package.
2
+
3
+ The server CLI only exposes commands that need the FastAPI/SQLite runtime.
4
+ The PTY sanitizer now lives in `shell_session_manager.shellctl.sanitize_pty`
5
+ as a separate lightweight module so tmux pipes do not have a second, heavier
6
+ entry path to maintain.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
- import sys
6
11
  from pathlib import Path
7
12
 
8
13
  import anyio
@@ -17,7 +22,6 @@ from shell_session_manager.shellctl.shared import (
17
22
  DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
18
23
  DEFAULT_GC_INTERVAL_SECONDS,
19
24
  default_state_dir,
20
- sanitize_pty_stream,
21
25
  )
22
26
 
23
27
  cli = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
@@ -54,17 +58,6 @@ def serve_command(
54
58
  uvicorn.run(create_app(config), host=host, port=port, log_level="info")
55
59
 
56
60
 
57
- @cli.command("sanitize-pty")
58
- def sanitize_pty_command(
59
- ready_file: Path | None = typer.Option(None, "--ready-file"),
60
- ) -> None:
61
- """Read raw PTY bytes from stdin and write sanitized UTF-8 text to stdout."""
62
-
63
- if ready_file is not None:
64
- ready_file.touch()
65
- sanitize_pty_stream(sys.stdin.buffer, sys.stdout.buffer)
66
-
67
-
68
61
  @cli.command("runner-exit")
69
62
  def runner_exit_command(
70
63
  state_dir: Path = typer.Option(..., "--state-dir"),
@@ -107,6 +100,5 @@ __all__ = [
107
100
  "cli",
108
101
  "main",
109
102
  "runner_exit_command",
110
- "sanitize_pty_command",
111
103
  "serve_command",
112
104
  ]
@@ -32,8 +32,10 @@ class ShellctlConfig:
32
32
  """Runtime configuration for the shellctl service and CLI.
33
33
 
34
34
  `shellctl_command` deliberately defaults to `python -m ...server` so the
35
- tmux-side commands stay pinned to the same interpreter environment that
36
- launched the API server.
35
+ tmux-side `runner-exit` callback stays pinned to the same interpreter
36
+ environment that launched the API server. `sanitize_pty_command` uses the
37
+ same interpreter but points at a lightweight module so the pipe ready-file
38
+ handshake does not pay FastAPI/SQLAlchemy import costs.
37
39
 
38
40
  Bearer auth is opt-in: if the explicit `auth_token` and the fallback
39
41
  `SHELLCTL_AUTH_TOKEN` environment variable are both missing or empty,
@@ -59,6 +61,7 @@ class ShellctlConfig:
59
61
  default_terminate_grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS
60
62
  poll_interval_seconds: float = 0.05
61
63
  pipe_monitor_interval_seconds: float = 1.0
64
+ pipe_ready_timeout_seconds: float = 10.0
62
65
  sqlite_busy_timeout_ms: int = 5000
63
66
  shellctl_command: tuple[str, ...] = field(
64
67
  default_factory=lambda: (
@@ -67,6 +70,13 @@ class ShellctlConfig:
67
70
  "shell_session_manager.shellctl.server",
68
71
  )
69
72
  )
73
+ sanitize_pty_command: tuple[str, ...] = field(
74
+ default_factory=lambda: (
75
+ sys.executable,
76
+ "-m",
77
+ "shell_session_manager.shellctl.sanitize_pty",
78
+ )
79
+ )
70
80
 
71
81
  def __post_init__(self) -> None:
72
82
  if self.runtime_dir is None:
@@ -30,6 +30,7 @@ from shell_session_manager.shellctl.server.artifacts import (
30
30
  RUNNER_EXIT_CODE_FILENAME,
31
31
  job_env_path,
32
32
  pipe_drained_path,
33
+ pipe_error_log_path,
33
34
  pipe_failed_path,
34
35
  runner_ended_at_path,
35
36
  runner_exit_code_path,
@@ -559,10 +560,10 @@ class ShellctlService:
559
560
  ) -> None:
560
561
  """Persist a drained normal-exit fact into SQLite.
561
562
 
562
- The usual caller is the tmux pipe finalizer after `sanitize-pty` has
563
- reached EOF and flushed `output.log`. `_materialize_status_view()` may
564
- also call this as a recovery path when `.pipe-drained` plus the normal
565
- exit metadata files exist but SQLite is still non-terminal. The
563
+ The usual caller is the tmux pipe finalizer after the lightweight PTY
564
+ sanitizer reaches EOF and flushes `output.log`. `_materialize_status_view()`
565
+ may also call this as a recovery path when `.pipe-drained` plus the
566
+ normal exit metadata files exist but SQLite is still non-terminal. The
566
567
  statement always records `exit_code` and `ended_at`, but it only changes
567
568
  `status` to `exited` when the current row is still non-terminal.
568
569
  """
@@ -635,13 +636,17 @@ class ShellctlService:
635
636
  async def _wait_for_output_pipe_ready(
636
637
  self, *, job_id: str, ready_file: Path
637
638
  ) -> None:
638
- """Confirm the sanitize/output pipeline is live before opening start-gate."""
639
+ """Confirm the sanitize/output pipeline is live before opening start-gate.
639
640
 
640
- deadline = anyio.current_time() + max(
641
- self.config.poll_interval_seconds * 10,
642
- self.config.pipe_monitor_interval_seconds,
643
- )
641
+ Timeout failures include the ready-file path, current `#{pane_pipe}`
642
+ state, and a summary of `pipe-error.log` so operators can tell the
643
+ difference between slow startup, tmux pipe loss, and sanitizer crashes.
644
+ """
645
+
646
+ started_at = anyio.current_time()
647
+ deadline = started_at + self.config.pipe_ready_timeout_seconds
644
648
  while True:
649
+ waited_seconds = anyio.current_time() - started_at
645
650
  if ready_file.exists():
646
651
  pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
647
652
  if pipe_state is True:
@@ -650,23 +655,87 @@ class ShellctlService:
650
655
  raise ShellctlServerError(
651
656
  500,
652
657
  "pipe_failed",
653
- f"Output pipe never became ready for {job_id}",
658
+ self._pipe_ready_failure_message(
659
+ job_id=job_id,
660
+ ready_file=ready_file,
661
+ waited_seconds=waited_seconds,
662
+ pipe_state=pipe_state,
663
+ cause="tmux pane disappeared after the ready-file handshake",
664
+ ),
654
665
  )
655
666
  else:
656
667
  if not await self._tmux.session_exists(job_session_name(job_id)):
668
+ pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
657
669
  raise ShellctlServerError(
658
670
  500,
659
671
  "pipe_failed",
660
- f"Output pipe never became ready for {job_id}",
672
+ self._pipe_ready_failure_message(
673
+ job_id=job_id,
674
+ ready_file=ready_file,
675
+ waited_seconds=waited_seconds,
676
+ pipe_state=pipe_state,
677
+ cause="tmux session exited before the ready-file handshake",
678
+ ),
661
679
  )
662
680
  if anyio.current_time() >= deadline:
681
+ pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
663
682
  raise ShellctlServerError(
664
683
  500,
665
684
  "pipe_failed",
666
- f"Output pipe never became ready for {job_id}",
685
+ self._pipe_ready_failure_message(
686
+ job_id=job_id,
687
+ ready_file=ready_file,
688
+ waited_seconds=waited_seconds,
689
+ pipe_state=pipe_state,
690
+ cause="timed out waiting for the sanitize/output pipeline handshake",
691
+ ),
667
692
  )
668
693
  await anyio.sleep(self.config.poll_interval_seconds)
669
694
 
695
+ def _pipe_ready_failure_message(
696
+ self,
697
+ *,
698
+ job_id: str,
699
+ ready_file: Path,
700
+ waited_seconds: float,
701
+ pipe_state: bool | None,
702
+ cause: str,
703
+ ) -> str:
704
+ """Build a startup error message with enough pipe diagnostics to debug."""
705
+
706
+ return (
707
+ f"Output pipe never became ready for {job_id}: {cause}; "
708
+ f"waited {waited_seconds:.3f}s; "
709
+ f"ready-file={ready_file}; "
710
+ f"tmux #{{pane_pipe}}={self._format_pane_pipe_state(pipe_state)}; "
711
+ f"pipe-error.log={self._pipe_error_log_summary(ready_file.parent)}"
712
+ )
713
+
714
+ def _format_pane_pipe_state(self, pipe_state: bool | None) -> str:
715
+ if pipe_state is True:
716
+ return "1"
717
+ if pipe_state is False:
718
+ return "0"
719
+ return "pane-missing"
720
+
721
+ def _pipe_error_log_summary(self, job_dir: Path) -> str:
722
+ """Summarize sanitizer stderr without flooding API error responses."""
723
+
724
+ error_log = pipe_error_log_path(job_dir)
725
+ if not error_log.exists():
726
+ return f"{error_log} missing"
727
+ stderr_text = error_log.read_text(encoding="utf-8", errors="replace").strip()
728
+ if not stderr_text:
729
+ return f"{error_log} empty"
730
+ summary = " | ".join(
731
+ line.strip() for line in stderr_text.splitlines() if line.strip()
732
+ )
733
+ if not summary:
734
+ return f"{error_log} contains only whitespace"
735
+ if len(summary) > 240:
736
+ summary = f"{summary[:237]}..."
737
+ return f"{error_log}: {summary}"
738
+
670
739
  async def _materialize_status_view(
671
740
  self,
672
741
  job_id: str,
@@ -18,6 +18,7 @@ import anyio
18
18
 
19
19
  from shell_session_manager.shellctl.server.artifacts import (
20
20
  pipe_drained_path,
21
+ pipe_error_log_path,
21
22
  pipe_failed_path,
22
23
  runner_ended_at_path,
23
24
  runner_exit_code_path,
@@ -182,13 +183,14 @@ class TmuxController:
182
183
 
183
184
  For normal exits, the runner now records completion metadata into job
184
185
  artifacts and the pipe finalizer commits `runner-exit` only after
185
- `sanitize-pty` reaches EOF and flushes `output.log` successfully.
186
+ the lightweight sanitizer reaches EOF and flushes `output.log`
187
+ successfully. Sanitizer stderr is captured into `pipe-error.log` so
188
+ startup timeouts can distinguish slow imports from subprocess crashes.
186
189
  """
187
190
 
188
191
  sanitize_command = self._shell_join(
189
192
  (
190
- *self._config.shellctl_command,
191
- "sanitize-pty",
193
+ *self._config.sanitize_pty_command,
192
194
  "--ready-file",
193
195
  str(ready_file),
194
196
  )
@@ -205,12 +207,13 @@ class TmuxController:
205
207
  )
206
208
  output_path = shlex.quote(str(job_dir / "output.log"))
207
209
  drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
210
+ error_log_path = shlex.quote(str(pipe_error_log_path(job_dir)))
208
211
  failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
209
212
  exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
210
213
  ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
211
214
  return " ; ".join(
212
215
  [
213
- f"{sanitize_command} >> {output_path}",
216
+ f"{sanitize_command} >> {output_path} 2> {error_log_path}",
214
217
  "sanitize_status=$?",
215
218
  (
216
219
  'if [ "$sanitize_status" -eq 0 ]; then '
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
3
4
  import importlib
4
5
  import json
5
6
  import os
@@ -29,10 +30,12 @@ from shell_session_manager.shellctl.server import (
29
30
  from shell_session_manager.shellctl.server.artifacts import (
30
31
  JOB_ENV_FILENAME,
31
32
  PIPE_DRAINED_FILENAME,
33
+ PIPE_ERROR_LOG_FILENAME,
32
34
  PIPE_FAILED_FILENAME,
33
35
  RUNNER_ENDED_AT_FILENAME,
34
36
  RUNNER_EXIT_CODE_FILENAME,
35
37
  job_env_path,
38
+ pipe_error_log_path,
36
39
  )
37
40
  from shell_session_manager.shellctl.server.tmux import TmuxController
38
41
  from shell_session_manager.shellctl.shared import (
@@ -57,6 +60,8 @@ class FakeTmuxController:
57
60
  self.pipe_active: dict[str, bool | None] = {}
58
61
  self.cleaned: list[str] = []
59
62
  self.touch_ready_on_enable = True
63
+ self.pipe_active_on_enable: bool | None = True
64
+ self.pipe_error_log_text: str | None = None
60
65
  self.on_send_interrupt: Callable[[str], Awaitable[None]] | None = None
61
66
  self.on_send_input: Callable[[str, str], Awaitable[None]] | None = None
62
67
 
@@ -82,8 +87,12 @@ class FakeTmuxController:
82
87
  async def enable_output_pipe(
83
88
  self, *, job_id: str, job_dir: Path, ready_file: Path
84
89
  ) -> None:
85
- del job_dir
86
- self.pipe_active[job_id] = True
90
+ self.pipe_active[job_id] = self.pipe_active_on_enable
91
+ if self.pipe_error_log_text is not None:
92
+ pipe_error_log_path(job_dir).write_text(
93
+ self.pipe_error_log_text,
94
+ encoding="utf-8",
95
+ )
87
96
  if self.touch_ready_on_enable:
88
97
  ready_file.touch()
89
98
 
@@ -122,15 +131,17 @@ async def _create_real_service(
122
131
  tmp_path: Path,
123
132
  *,
124
133
  shellctl_command: tuple[str, ...] | None = None,
134
+ sanitize_pty_command: tuple[str, ...] | None = None,
125
135
  ) -> ShellctlService:
136
+ defaults = ShellctlConfig(
137
+ state_dir=tmp_path / "default-state",
138
+ runtime_dir=tmp_path / "default-run",
139
+ )
126
140
  config = ShellctlConfig(
127
141
  state_dir=tmp_path / "state",
128
142
  runtime_dir=tmp_path / "run",
129
- shellctl_command=shellctl_command
130
- or ShellctlConfig(
131
- state_dir=tmp_path / "default-state",
132
- runtime_dir=tmp_path / "default-run",
133
- ).shellctl_command,
143
+ shellctl_command=shellctl_command or defaults.shellctl_command,
144
+ sanitize_pty_command=sanitize_pty_command or defaults.sanitize_pty_command,
134
145
  )
135
146
  service = ShellctlService(config)
136
147
  await service.initialize()
@@ -154,9 +165,12 @@ def _write_delayed_sanitize_wrapper(
154
165
  "import time",
155
166
  "from pathlib import Path",
156
167
  "",
157
- f"REAL = [{sys.executable!r}, '-m', 'shell_session_manager.shellctl.server']",
168
+ (
169
+ f"REAL = [{sys.executable!r}, '-m', "
170
+ "'shell_session_manager.shellctl.sanitize_pty']"
171
+ ),
158
172
  "args = sys.argv[1:]",
159
- "if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
173
+ "if '--ready-file' in args:",
160
174
  " ready_file = Path(args[args.index('--ready-file') + 1])",
161
175
  " ready_file.touch()",
162
176
  f" time.sleep({delay_seconds!r})",
@@ -318,6 +332,12 @@ async def test_run_job_does_not_materialize_fresh_row_to_lost_during_startup_win
318
332
  await service.initialize_database()
319
333
  service._ensure_dir(service.config.jobs_dir)
320
334
  fake_tmux.touch_ready_on_enable = False
335
+ service.config = ShellctlConfig(
336
+ state_dir=service.config.state_dir,
337
+ runtime_dir=service.config.runtime_dir,
338
+ poll_interval_seconds=0.001,
339
+ pipe_ready_timeout_seconds=0.01,
340
+ )
321
341
 
322
342
  await service.run_job(
323
343
  RunJobRequest(
@@ -601,10 +621,10 @@ async def test_terminated_job_does_not_emit_python_wrapper_traceback(
601
621
  async def test_run_job_returns_flushed_output_on_first_terminal_result(
602
622
  tmp_path: Path,
603
623
  ) -> None:
604
- shellctl_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
624
+ sanitize_pty_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
605
625
  service = await _create_real_service(
606
626
  tmp_path,
607
- shellctl_command=shellctl_command,
627
+ sanitize_pty_command=sanitize_pty_command,
608
628
  )
609
629
 
610
630
  try:
@@ -644,14 +664,14 @@ async def test_run_job_returns_flushed_output_on_first_terminal_result(
644
664
  async def test_sanitize_failure_does_not_commit_normal_exit(
645
665
  tmp_path: Path,
646
666
  ) -> None:
647
- shellctl_command = _write_delayed_sanitize_wrapper(
667
+ sanitize_pty_command = _write_delayed_sanitize_wrapper(
648
668
  tmp_path,
649
669
  delay_seconds=0.2,
650
670
  sanitize_exit_code=7,
651
671
  )
652
672
  service = await _create_real_service(
653
673
  tmp_path,
654
- shellctl_command=shellctl_command,
674
+ sanitize_pty_command=sanitize_pty_command,
655
675
  )
656
676
 
657
677
  try:
@@ -1206,6 +1226,7 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
1206
1226
  runtime_dir=service.config.runtime_dir,
1207
1227
  poll_interval_seconds=0.001,
1208
1228
  pipe_monitor_interval_seconds=0.01,
1229
+ pipe_ready_timeout_seconds=0.01,
1209
1230
  )
1210
1231
 
1211
1232
  result = await service.run_job(
@@ -1225,6 +1246,43 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
1225
1246
  await service.shutdown()
1226
1247
 
1227
1248
 
1249
+ @pytest.mark.anyio
1250
+ async def test_run_job_pipe_ready_timeout_records_diagnostics(tmp_path: Path) -> None:
1251
+ service, fake_tmux = await _create_service(tmp_path)
1252
+ fake_tmux.touch_ready_on_enable = False
1253
+ fake_tmux.pipe_active_on_enable = False
1254
+ fake_tmux.pipe_error_log_text = "Traceback: sanitizer crashed\nsecond line\n"
1255
+ service.config = ShellctlConfig(
1256
+ state_dir=service.config.state_dir,
1257
+ runtime_dir=service.config.runtime_dir,
1258
+ poll_interval_seconds=0.001,
1259
+ pipe_ready_timeout_seconds=0.01,
1260
+ )
1261
+
1262
+ result = await service.run_job(
1263
+ RunJobRequest(
1264
+ script="printf ready\n",
1265
+ cwd=str(tmp_path),
1266
+ terminal=TerminalSize(),
1267
+ timeout=0.05,
1268
+ output_limit=8192,
1269
+ idle_flush_seconds=0.01,
1270
+ )
1271
+ )
1272
+
1273
+ row = await service._get_job_row(result.job_id)
1274
+ ready_file = service.config.jobs_dir / result.job_id / ".pipe-ready"
1275
+
1276
+ assert result.status is JobStatusName.FAILED
1277
+ assert row.message is not None
1278
+ assert "waited " in row.message
1279
+ assert str(ready_file) in row.message
1280
+ assert "tmux #{pane_pipe}=0" in row.message
1281
+ assert PIPE_ERROR_LOG_FILENAME in row.message
1282
+ assert "Traceback: sanitizer crashed | second line" in row.message
1283
+ await service.shutdown()
1284
+
1285
+
1228
1286
  @pytest.mark.anyio
1229
1287
  async def test_send_input_terminal_race_returns_conflict_not_500(
1230
1288
  tmp_path: Path,
@@ -1558,6 +1616,19 @@ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1558
1616
  anyio.run(service.shutdown)
1559
1617
 
1560
1618
 
1619
+ def test_shellctl_config_defaults_to_lightweight_sanitize_entrypoint(
1620
+ tmp_path: Path,
1621
+ ) -> None:
1622
+ config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
1623
+
1624
+ assert config.pipe_ready_timeout_seconds == 10.0
1625
+ assert config.sanitize_pty_command == (
1626
+ sys.executable,
1627
+ "-m",
1628
+ "shell_session_manager.shellctl.sanitize_pty",
1629
+ )
1630
+
1631
+
1561
1632
  def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
1562
1633
  controller = TmuxController(
1563
1634
  ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
@@ -1568,17 +1639,88 @@ def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path)
1568
1639
  ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
1569
1640
  )
1570
1641
 
1571
- assert "sanitize-pty" in source
1642
+ assert "shell_session_manager.shellctl.sanitize_pty" in source
1643
+ assert "shell_session_manager.shellctl.server sanitize-pty" not in source
1572
1644
  assert PIPE_DRAINED_FILENAME in source
1645
+ assert PIPE_ERROR_LOG_FILENAME in source
1573
1646
  assert PIPE_FAILED_FILENAME in source
1574
1647
  assert RUNNER_EXIT_CODE_FILENAME in source
1575
1648
  assert RUNNER_ENDED_AT_FILENAME in source
1576
1649
  assert re.search(r"\brunner-exit\b", source) is not None
1577
1650
  assert 'if [ "$sanitize_status" -eq 0 ]' in source
1578
- assert source.index("sanitize-pty") < source.index(PIPE_DRAINED_FILENAME)
1651
+ assert "2>" in source
1652
+ assert source.index("shell_session_manager.shellctl.sanitize_pty") < source.index(
1653
+ PIPE_DRAINED_FILENAME
1654
+ )
1579
1655
  assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
1580
1656
 
1581
1657
 
1658
+ def test_sanitize_pty_module_uses_lightweight_imports_only() -> None:
1659
+ module_path = (
1660
+ Path(__file__).resolve().parents[1]
1661
+ / "src"
1662
+ / "shell_session_manager"
1663
+ / "shellctl"
1664
+ / "sanitize_pty.py"
1665
+ )
1666
+ tree = ast.parse(module_path.read_text(encoding="utf-8"))
1667
+
1668
+ imports: set[str] = set()
1669
+ for node in ast.walk(tree):
1670
+ if isinstance(node, ast.Import):
1671
+ imports.update(alias.name for alias in node.names)
1672
+ elif isinstance(node, ast.ImportFrom) and node.module is not None:
1673
+ imports.add(node.module)
1674
+
1675
+ assert imports == {
1676
+ "argparse",
1677
+ "pathlib",
1678
+ "sys",
1679
+ "shell_session_manager.shellctl.shared.sanitize",
1680
+ }
1681
+
1682
+
1683
+ def test_python_m_sanitize_pty_module_touches_ready_file_and_sanitizes_output(
1684
+ tmp_path: Path,
1685
+ ) -> None:
1686
+ package_root = Path(__file__).resolve().parents[1]
1687
+ src_path = package_root / "src"
1688
+ env = dict(os.environ)
1689
+ current_pythonpath = env.get("PYTHONPATH")
1690
+ env["PYTHONPATH"] = (
1691
+ f"{src_path}{os.pathsep}{current_pythonpath}"
1692
+ if current_pythonpath
1693
+ else str(src_path)
1694
+ )
1695
+ ready_file = tmp_path / "ready"
1696
+
1697
+ result = subprocess.run(
1698
+ [
1699
+ sys.executable,
1700
+ "-m",
1701
+ "shell_session_manager.shellctl.sanitize_pty",
1702
+ "--ready-file",
1703
+ str(ready_file),
1704
+ ],
1705
+ input=b"before\rafter\n",
1706
+ capture_output=True,
1707
+ check=False,
1708
+ cwd=package_root,
1709
+ env=env,
1710
+ )
1711
+
1712
+ assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace")
1713
+ assert ready_file.exists()
1714
+ assert result.stdout.decode("utf-8") == "after\n"
1715
+
1716
+
1717
+ def test_server_cli_no_longer_exposes_sanitize_pty_command() -> None:
1718
+ result = CliRunner().invoke(cli, ["sanitize-pty"])
1719
+
1720
+ assert result.exit_code != 0
1721
+ assert "No such command 'sanitize-pty'" in result.output
1722
+
1723
+
1582
1724
  def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
1583
1725
  async def setup_running_job() -> ShellctlConfig:
1584
1726
  service, _fake_tmux = await _create_service(tmp_path)
@@ -1634,5 +1776,5 @@ def test_python_m_shellctl_server_module_entrypoint_shows_cli_help() -> None:
1634
1776
  )
1635
1777
 
1636
1778
  assert result.returncode == 0, result.stderr
1637
- assert "sanitize-pty" in result.stdout
1779
+ assert "sanitize-pty" not in result.stdout
1638
1780
  assert "runner-exit" in result.stdout