shell-session-manager 2.1.1__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.1.1 → shell_session_manager-2.2.1}/PKG-INFO +1 -1
  2. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/pyproject.toml +1 -1
  3. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
  4. shell_session_manager-2.2.1/src/shell_session_manager/shellctl/sanitize_pty.py +39 -0
  5. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -2
  6. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/artifacts.py +19 -2
  7. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/cli.py +7 -15
  8. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/config.py +12 -2
  9. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/service.py +157 -25
  10. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/tmux.py +7 -4
  11. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
  12. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_client.py +7 -1
  13. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_service.py +366 -16
  14. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_shared.py +20 -0
  15. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/LICENSE +0 -0
  16. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/README.md +0 -0
  17. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py +0 -0
  18. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed +0 -0
  19. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py +0 -0
  20. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
  21. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  22. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  23. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
  24. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
  25. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  26. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  27. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  28. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  29. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  30. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  31. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json +0 -0
  32. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py +0 -0
  33. {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shell_session_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shell-session-manager
3
- Version: 2.1.1
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.1.1"
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" },
@@ -96,14 +96,20 @@ class ShellctlClient:
96
96
  script: str,
97
97
  *,
98
98
  cwd: str | None = None,
99
+ env: dict[str, str] | None = None,
99
100
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
100
101
  terminal: TerminalSize | None = None,
101
102
  ) -> JobResult:
102
- """Create a new job and wait for initial output or completion."""
103
+ """Create a new job and wait for initial output or completion.
104
+
105
+ `cwd` and `env` preset the script's working directory and environment
106
+ overlay on the server side.
107
+ """
103
108
 
104
109
  payload = RunJobRequest(
105
110
  script=script,
106
111
  cwd=cwd,
112
+ env=env,
107
113
  terminal=terminal,
108
114
  timeout=timeout,
109
115
  output_limit=self.output_limit,
@@ -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
  ]
@@ -3,8 +3,11 @@
3
3
  Normal job completion is coordinated through small marker files inside each
