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.
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/PKG-INFO +1 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/pyproject.toml +1 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
- shell_session_manager-2.2.1/src/shell_session_manager/shellctl/sanitize_pty.py +39 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/artifacts.py +19 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/cli.py +7 -15
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/config.py +12 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/service.py +157 -25
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/tmux.py +7 -4
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_client.py +7 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_service.py +366 -16
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shellctl_shared.py +20 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/LICENSE +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/README.md +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py +0 -0
- {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.
|
|
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.
|
|
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`.
|
|
7
|
-
|
|
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
|
|
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
|
|
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
|
|
554
|
-
|
|
555
|
-
also call this as a recovery path when `.pipe-drained` plus the
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
83
|
-
self.
|
|
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
|
|
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
|
-
|
|
168
|
+
(
|
|
169
|
+
f"REAL = [{sys.executable!r}, '-m', "
|
|
170
|
+
"'shell_session_manager.shellctl.sanitize_pty']"
|
|
171
|
+
),
|
|
155
172
|
"args = sys.argv[1:]",
|
|
156
|
-
"if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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)
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.1}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|