4
4
  `jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
5
5
  SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
6
- drained into `output.log`. A separate failure marker is used so an unsuccessful
7
- sanitizer run does not masquerade as a drained normal exit.
6
+ drained into `output.log`. The same artifact directory also stores the request's
7
+ environment overlay so the runner can merge arbitrary key/value pairs without
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.
8
11
  """
9
12
 
10
13
  from __future__ import annotations
@@ -13,8 +16,10 @@ from pathlib import Path
13
16
 
14
17
  RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
15
18
  RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
19
+ JOB_ENV_FILENAME = ".job-env.json"
16
20
  PIPE_DRAINED_FILENAME = ".pipe-drained"
17
21
  PIPE_FAILED_FILENAME = ".pipe-failed"
22
+ PIPE_ERROR_LOG_FILENAME = "pipe-error.log"
18
23
 
19
24
 
20
25
  def runner_exit_code_path(job_dir: Path) -> Path:
@@ -25,6 +30,10 @@ def runner_ended_at_path(job_dir: Path) -> Path:
25
30
  return job_dir / RUNNER_ENDED_AT_FILENAME
26
31
 
27
32
 
33
+ def job_env_path(job_dir: Path) -> Path:
34
+ return job_dir / JOB_ENV_FILENAME
35
+
36
+
28
37
  def pipe_drained_path(job_dir: Path) -> Path:
29
38
  return job_dir / PIPE_DRAINED_FILENAME
30
39
 
@@ -33,12 +42,20 @@ def pipe_failed_path(job_dir: Path) -> Path:
33
42
  return job_dir / PIPE_FAILED_FILENAME
34
43
 
35
44
 
45
+ def pipe_error_log_path(job_dir: Path) -> Path:
46
+ return job_dir / PIPE_ERROR_LOG_FILENAME
47
+
48
+
36
49
  __all__ = [
50
+ "JOB_ENV_FILENAME",
37
51
  "PIPE_DRAINED_FILENAME",
52
+ "PIPE_ERROR_LOG_FILENAME",
38
53
  "PIPE_FAILED_FILENAME",
39
54
  "RUNNER_ENDED_AT_FILENAME",
40
55
  "RUNNER_EXIT_CODE_FILENAME",
56
+ "job_env_path",
41
57
  "pipe_drained_path",
58
+ "pipe_error_log_path",
42
59
  "pipe_failed_path",
43
60
  "runner_ended_at_path",
44
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:
@@ -9,6 +9,7 @@ This module owns the long-lived job lifecycle rules: artifact creation,
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import json
12
13
  import shlex
13
14
  import shutil
14
15
  import stat
@@ -24,9 +25,12 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
24
25
  from sqlmodel import SQLModel
25
26
 
26
27
  from shell_session_manager.shellctl.server.artifacts import (
28
+ JOB_ENV_FILENAME,
27
29
  RUNNER_ENDED_AT_FILENAME,
28
30
  RUNNER_EXIT_CODE_FILENAME,
31
+ job_env_path,
29
32
  pipe_drained_path,
33
+ pipe_error_log_path,
30
34
  pipe_failed_path,
31
35
  runner_ended_at_path,
32
36
  runner_exit_code_path,
@@ -148,7 +152,8 @@ class ShellctlService:
148
152
  """Create a tmux-backed job and wait for its initial result window.
149
153
 
150
154
  Side effects:
151
- - allocates `jobs/<job_id>/` and writes `script` / `output.log`
155
+ - allocates `jobs/<job_id>/` and writes `script`, `.job-env.json`, and
156
+ `output.log`
152
157
  - inserts a `jobs` row into SQLite, then conditionally transitions it
153
158
  through `created -> starting -> running`
154
159
  - creates a dedicated tmux session, installs `pipe-pane`, waits for the
@@ -168,6 +173,7 @@ class ShellctlService:
168
173
  """
169
174
 
170
175
  cwd = self._resolve_cwd(request.cwd)
176
+ env = self._resolve_env(request.env)
171
177
  terminal = request.terminal or TerminalSize(
172
178
  cols=self.config.default_terminal_cols,
173
179
  rows=self.config.default_terminal_rows,
@@ -183,6 +189,10 @@ class ShellctlService:
183
189
  script_path = candidate_job_dir / "script"
184
190
  output_path = candidate_job_dir / "output.log"
185
191
  script_path.write_text(request.script, encoding="utf-8")
192
+ job_env_path(candidate_job_dir).write_text(
193
+ json.dumps(env, ensure_ascii=False),
194
+ encoding="utf-8",
195
+ )
186
196
  output_path.touch()
187
197
  row = JobRow(
188
198
  job_id=candidate_job_id,
@@ -550,10 +560,10 @@ class ShellctlService:
550
560
  ) -> None:
551
561
  """Persist a drained normal-exit fact into SQLite.
552
562
 
553
- The usual caller is the tmux pipe finalizer after `sanitize-pty` has
554
- reached EOF and flushed `output.log`. `_materialize_status_view()` may
555
- also call this as a recovery path when `.pipe-drained` plus the normal
556
- 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
557
567
  statement always records `exit_code` and `ended_at`, but it only changes
558
568
  `status` to `exited` when the current row is still non-terminal.
559
569
  """
@@ -626,13 +636,17 @@ class ShellctlService:
626
636
  async def _wait_for_output_pipe_ready(
627
637
  self, *, job_id: str, ready_file: Path
628
638
  ) -> None:
629
- """Confirm the sanitize/output pipeline is live before opening start-gate."""
639
+ """Confirm the sanitize/output pipeline is live before opening start-gate.
630
640
 
631
- deadline = anyio.current_time() + max(
632
- self.config.poll_interval_seconds * 10,
633
- self.config.pipe_monitor_interval_seconds,
634
- )
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
635
648
  while True:
649
+ waited_seconds = anyio.current_time() - started_at
636
650
  if ready_file.exists():
637
651
  pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
638
652
  if pipe_state is True:
@@ -641,23 +655,87 @@ class ShellctlService:
641
655
  raise ShellctlServerError(
642
656
  500,
643
657
  "pipe_failed",
644
- 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
+ ),
645
665
  )
646
666
  else:
647
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)
648
669
  raise ShellctlServerError(
649
670
  500,
650
671
  "pipe_failed",
651
- 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
+ ),
652
679
  )
653
680
  if anyio.current_time() >= deadline:
681
+ pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
654
682
  raise ShellctlServerError(
655
683
  500,
656
684
  "pipe_failed",
657
- 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
+ ),
658
692
  )
659
693
  await anyio.sleep(self.config.poll_interval_seconds)
660
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
+
661
739
  async def _materialize_status_view(
662
740
  self,
663
741
  job_id: str,
@@ -1000,6 +1078,17 @@ class ShellctlService:
1000
1078
  )
1001
1079
  return cwd
1002
1080
 
1081
+ def _resolve_env(self, raw_env: dict[str, str] | None) -> dict[str, str]:
1082
+ """Return a detached environment overlay for the generated runner.
1083
+
1084
+ `RunJobRequest` validates names/values before the service sees them.
1085
+ The overlay augments the runner's inherited environment after shellctl
1086
+ scrubs its own control variables, so explicit request values can be used
1087
+ without replacing ambient entries like `PATH`.
1088
+ """
1089
+
1090
+ return dict(raw_env or {})
1091
+
1003
1092
  def _validate_offset(self, output_path: Path, offset: int) -> None:
1004
1093
  size = output_path.stat().st_size if output_path.exists() else 0
1005
1094
  if offset > size:
@@ -1022,7 +1111,59 @@ class ShellctlService:
1022
1111
  self.config.runner_path.chmod(mode | stat.S_IXUSR)
1023
1112
 
1024
1113
  def _runner_script_source(self) -> str:
1114
+ """Build the bash runner installed into the shellctl runtime directory.
1115
+
1116
+ The wrapper still handles gate waiting and exit metadata in bash, but it
1117
+ delegates launch setup to Python so per-job env overlays can be loaded
1118
+ from JSON without shell-escaping arbitrary values into `export`
1119
+ statements. The Python helper then `exec`s the target process instead of
1120
+ waiting on it, which keeps `SIGINT` behavior identical to running the
1121
+ script directly in the tmux pane. The bootstrap uses `python -c ...`
1122
+ instead of a stdin-fed heredoc so the exec'd job still inherits the tmux
1123
+ pane's PTY on fd 0 and can receive later `send_input()` data.
1124
+ """
1125
+
1025
1126
  auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
1127
+ bootstrap_source = shlex.quote(
1128
+ """
1129
+ import json
1130
+ import os
1131
+ import stat
1132
+ import sys
1133
+ from pathlib import Path
1134
+
1135
+ script_path = Path(sys.argv[1])
1136
+ cwd = sys.argv[2]
1137
+ env_path = Path(sys.argv[3])
1138
+
1139
+ env = os.environ.copy()
1140
+ if env_path.exists():
1141
+ env.update(json.loads(env_path.read_text(encoding="utf-8")))
1142
+
1143
+ try:
1144
+ os.chdir(cwd)
1145
+ except OSError:
1146
+ raise SystemExit(111)
1147
+
1148
+ with script_path.open("r", encoding="utf-8") as handle:
1149
+ first_line = handle.readline()
1150
+
1151
+ if first_line.startswith("#!"):
1152
+ script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR)
1153
+ argv = [str(script_path)]
1154
+ else:
1155
+ argv = ["sh", str(script_path)]
1156
+
1157
+ try:
1158
+ os.execvpe(argv[0], argv, env)
1159
+ except FileNotFoundError as exc:
1160
+ print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
1161
+ raise SystemExit(127) from exc
1162
+ except OSError as exc:
1163
+ print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
1164
+ raise SystemExit(126) from exc
1165
+ """.strip()
1166
+ )
1026
1167
  return f"""#!/usr/bin/env bash
1027
1168
  set -uo pipefail
1028
1169
 
@@ -1030,6 +1171,7 @@ JOB_DIR="$1"
1030
1171
  JOB_ID="$2"
1031
1172
  CWD="$3"
1032
1173
  SCRIPT_PATH="$JOB_DIR/script"
1174
+ ENV_PATH="$JOB_DIR/{JOB_ENV_FILENAME}"
1033
1175
  START_GATE="$JOB_DIR/start-gate"
1034
1176
  RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
1035
1177
  RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
@@ -1053,18 +1195,8 @@ unset SHELLCTL_TMUX_SOCKET
1053
1195
  unset SHELLCTL_RUNNER
1054
1196
  unset {auth_env}
1055
1197
 
1056
- if cd "$CWD"; then
1057
- if IFS= read -r FIRST_LINE < "$SCRIPT_PATH" && [[ "$FIRST_LINE" == '#!'* ]]; then
1058
- chmod +x "$SCRIPT_PATH"
1059
- "$SCRIPT_PATH"
1060
- EXIT_CODE=$?
1061
- else
1062
- sh "$SCRIPT_PATH"
1063
- EXIT_CODE=$?
1064
- fi
1065
- else
1066
- EXIT_CODE=111
1067
- fi
1198
+ {shlex.quote(sys.executable)} -c {bootstrap_source} "$SCRIPT_PATH" "$CWD" "$ENV_PATH"
1199
+ EXIT_CODE=$?
1068
1200
 
1069
1201
  ENDED_AT="$({shlex.quote(sys.executable)} - <<'PY'
1070
1202
  from datetime import UTC, datetime
@@ -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 '
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from enum import StrEnum
6
6
 
7
- from pydantic import BaseModel, ConfigDict, Field
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
8
 
9
9
  from shell_session_manager.shellctl.shared.constants import (
10
10
  DEFAULT_HEALTH_STATUS,
@@ -128,10 +128,16 @@ class ErrorResponse(ShellctlModel):
128
128
 
129
129
 
130
130
  class RunJobRequest(ShellctlModel):
131
- """HTTP request body for `POST /v1/jobs/run`."""
131
+ """HTTP request body for `POST /v1/jobs/run`.
132
+
133
+ `env` augments the runner's inherited process environment instead of
134
+ replacing it, so callers can preset script-local variables without losing
135
+ ambient values such as `PATH`.
136
+ """
132
137
 
133
138
  script: str
134
139
  cwd: str | None = None
140
+ env: dict[str, str] | None = None
135
141
  terminal: TerminalSize | None = None
136
142
  timeout: float = Field(
137
143
  default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS
@@ -141,6 +147,30 @@ class RunJobRequest(ShellctlModel):
141
147
  )
142
148
  idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
143
149
 
150
+ @field_validator("env")
151
+ @classmethod
152
+ def _validate_env(cls, env: dict[str, str] | None) -> dict[str, str] | None:
153
+ """Reject env entries that cannot be represented in `execve`.
154
+
155
+ shellctl applies `env` as a process environment overlay, so validation
156
+ follows the low-level `NAME=value` constraints instead of shell variable
157
+ naming rules: names must be non-empty and cannot contain `=` or NUL,
158
+ while values cannot contain NUL.
159
+ """
160
+
161
+ if env is None:
162
+ return None
163
+ for name, value in env.items():
164
+ if not name:
165
+ raise ValueError("env names must be non-empty")
166
+ if "=" in name:
167
+ raise ValueError(f"env name must not contain '=': {name!r}")
168
+ if "\x00" in name:
169
+ raise ValueError(f"env name must not contain NUL: {name!r}")
170
+ if "\x00" in value:
171
+ raise ValueError(f"env value must not contain NUL: {name!r}")
172
+ return env
173
+
144
174
 
145
175
  class WaitJobRequest(ShellctlModel):
146
176
  """HTTP request body for `POST /v1/jobs/{job_id}/wait`."""
@@ -39,7 +39,12 @@ async def test_shellctl_client_run_injects_headers_and_instance_defaults(
39
39
  idle_flush_seconds=0.25,
40
40
  transport=transport,
41
41
  ) as client:
42
- await client.run("printf ready\\n", cwd="/tmp", timeout=12)
42
+ await client.run(
43
+ "printf ready\\n",
44
+ cwd="/tmp",
45
+ env={"HELLO": "world"},
46
+ timeout=12,
47
+ )
43
48
 
44
49
  assert captured["method"] == "POST"
45
50
  assert captured["path"] == "/v1/jobs/run"
@@ -47,6 +52,7 @@ async def test_shellctl_client_run_injects_headers_and_instance_defaults(
47
52
  assert captured["json"] == {
48
53
  "script": "printf ready\\n",
49
54
  "cwd": "/tmp",
55
+ "env": {"HELLO": "world"},
50
56
  "timeout": 12.0,
51
57
  "output_limit": 4096,
52
58
  "idle_flush_seconds": 0.25,
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
3
4
  import importlib
5
+ import json
4
6
  import os
5
7
  import re
6
8
  import shutil
@@ -26,10 +28,14 @@ from shell_session_manager.shellctl.server import (
26
28
  create_app,
27
29
  )
28
30
  from shell_session_manager.shellctl.server.artifacts import (
31
+ JOB_ENV_FILENAME,
29
32
  PIPE_DRAINED_FILENAME,
33
+ PIPE_ERROR_LOG_FILENAME,
30
34
  PIPE_FAILED_FILENAME,
31
35
  RUNNER_ENDED_AT_FILENAME,
32
36
  RUNNER_EXIT_CODE_FILENAME,
37
+ job_env_path,
38
+ pipe_error_log_path,
33
39
  )
34
40
  from shell_session_manager.shellctl.server.tmux import TmuxController
35
41
  from shell_session_manager.shellctl.shared import (
@@ -54,6 +60,8 @@ class FakeTmuxController:
54
60
  self.pipe_active: dict[str, bool | None] = {}
55
61
  self.cleaned: list[str] = []
56
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
57
65
  self.on_send_interrupt: Callable[[str], Awaitable[None]] | None = None
58
66
  self.on_send_input: Callable[[str, str], Awaitable[None]] | None = None
59
67
 
@@ -79,8 +87,12 @@ class FakeTmuxController:
79
87
  async def enable_output_pipe(
80
88
  self, *, job_id: str, job_dir: Path, ready_file: Path
81
89
  ) -> None:
82
- del job_dir
83
- 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
+ )
84
96
  if self.touch_ready_on_enable:
85
97
  ready_file.touch()
86
98
 
@@ -119,15 +131,17 @@ async def _create_real_service(
119
131
  tmp_path: Path,
120
132
  *,
121
133
  shellctl_command: tuple[str, ...] | None = None,
134
+ sanitize_pty_command: tuple[str, ...] | None = None,
122
135
  ) -> ShellctlService:
136
+ defaults = ShellctlConfig(
137
+ state_dir=tmp_path / "default-state",
138
+ runtime_dir=tmp_path / "default-run",
139
+ )
123
140
  config = ShellctlConfig(
124
141
  state_dir=tmp_path / "state",
125
142
  runtime_dir=tmp_path / "run",
126
- shellctl_command=shellctl_command
127
- or ShellctlConfig(
128
- state_dir=tmp_path / "default-state",
129
- runtime_dir=tmp_path / "default-run",
130
- ).shellctl_command,
143
+ shellctl_command=shellctl_command or defaults.shellctl_command,
144
+ sanitize_pty_command=sanitize_pty_command or defaults.sanitize_pty_command,
131
145
  )
132
146
  service = ShellctlService(config)
133
147
  await service.initialize()
@@ -151,9 +165,12 @@ def _write_delayed_sanitize_wrapper(
151
165
  "import time",
152
166
  "from pathlib import Path",
153
167
  "",
154
- 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
+ ),
155
172
  "args = sys.argv[1:]",
156
- "if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
173
+ "if '--ready-file' in args:",
157
174
  " ready_file = Path(args[args.index('--ready-file') + 1])",
158
175
  " ready_file.touch()",
159
176
  f" time.sleep({delay_seconds!r})",
@@ -315,6 +332,12 @@ async def test_run_job_does_not_materialize_fresh_row_to_lost_during_startup_win
315
332
  await service.initialize_database()
316
333
  service._ensure_dir(service.config.jobs_dir)
317
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
+ )
318
341
 
319
342
  await service.run_job(
320
343
  RunJobRequest(
@@ -352,6 +375,7 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
352
375
  RunJobRequest(
353
376
  script="printf ready\n",
354
377
  cwd=str(tmp_path),
378
+ env={"HELLO": "world", "UNICODE": "盐粒"},
355
379
  terminal=TerminalSize(cols=100, rows=40),
356
380
  timeout=0.01,
357
381
  output_limit=8192,
@@ -372,6 +396,10 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
372
396
  assert row.output_path == f"jobs/{result.job_id}/output.log"
373
397
  assert row.started_at is not None
374
398
  assert (job_dir / "script").exists()
399
+ assert json.loads(job_env_path(job_dir).read_text(encoding="utf-8")) == {
400
+ "HELLO": "world",
401
+ "UNICODE": "盐粒",
402
+ }
375
403
  assert (job_dir / "output.log").exists()
376
404
  assert (job_dir / "start-gate").exists()
377
405
  assert job_session_name(result.job_id) in fake_tmux.sessions
@@ -389,15 +417,214 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
389
417
  await service.shutdown()
390
418
 
391
419
 
420
+ @pytest.mark.anyio
421
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
422
+ async def test_run_job_env_overlay_is_visible_without_replacing_inherited_env(
423
+ tmp_path: Path,
424
+ ) -> None:
425
+ service = await _create_real_service(tmp_path)
426
+
427
+ try:
428
+ result = await service.run_job(
429
+ RunJobRequest(
430
+ script=(
431
+ "python3 - <<'PY'\n"
432
+ "import os\n"
433
+ "print(os.environ['SHELLCTL_PRESET'])\n"
434
+ "print('PATH' in os.environ)\n"
435
+ "PY\n"
436
+ ),
437
+ cwd=str(tmp_path),
438
+ env={"SHELLCTL_PRESET": "from-client"},
439
+ terminal=TerminalSize(),
440
+ timeout=5,
441
+ output_limit=8192,
442
+ idle_flush_seconds=1,
443
+ )
444
+ )
445
+
446
+ assert result.done is True
447
+ assert result.status is JobStatusName.EXITED
448
+ assert result.exit_code == 0
449
+ assert result.output == "from-client\nTrue\n"
450
+ finally:
451
+ await service.shutdown()
452
+
453
+
454
+ @pytest.mark.anyio
455
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
456
+ async def test_run_job_env_overlay_overrides_inherited_value(
457
+ tmp_path: Path,
458
+ monkeypatch: pytest.MonkeyPatch,
459
+ ) -> None:
460
+ monkeypatch.setenv("SHELLCTL_PRESET", "parent")
461
+ service = await _create_real_service(tmp_path)
462
+
463
+ try:
464
+ result = await service.run_job(
465
+ RunJobRequest(
466
+ script=(
467
+ "python3 - <<'PY'\n"
468
+ "import os\n"
469
+ "print(os.environ['SHELLCTL_PRESET'])\n"
470
+ "PY\n"
471
+ ),
472
+ cwd=str(tmp_path),
473
+ env={"SHELLCTL_PRESET": "from-client"},
474
+ terminal=TerminalSize(),
475
+ timeout=5,
476
+ output_limit=8192,
477
+ idle_flush_seconds=1,
478
+ )
479
+ )
480
+
481
+ assert result.done is True
482
+ assert result.status is JobStatusName.EXITED
483
+ assert result.exit_code == 0
484
+ assert result.output == "from-client\n"
485
+ finally:
486
+ await service.shutdown()
487
+
488
+
489
+ @pytest.mark.anyio
490
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
491
+ async def test_send_input_reaches_real_job_stdin_after_env_bootstrap(
492
+ tmp_path: Path,
493
+ ) -> None:
494
+ service = await _create_real_service(tmp_path)
495
+
496
+ try:
497
+ initial = await service.run_job(
498
+ RunJobRequest(
499
+ script=(
500
+ "printf 'ready\\n'\n"
501
+ "IFS= read -r line\n"
502
+ "printf 'got:%s\\n' \"$line\"\n"
503
+ ),
504
+ cwd=str(tmp_path),
505
+ terminal=TerminalSize(),
506
+ timeout=5,
507
+ output_limit=8192,
508
+ idle_flush_seconds=0.01,
509
+ )
510
+ )
511
+
512
+ ready_window = initial
513
+ if ready_window.output == "":
514
+ ready_window = await service.wait_job(
515
+ initial.job_id,
516
+ WaitJobRequest(
517
+ offset=initial.offset,
518
+ timeout=1,
519
+ output_limit=8192,
520
+ idle_flush_seconds=0.01,
521
+ ),
522
+ )
523
+
524
+ result = await service.send_input(
525
+ initial.job_id,
526
+ InputJobRequest(
527
+ text="hello from stdin\n",
528
+ offset=ready_window.offset,
529
+ timeout=1,
530
+ output_limit=8192,
531
+ idle_flush_seconds=0.01,
532
+ ),
533
+ )
534
+
535
+ output_after_input = result.output
536
+ final = result
537
+ if not final.done:
538
+ # `send_input()` shares wait semantics with `wait_job()`: it may
539
+ # return after the post-input output flushes but before tmux exit
540
+ # artifacts have materialized into a terminal DB status on slower CI.
541
+ final = await service.wait_job(
542
+ initial.job_id,
543
+ WaitJobRequest(
544
+ offset=result.offset,
545
+ timeout=5,
546
+ output_limit=8192,
547
+ idle_flush_seconds=0.01,
548
+ ),
549
+ )
550
+ output_after_input += final.output
551
+
552
+ assert ready_window.done is False
553
+ assert final.done is True
554
+ assert final.status is JobStatusName.EXITED
555
+ assert final.exit_code == 0
556
+ assert output_after_input.endswith("got:hello from stdin\n")
557
+ finally:
558
+ await service.shutdown()
559
+
560
+
561
+ @pytest.mark.anyio
562
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
563
+ async def test_terminated_job_does_not_emit_python_wrapper_traceback(
564
+ tmp_path: Path,
565
+ ) -> None:
566
+ service = await _create_real_service(tmp_path)
567
+
568
+ try:
569
+ initial = await service.run_job(
570
+ RunJobRequest(
571
+ script=(
572
+ "trap 'exit 130' INT\n"
573
+ "printf 'ready\\n'\n"
574
+ "while :; do sleep 1; done\n"
575
+ ),
576
+ cwd=str(tmp_path),
577
+ terminal=TerminalSize(),
578
+ timeout=5,
579
+ output_limit=8192,
580
+ idle_flush_seconds=0.01,
581
+ )
582
+ )
583
+
584
+ ready_window = initial
585
+ if ready_window.output == "":
586
+ ready_window = await service.wait_job(
587
+ initial.job_id,
588
+ WaitJobRequest(
589
+ offset=initial.offset,
590
+ timeout=1,
591
+ output_limit=8192,
592
+ idle_flush_seconds=0.01,
593
+ ),
594
+ )
595
+ assert ready_window.done is False
596
+
597
+ terminated = await service.terminate_job(
598
+ initial.job_id,
599
+ TerminateJobRequest(grace_seconds=0.2),
600
+ )
601
+ final = await service.wait_job(
602
+ initial.job_id,
603
+ WaitJobRequest(
604
+ offset=ready_window.offset,
605
+ timeout=1,
606
+ output_limit=8192,
607
+ idle_flush_seconds=0.01,
608
+ ),
609
+ )
610
+
611
+ assert terminated.status is JobStatusName.TERMINATED
612
+ assert final.done is True
613
+ assert "KeyboardInterrupt" not in final.output
614
+ assert "Traceback (most recent call last)" not in final.output
615
+ finally:
616
+ await service.shutdown()
617
+
618
+
392
619
  @pytest.mark.anyio
393
620
  @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
394
621
  async def test_run_job_returns_flushed_output_on_first_terminal_result(
395
622
  tmp_path: Path,
396
623
  ) -> None:
397
- 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)
398
625
  service = await _create_real_service(
399
626
  tmp_path,
400
- shellctl_command=shellctl_command,
627
+ sanitize_pty_command=sanitize_pty_command,
401
628
  )
402
629
 
403
630
  try:
@@ -437,14 +664,14 @@ async def test_run_job_returns_flushed_output_on_first_terminal_result(
437
664
  async def test_sanitize_failure_does_not_commit_normal_exit(
438
665
  tmp_path: Path,
439
666
  ) -> None:
440
- shellctl_command = _write_delayed_sanitize_wrapper(
667
+ sanitize_pty_command = _write_delayed_sanitize_wrapper(
441
668
  tmp_path,
442
669
  delay_seconds=0.2,
443
670
  sanitize_exit_code=7,
444
671
  )
445
672
  service = await _create_real_service(
446
673
  tmp_path,
447
- shellctl_command=shellctl_command,
674
+ sanitize_pty_command=sanitize_pty_command,
448
675
  )
449
676
 
450
677
  try:
@@ -999,6 +1226,7 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
999
1226
  runtime_dir=service.config.runtime_dir,
1000
1227
  poll_interval_seconds=0.001,
1001
1228
  pipe_monitor_interval_seconds=0.01,
1229
+ pipe_ready_timeout_seconds=0.01,
1002
1230
  )
1003
1231
 
1004
1232
  result = await service.run_job(
@@ -1018,6 +1246,43 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
1018
1246
  await service.shutdown()
1019
1247
 
1020
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
+
1021
1286
  @pytest.mark.anyio
1022
1287
  async def test_send_input_terminal_race_returns_conflict_not_500(
1023
1288
  tmp_path: Path,
@@ -1342,6 +1607,7 @@ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1342
1607
  )
1343
1608
  source = service._runner_script_source()
1344
1609
 
1610
+ assert JOB_ENV_FILENAME in source
1345
1611
  assert RUNNER_EXIT_CODE_FILENAME in source
1346
1612
  assert RUNNER_ENDED_AT_FILENAME in source
1347
1613
  assert "write_atomic" in source
@@ -1350,6 +1616,19 @@ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1350
1616
  anyio.run(service.shutdown)
1351
1617
 
1352
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
+
1353
1632
  def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
1354
1633
  controller = TmuxController(
1355
1634
  ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
@@ -1360,17 +1639,88 @@ def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path)
1360
1639
  ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
1361
1640
  )
1362
1641
 
1363
- 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
1364
1644
  assert PIPE_DRAINED_FILENAME in source
1645
+ assert PIPE_ERROR_LOG_FILENAME in source
1365
1646
  assert PIPE_FAILED_FILENAME in source
1366
1647
  assert RUNNER_EXIT_CODE_FILENAME in source
1367
1648
  assert RUNNER_ENDED_AT_FILENAME in source
1368
1649
  assert re.search(r"\brunner-exit\b", source) is not None
1369
1650
  assert 'if [ "$sanitize_status" -eq 0 ]' in source
1370
- 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
+ )
1371
1655
  assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
1372
1656
 
1373
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
+
1374
1724
  def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
1375
1725
  async def setup_running_job() -> ShellctlConfig:
1376
1726
  service, _fake_tmux = await _create_service(tmp_path)
@@ -1426,5 +1776,5 @@ def test_python_m_shellctl_server_module_entrypoint_shows_cli_help() -> None:
1426
1776
  )
1427
1777
 
1428
1778
  assert result.returncode == 0, result.stderr
1429
- assert "sanitize-pty" in result.stdout
1779
+ assert "sanitize-pty" not in result.stdout
1430
1780
  assert "runner-exit" in result.stdout
@@ -4,9 +4,13 @@ from datetime import UTC, datetime
4
4
  from io import BytesIO
5
5
  from pathlib import Path
6
6
 
7
+ import pytest
8
+ from pydantic import ValidationError
9
+
7
10
  from shell_session_manager.shellctl.shared import (
8
11
  JOB_ID_ALPHABET,
9
12
  PtySanitizer,
13
+ RunJobRequest,
10
14
  generate_job_id,
11
15
  read_output_window,
12
16
  sanitize_pty_output,
@@ -112,3 +116,19 @@ def test_sanitize_pty_stream_flushes_incrementally() -> None:
112
116
 
113
117
  assert stdout.getvalue() == b"ready\nnext\n"
114
118
  assert stdout.flush_count >= 2
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ ("env", "message"),
123
+ [
124
+ ({"": "x"}, "non-empty"),
125
+ ({"A=B": "x"}, "must not contain '='"),
126
+ ({"A\x00B": "x"}, "must not contain NUL"),
127
+ ({"A": "x\x00y"}, "must not contain NUL"),
128
+ ],
129
+ )
130
+ def test_run_job_request_rejects_invalid_env_entries(
131
+ env: dict[str, str], message: str
132
+ ) -> None:
133
+ with pytest.raises(ValidationError, match=message):
134
+ RunJobRequest(script="printf ready\n", env=env)