shell-session-manager 2.1.0__tar.gz → 2.2.0__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.0 → shell_session_manager-2.2.0}/PKG-INFO +1 -1
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/pyproject.toml +1 -1
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
- shell_session_manager-2.2.0/src/shell_session_manager/shellctl/server/artifacts.py +54 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/service.py +145 -22
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_client.py +7 -1
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_service.py +505 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_shared.py +20 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/LICENSE +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/README.md +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/api.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/cli.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/config.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/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.0
|
|
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.0"
|
|
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,54 @@
|
|
|
1
|
+
"""Per-job artifact names used by shellctl server/runtime code.
|
|
2
|
+
|
|
3
|
+
Normal job completion is coordinated through small marker files inside each
|
|
4
|
+
`jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
|
|
5
|
+
SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
|
|
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. A separate failure marker is used
|
|
9
|
+
so an unsuccessful sanitizer run does not masquerade as a drained normal exit.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
|
|
17
|
+
RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
|
|
18
|
+
JOB_ENV_FILENAME = ".job-env.json"
|
|
19
|
+
PIPE_DRAINED_FILENAME = ".pipe-drained"
|
|
20
|
+
PIPE_FAILED_FILENAME = ".pipe-failed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def runner_exit_code_path(job_dir: Path) -> Path:
|
|
24
|
+
return job_dir / RUNNER_EXIT_CODE_FILENAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def runner_ended_at_path(job_dir: Path) -> Path:
|
|
28
|
+
return job_dir / RUNNER_ENDED_AT_FILENAME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def job_env_path(job_dir: Path) -> Path:
|
|
32
|
+
return job_dir / JOB_ENV_FILENAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pipe_drained_path(job_dir: Path) -> Path:
|
|
36
|
+
return job_dir / PIPE_DRAINED_FILENAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pipe_failed_path(job_dir: Path) -> Path:
|
|
40
|
+
return job_dir / PIPE_FAILED_FILENAME
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"JOB_ENV_FILENAME",
|
|
45
|
+
"PIPE_DRAINED_FILENAME",
|
|
46
|
+
"PIPE_FAILED_FILENAME",
|
|
47
|
+
"RUNNER_ENDED_AT_FILENAME",
|
|
48
|
+
"RUNNER_EXIT_CODE_FILENAME",
|
|
49
|
+
"job_env_path",
|
|
50
|
+
"pipe_drained_path",
|
|
51
|
+
"pipe_failed_path",
|
|
52
|
+
"runner_ended_at_path",
|
|
53
|
+
"runner_exit_code_path",
|
|
54
|
+
]
|
|
@@ -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
|
|
@@ -23,6 +24,16 @@ from sqlalchemy.exc import IntegrityError
|
|
|
23
24
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
24
25
|
from sqlmodel import SQLModel
|
|
25
26
|
|
|
27
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
28
|
+
JOB_ENV_FILENAME,
|
|
29
|
+
RUNNER_ENDED_AT_FILENAME,
|
|
30
|
+
RUNNER_EXIT_CODE_FILENAME,
|
|
31
|
+
job_env_path,
|
|
32
|
+
pipe_drained_path,
|
|
33
|
+
pipe_failed_path,
|
|
34
|
+
runner_ended_at_path,
|
|
35
|
+
runner_exit_code_path,
|
|
36
|
+
)
|
|
26
37
|
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
27
38
|
from shell_session_manager.shellctl.server.db import JobRow, configure_sqlite_engine
|
|
28
39
|
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
@@ -140,7 +151,8 @@ class ShellctlService:
|
|
|
140
151
|
"""Create a tmux-backed job and wait for its initial result window.
|
|
141
152
|
|
|
142
153
|
Side effects:
|
|
143
|
-
- allocates `jobs/<job_id>/` and writes `script
|
|
154
|
+
- allocates `jobs/<job_id>/` and writes `script`, `.job-env.json`, and
|
|
155
|
+
`output.log`
|
|
144
156
|
- inserts a `jobs` row into SQLite, then conditionally transitions it
|
|
145
157
|
through `created -> starting -> running`
|
|
146
158
|
- creates a dedicated tmux session, installs `pipe-pane`, waits for the
|
|
@@ -160,6 +172,7 @@ class ShellctlService:
|
|
|
160
172
|
"""
|
|
161
173
|
|
|
162
174
|
cwd = self._resolve_cwd(request.cwd)
|
|
175
|
+
env = self._resolve_env(request.env)
|
|
163
176
|
terminal = request.terminal or TerminalSize(
|
|
164
177
|
cols=self.config.default_terminal_cols,
|
|
165
178
|
rows=self.config.default_terminal_rows,
|
|
@@ -175,6 +188,10 @@ class ShellctlService:
|
|
|
175
188
|
script_path = candidate_job_dir / "script"
|
|
176
189
|
output_path = candidate_job_dir / "output.log"
|
|
177
190
|
script_path.write_text(request.script, encoding="utf-8")
|
|
191
|
+
job_env_path(candidate_job_dir).write_text(
|
|
192
|
+
json.dumps(env, ensure_ascii=False),
|
|
193
|
+
encoding="utf-8",
|
|
194
|
+
)
|
|
178
195
|
output_path.touch()
|
|
179
196
|
row = JobRow(
|
|
180
197
|
job_id=candidate_job_id,
|
|
@@ -540,9 +557,12 @@ class ShellctlService:
|
|
|
540
557
|
async def record_runner_exit(
|
|
541
558
|
self, job_id: str, exit_code: int, ended_at: str
|
|
542
559
|
) -> None:
|
|
543
|
-
"""Persist
|
|
560
|
+
"""Persist a drained normal-exit fact into SQLite.
|
|
544
561
|
|
|
545
|
-
|
|
562
|
+
The usual caller is the tmux pipe finalizer after `sanitize-pty` has
|
|
563
|
+
reached EOF and flushed `output.log`. `_materialize_status_view()` may
|
|
564
|
+
also call this as a recovery path when `.pipe-drained` plus the normal
|
|
565
|
+
exit metadata files exist but SQLite is still non-terminal. The
|
|
546
566
|
statement always records `exit_code` and `ended_at`, but it only changes
|
|
547
567
|
`status` to `exited` when the current row is still non-terminal.
|
|
548
568
|
"""
|
|
@@ -660,6 +680,13 @@ class ShellctlService:
|
|
|
660
680
|
- terminal SQLite status wins and is preserved
|
|
661
681
|
- if SQLite already has an `exit_code` on a non-terminal row, materialize
|
|
662
682
|
`exited`
|
|
683
|
+
- if normal-exit metadata and `.pipe-drained` already exist, recover the
|
|
684
|
+
drained normal exit immediately even when the finalizer has not yet
|
|
685
|
+
committed SQLite state
|
|
686
|
+
- if normal-exit metadata exists and neither `.pipe-drained` nor
|
|
687
|
+
`.pipe-failed` exists, keep the non-terminal row instead of guessing
|
|
688
|
+
`lost` while the pipe finalizer is still responsible for the eventual
|
|
689
|
+
`runner-exit` commit
|
|
663
690
|
- if a live tmux session exists but the output pipe is known-dead,
|
|
664
691
|
conditionally materialize `failed(reason=pipe_failed)`
|
|
665
692
|
- if no live session exists and the row is not protected by the local
|
|
@@ -693,6 +720,10 @@ class ShellctlService:
|
|
|
693
720
|
target=JobStatusName.EXITED,
|
|
694
721
|
ended_at=row.ended_at or format_timestamp(),
|
|
695
722
|
)
|
|
723
|
+
elif (drained_exit := self._drained_normal_exit_metadata(job_id)) is not None:
|
|
724
|
+
exit_code, ended_at = drained_exit
|
|
725
|
+
await self.record_runner_exit(job_id, exit_code, ended_at)
|
|
726
|
+
row = await self._get_job_row(job_id)
|
|
696
727
|
elif session_exists:
|
|
697
728
|
if pipe_active is False:
|
|
698
729
|
if (
|
|
@@ -728,7 +759,9 @@ class ShellctlService:
|
|
|
728
759
|
require_exit_code_null=True,
|
|
729
760
|
)
|
|
730
761
|
else:
|
|
731
|
-
if
|
|
762
|
+
if self._normal_exit_commit_pending(job_id):
|
|
763
|
+
pass
|
|
764
|
+
elif not (
|
|
732
765
|
status in {JobStatusName.CREATED, JobStatusName.STARTING}
|
|
733
766
|
and job_id in self._starting_jobs
|
|
734
767
|
):
|
|
@@ -911,6 +944,36 @@ class ShellctlService:
|
|
|
911
944
|
def _artifact_dir(self, job_id: str) -> Path:
|
|
912
945
|
return self.config.jobs_dir / job_id
|
|
913
946
|
|
|
947
|
+
def _normal_exit_commit_pending(self, job_id: str) -> bool:
|
|
948
|
+
"""Check whether a normal exit is still waiting for pipe drain + commit."""
|
|
949
|
+
|
|
950
|
+
job_dir = self._artifact_dir(job_id)
|
|
951
|
+
return (
|
|
952
|
+
runner_exit_code_path(job_dir).exists()
|
|
953
|
+
and runner_ended_at_path(job_dir).exists()
|
|
954
|
+
and not pipe_drained_path(job_dir).exists()
|
|
955
|
+
and not pipe_failed_path(job_dir).exists()
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
def _drained_normal_exit_metadata(self, job_id: str) -> tuple[int, str] | None:
|
|
959
|
+
"""Read drained normal-exit metadata after successful sanitize drain."""
|
|
960
|
+
|
|
961
|
+
job_dir = self._artifact_dir(job_id)
|
|
962
|
+
drained_path = pipe_drained_path(job_dir)
|
|
963
|
+
exit_code_path = runner_exit_code_path(job_dir)
|
|
964
|
+
ended_at_path = runner_ended_at_path(job_dir)
|
|
965
|
+
if (
|
|
966
|
+
not drained_path.exists()
|
|
967
|
+
or not exit_code_path.exists()
|
|
968
|
+
or not ended_at_path.exists()
|
|
969
|
+
):
|
|
970
|
+
return None
|
|
971
|
+
raw_exit_code = exit_code_path.read_text(encoding="utf-8").strip()
|
|
972
|
+
raw_ended_at = ended_at_path.read_text(encoding="utf-8").strip()
|
|
973
|
+
if not raw_exit_code or not raw_ended_at:
|
|
974
|
+
return None
|
|
975
|
+
return int(raw_exit_code), raw_ended_at
|
|
976
|
+
|
|
914
977
|
def _allocate_job_dir(self) -> tuple[str, Path]:
|
|
915
978
|
"""Atomically allocate a unique artifact directory for a new job.
|
|
916
979
|
|
|
@@ -946,6 +1009,17 @@ class ShellctlService:
|
|
|
946
1009
|
)
|
|
947
1010
|
return cwd
|
|
948
1011
|
|
|
1012
|
+
def _resolve_env(self, raw_env: dict[str, str] | None) -> dict[str, str]:
|
|
1013
|
+
"""Return a detached environment overlay for the generated runner.
|
|
1014
|
+
|
|
1015
|
+
`RunJobRequest` validates names/values before the service sees them.
|
|
1016
|
+
The overlay augments the runner's inherited environment after shellctl
|
|
1017
|
+
scrubs its own control variables, so explicit request values can be used
|
|
1018
|
+
without replacing ambient entries like `PATH`.
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
return dict(raw_env or {})
|
|
1022
|
+
|
|
949
1023
|
def _validate_offset(self, output_path: Path, offset: int) -> None:
|
|
950
1024
|
size = output_path.stat().st_size if output_path.exists() else 0
|
|
951
1025
|
if offset > size:
|
|
@@ -968,11 +1042,58 @@ class ShellctlService:
|
|
|
968
1042
|
self.config.runner_path.chmod(mode | stat.S_IXUSR)
|
|
969
1043
|
|
|
970
1044
|
def _runner_script_source(self) -> str:
|
|
971
|
-
|
|
972
|
-
|
|
1045
|
+
"""Build the bash runner installed into the shellctl runtime directory.
|
|
1046
|
+
|
|
1047
|
+
The wrapper still handles gate waiting and exit metadata in bash, but it
|
|
1048
|
+
delegates launch setup to Python so per-job env overlays can be loaded
|
|
1049
|
+
from JSON without shell-escaping arbitrary values into `export`
|
|
1050
|
+
statements. The Python helper then `exec`s the target process instead of
|
|
1051
|
+
waiting on it, which keeps `SIGINT` behavior identical to running the
|
|
1052
|
+
script directly in the tmux pane. The bootstrap uses `python -c ...`
|
|
1053
|
+
instead of a stdin-fed heredoc so the exec'd job still inherits the tmux
|
|
1054
|
+
pane's PTY on fd 0 and can receive later `send_input()` data.
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
973
1057
|
auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
|
|
974
|
-
|
|
975
|
-
|
|
1058
|
+
bootstrap_source = shlex.quote(
|
|
1059
|
+
"""
|
|
1060
|
+
import json
|
|
1061
|
+
import os
|
|
1062
|
+
import stat
|
|
1063
|
+
import sys
|
|
1064
|
+
from pathlib import Path
|
|
1065
|
+
|
|
1066
|
+
script_path = Path(sys.argv[1])
|
|
1067
|
+
cwd = sys.argv[2]
|
|
1068
|
+
env_path = Path(sys.argv[3])
|
|
1069
|
+
|
|
1070
|
+
env = os.environ.copy()
|
|
1071
|
+
if env_path.exists():
|
|
1072
|
+
env.update(json.loads(env_path.read_text(encoding="utf-8")))
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
os.chdir(cwd)
|
|
1076
|
+
except OSError:
|
|
1077
|
+
raise SystemExit(111)
|
|
1078
|
+
|
|
1079
|
+
with script_path.open("r", encoding="utf-8") as handle:
|
|
1080
|
+
first_line = handle.readline()
|
|
1081
|
+
|
|
1082
|
+
if first_line.startswith("#!"):
|
|
1083
|
+
script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR)
|
|
1084
|
+
argv = [str(script_path)]
|
|
1085
|
+
else:
|
|
1086
|
+
argv = ["sh", str(script_path)]
|
|
1087
|
+
|
|
1088
|
+
try:
|
|
1089
|
+
os.execvpe(argv[0], argv, env)
|
|
1090
|
+
except FileNotFoundError as exc:
|
|
1091
|
+
print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
|
|
1092
|
+
raise SystemExit(127) from exc
|
|
1093
|
+
except OSError as exc:
|
|
1094
|
+
print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
|
|
1095
|
+
raise SystemExit(126) from exc
|
|
1096
|
+
""".strip()
|
|
976
1097
|
)
|
|
977
1098
|
return f"""#!/usr/bin/env bash
|
|
978
1099
|
set -uo pipefail
|
|
@@ -981,7 +1102,18 @@ JOB_DIR="$1"
|
|
|
981
1102
|
JOB_ID="$2"
|
|
982
1103
|
CWD="$3"
|
|
983
1104
|
SCRIPT_PATH="$JOB_DIR/script"
|
|
1105
|
+
ENV_PATH="$JOB_DIR/{JOB_ENV_FILENAME}"
|
|
984
1106
|
START_GATE="$JOB_DIR/start-gate"
|
|
1107
|
+
RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
|
|
1108
|
+
RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
|
|
1109
|
+
|
|
1110
|
+
write_atomic() {{
|
|
1111
|
+
local dest="$1"
|
|
1112
|
+
local value="$2"
|
|
1113
|
+
local tmp="${{dest}}.tmp.$$"
|
|
1114
|
+
printf '%s\n' "$value" > "$tmp"
|
|
1115
|
+
mv "$tmp" "$dest"
|
|
1116
|
+
}}
|
|
985
1117
|
|
|
986
1118
|
while [ ! -e "$START_GATE" ]; do
|
|
987
1119
|
sleep 0.05
|
|
@@ -994,18 +1126,8 @@ unset SHELLCTL_TMUX_SOCKET
|
|
|
994
1126
|
unset SHELLCTL_RUNNER
|
|
995
1127
|
unset {auth_env}
|
|
996
1128
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
chmod +x "$SCRIPT_PATH"
|
|
1000
|
-
"$SCRIPT_PATH"
|
|
1001
|
-
EXIT_CODE=$?
|
|
1002
|
-
else
|
|
1003
|
-
sh "$SCRIPT_PATH"
|
|
1004
|
-
EXIT_CODE=$?
|
|
1005
|
-
fi
|
|
1006
|
-
else
|
|
1007
|
-
EXIT_CODE=111
|
|
1008
|
-
fi
|
|
1129
|
+
{shlex.quote(sys.executable)} -c {bootstrap_source} "$SCRIPT_PATH" "$CWD" "$ENV_PATH"
|
|
1130
|
+
EXIT_CODE=$?
|
|
1009
1131
|
|
|
1010
1132
|
ENDED_AT="$({shlex.quote(sys.executable)} - <<'PY'
|
|
1011
1133
|
from datetime import UTC, datetime
|
|
@@ -1013,8 +1135,9 @@ print(datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'
|
|
|
1013
1135
|
PY
|
|
1014
1136
|
)"
|
|
1015
1137
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1138
|
+
write_atomic "$RUNNER_EXIT_CODE_PATH" "$EXIT_CODE"
|
|
1139
|
+
write_atomic "$RUNNER_ENDED_AT_PATH" "$ENDED_AT"
|
|
1140
|
+
|
|
1018
1141
|
exit "$EXIT_CODE"
|
|
1019
1142
|
"""
|
|
1020
1143
|
|
|
@@ -16,6 +16,12 @@ from typing import Protocol, cast
|
|
|
16
16
|
|
|
17
17
|
import anyio
|
|
18
18
|
|
|
19
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
20
|
+
pipe_drained_path,
|
|
21
|
+
pipe_failed_path,
|
|
22
|
+
runner_ended_at_path,
|
|
23
|
+
runner_exit_code_path,
|
|
24
|
+
)
|
|
19
25
|
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
20
26
|
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
21
27
|
from shell_session_manager.shellctl.shared import (
|
|
@@ -148,16 +154,10 @@ class TmuxController:
|
|
|
148
154
|
async def enable_output_pipe(
|
|
149
155
|
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
150
156
|
) -> None:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"--ready-file",
|
|
156
|
-
str(ready_file),
|
|
157
|
-
)
|
|
158
|
-
)
|
|
159
|
-
output_command = (
|
|
160
|
-
f"{sanitize_command} >> {shlex.quote(str(job_dir / 'output.log'))}"
|
|
157
|
+
output_command = self._pipe_command_source(
|
|
158
|
+
job_id=job_id,
|
|
159
|
+
job_dir=job_dir,
|
|
160
|
+
ready_file=ready_file,
|
|
161
161
|
)
|
|
162
162
|
result = await self._run_tmux(
|
|
163
163
|
"pipe-pane",
|
|
@@ -175,6 +175,55 @@ class TmuxController:
|
|
|
175
175
|
or f"Failed to attach output pipe for {job_id}",
|
|
176
176
|
)
|
|
177
177
|
|
|
178
|
+
def _pipe_command_source(
|
|
179
|
+
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Build the tmux `pipe-pane` command that drains and finalizes output.
|
|
182
|
+
|
|
183
|
+
For normal exits, the runner now records completion metadata into job
|
|
184
|
+
artifacts and the pipe finalizer commits `runner-exit` only after
|
|
185
|
+
`sanitize-pty` reaches EOF and flushes `output.log` successfully.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
sanitize_command = self._shell_join(
|
|
189
|
+
(
|
|
190
|
+
*self._config.shellctl_command,
|
|
191
|
+
"sanitize-pty",
|
|
192
|
+
"--ready-file",
|
|
193
|
+
str(ready_file),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
runner_exit_command = self._shell_join(
|
|
197
|
+
(
|
|
198
|
+
*self._config.shellctl_command,
|
|
199
|
+
"runner-exit",
|
|
200
|
+
"--state-dir",
|
|
201
|
+
str(self._config.state_dir),
|
|
202
|
+
"--job-id",
|
|
203
|
+
job_id,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
output_path = shlex.quote(str(job_dir / "output.log"))
|
|
207
|
+
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
|
208
|
+
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
|
209
|
+
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
|
210
|
+
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
|
211
|
+
return " ; ".join(
|
|
212
|
+
[
|
|
213
|
+
f"{sanitize_command} >> {output_path}",
|
|
214
|
+
"sanitize_status=$?",
|
|
215
|
+
(
|
|
216
|
+
'if [ "$sanitize_status" -eq 0 ]; then '
|
|
217
|
+
f": > {drained_path}; "
|
|
218
|
+
f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
|
|
219
|
+
f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
|
|
220
|
+
f'--ended-at "$(cat {ended_at_path})"; fi; '
|
|
221
|
+
f"else : > {failed_path}; fi"
|
|
222
|
+
),
|
|
223
|
+
'exit "$sanitize_status"',
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
178
227
|
async def send_input(self, *, job_id: str, text: str) -> None:
|
|
179
228
|
buffer_name = f"shellctl-in-{job_id}"
|
|
180
229
|
runtime_dir = cast(Path, self._config.runtime_dir)
|
|
@@ -286,6 +335,7 @@ def _tmux_target_missing(stderr: str) -> bool:
|
|
|
286
335
|
or "can't find session" in normalized
|
|
287
336
|
or "no server running" in normalized
|
|
288
337
|
or "failed to connect" in normalized
|
|
338
|
+
or "server exited unexpectedly" in normalized
|
|
289
339
|
)
|
|
290
340
|
|
|
291
341
|
|
|
@@ -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,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import importlib
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
5
8
|
import sqlite3
|
|
6
9
|
import subprocess
|
|
7
10
|
import sys
|
|
@@ -23,6 +26,15 @@ from shell_session_manager.shellctl.server import (
|
|
|
23
26
|
cli,
|
|
24
27
|
create_app,
|
|
25
28
|
)
|
|
29
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
30
|
+
JOB_ENV_FILENAME,
|
|
31
|
+
PIPE_DRAINED_FILENAME,
|
|
32
|
+
PIPE_FAILED_FILENAME,
|
|
33
|
+
RUNNER_ENDED_AT_FILENAME,
|
|
34
|
+
RUNNER_EXIT_CODE_FILENAME,
|
|
35
|
+
job_env_path,
|
|
36
|
+
)
|
|
37
|
+
from shell_session_manager.shellctl.server.tmux import TmuxController
|
|
26
38
|
from shell_session_manager.shellctl.shared import (
|
|
27
39
|
DEFAULT_AUTH_TOKEN_ENV,
|
|
28
40
|
InputJobRequest,
|
|
@@ -106,6 +118,62 @@ async def _create_service(
|
|
|
106
118
|
return service, fake_tmux
|
|
107
119
|
|
|
108
120
|
|
|
121
|
+
async def _create_real_service(
|
|
122
|
+
tmp_path: Path,
|
|
123
|
+
*,
|
|
124
|
+
shellctl_command: tuple[str, ...] | None = None,
|
|
125
|
+
) -> ShellctlService:
|
|
126
|
+
config = ShellctlConfig(
|
|
127
|
+
state_dir=tmp_path / "state",
|
|
128
|
+
runtime_dir=tmp_path / "run",
|
|
129
|
+
shellctl_command=shellctl_command
|
|
130
|
+
or ShellctlConfig(
|
|
131
|
+
state_dir=tmp_path / "default-state",
|
|
132
|
+
runtime_dir=tmp_path / "default-run",
|
|
133
|
+
).shellctl_command,
|
|
134
|
+
)
|
|
135
|
+
service = ShellctlService(config)
|
|
136
|
+
await service.initialize()
|
|
137
|
+
return service
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_delayed_sanitize_wrapper(
|
|
141
|
+
tmp_path: Path,
|
|
142
|
+
*,
|
|
143
|
+
delay_seconds: float,
|
|
144
|
+
sanitize_exit_code: int | None = None,
|
|
145
|
+
) -> tuple[str, ...]:
|
|
146
|
+
wrapper_path = tmp_path / "delayed-sanitize-wrapper.py"
|
|
147
|
+
wrapper_path.write_text(
|
|
148
|
+
"\n".join(
|
|
149
|
+
[
|
|
150
|
+
"from __future__ import annotations",
|
|
151
|
+
"",
|
|
152
|
+
"import subprocess",
|
|
153
|
+
"import sys",
|
|
154
|
+
"import time",
|
|
155
|
+
"from pathlib import Path",
|
|
156
|
+
"",
|
|
157
|
+
f"REAL = [{sys.executable!r}, '-m', 'shell_session_manager.shellctl.server']",
|
|
158
|
+
"args = sys.argv[1:]",
|
|
159
|
+
"if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
|
|
160
|
+
" ready_file = Path(args[args.index('--ready-file') + 1])",
|
|
161
|
+
" ready_file.touch()",
|
|
162
|
+
f" time.sleep({delay_seconds!r})",
|
|
163
|
+
(
|
|
164
|
+
f" raise SystemExit({sanitize_exit_code!r})"
|
|
165
|
+
if sanitize_exit_code is not None
|
|
166
|
+
else ""
|
|
167
|
+
),
|
|
168
|
+
"raise SystemExit(subprocess.run(REAL + args, check=False).returncode)",
|
|
169
|
+
"",
|
|
170
|
+
]
|
|
171
|
+
),
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
return (sys.executable, str(wrapper_path))
|
|
175
|
+
|
|
176
|
+
|
|
109
177
|
def _capture_serve_config(
|
|
110
178
|
monkeypatch: pytest.MonkeyPatch,
|
|
111
179
|
) -> tuple[dict[str, object], CliRunner]:
|
|
@@ -287,6 +355,7 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
|
|
|
287
355
|
RunJobRequest(
|
|
288
356
|
script="printf ready\n",
|
|
289
357
|
cwd=str(tmp_path),
|
|
358
|
+
env={"HELLO": "world", "UNICODE": "盐粒"},
|
|
290
359
|
terminal=TerminalSize(cols=100, rows=40),
|
|
291
360
|
timeout=0.01,
|
|
292
361
|
output_limit=8192,
|
|
@@ -307,6 +376,10 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
|
|
|
307
376
|
assert row.output_path == f"jobs/{result.job_id}/output.log"
|
|
308
377
|
assert row.started_at is not None
|
|
309
378
|
assert (job_dir / "script").exists()
|
|
379
|
+
assert json.loads(job_env_path(job_dir).read_text(encoding="utf-8")) == {
|
|
380
|
+
"HELLO": "world",
|
|
381
|
+
"UNICODE": "盐粒",
|
|
382
|
+
}
|
|
310
383
|
assert (job_dir / "output.log").exists()
|
|
311
384
|
assert (job_dir / "start-gate").exists()
|
|
312
385
|
assert job_session_name(result.job_id) in fake_tmux.sessions
|
|
@@ -324,6 +397,373 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
|
|
|
324
397
|
await service.shutdown()
|
|
325
398
|
|
|
326
399
|
|
|
400
|
+
@pytest.mark.anyio
|
|
401
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
402
|
+
async def test_run_job_env_overlay_is_visible_without_replacing_inherited_env(
|
|
403
|
+
tmp_path: Path,
|
|
404
|
+
) -> None:
|
|
405
|
+
service = await _create_real_service(tmp_path)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
result = await service.run_job(
|
|
409
|
+
RunJobRequest(
|
|
410
|
+
script=(
|
|
411
|
+
"python3 - <<'PY'\n"
|
|
412
|
+
"import os\n"
|
|
413
|
+
"print(os.environ['SHELLCTL_PRESET'])\n"
|
|
414
|
+
"print('PATH' in os.environ)\n"
|
|
415
|
+
"PY\n"
|
|
416
|
+
),
|
|
417
|
+
cwd=str(tmp_path),
|
|
418
|
+
env={"SHELLCTL_PRESET": "from-client"},
|
|
419
|
+
terminal=TerminalSize(),
|
|
420
|
+
timeout=5,
|
|
421
|
+
output_limit=8192,
|
|
422
|
+
idle_flush_seconds=1,
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
assert result.done is True
|
|
427
|
+
assert result.status is JobStatusName.EXITED
|
|
428
|
+
assert result.exit_code == 0
|
|
429
|
+
assert result.output == "from-client\nTrue\n"
|
|
430
|
+
finally:
|
|
431
|
+
await service.shutdown()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@pytest.mark.anyio
|
|
435
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
436
|
+
async def test_run_job_env_overlay_overrides_inherited_value(
|
|
437
|
+
tmp_path: Path,
|
|
438
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
439
|
+
) -> None:
|
|
440
|
+
monkeypatch.setenv("SHELLCTL_PRESET", "parent")
|
|
441
|
+
service = await _create_real_service(tmp_path)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
result = await service.run_job(
|
|
445
|
+
RunJobRequest(
|
|
446
|
+
script=(
|
|
447
|
+
"python3 - <<'PY'\n"
|
|
448
|
+
"import os\n"
|
|
449
|
+
"print(os.environ['SHELLCTL_PRESET'])\n"
|
|
450
|
+
"PY\n"
|
|
451
|
+
),
|
|
452
|
+
cwd=str(tmp_path),
|
|
453
|
+
env={"SHELLCTL_PRESET": "from-client"},
|
|
454
|
+
terminal=TerminalSize(),
|
|
455
|
+
timeout=5,
|
|
456
|
+
output_limit=8192,
|
|
457
|
+
idle_flush_seconds=1,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
assert result.done is True
|
|
462
|
+
assert result.status is JobStatusName.EXITED
|
|
463
|
+
assert result.exit_code == 0
|
|
464
|
+
assert result.output == "from-client\n"
|
|
465
|
+
finally:
|
|
466
|
+
await service.shutdown()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@pytest.mark.anyio
|
|
470
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
471
|
+
async def test_send_input_reaches_real_job_stdin_after_env_bootstrap(
|
|
472
|
+
tmp_path: Path,
|
|
473
|
+
) -> None:
|
|
474
|
+
service = await _create_real_service(tmp_path)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
initial = await service.run_job(
|
|
478
|
+
RunJobRequest(
|
|
479
|
+
script=(
|
|
480
|
+
"printf 'ready\\n'\n"
|
|
481
|
+
"IFS= read -r line\n"
|
|
482
|
+
"printf 'got:%s\\n' \"$line\"\n"
|
|
483
|
+
),
|
|
484
|
+
cwd=str(tmp_path),
|
|
485
|
+
terminal=TerminalSize(),
|
|
486
|
+
timeout=5,
|
|
487
|
+
output_limit=8192,
|
|
488
|
+
idle_flush_seconds=0.01,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
ready_window = initial
|
|
493
|
+
if ready_window.output == "":
|
|
494
|
+
ready_window = await service.wait_job(
|
|
495
|
+
initial.job_id,
|
|
496
|
+
WaitJobRequest(
|
|
497
|
+
offset=initial.offset,
|
|
498
|
+
timeout=1,
|
|
499
|
+
output_limit=8192,
|
|
500
|
+
idle_flush_seconds=0.01,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
result = await service.send_input(
|
|
505
|
+
initial.job_id,
|
|
506
|
+
InputJobRequest(
|
|
507
|
+
text="hello from stdin\n",
|
|
508
|
+
offset=ready_window.offset,
|
|
509
|
+
timeout=1,
|
|
510
|
+
output_limit=8192,
|
|
511
|
+
idle_flush_seconds=0.01,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
output_after_input = result.output
|
|
516
|
+
final = result
|
|
517
|
+
if not final.done:
|
|
518
|
+
# `send_input()` shares wait semantics with `wait_job()`: it may
|
|
519
|
+
# return after the post-input output flushes but before tmux exit
|
|
520
|
+
# artifacts have materialized into a terminal DB status on slower CI.
|
|
521
|
+
final = await service.wait_job(
|
|
522
|
+
initial.job_id,
|
|
523
|
+
WaitJobRequest(
|
|
524
|
+
offset=result.offset,
|
|
525
|
+
timeout=5,
|
|
526
|
+
output_limit=8192,
|
|
527
|
+
idle_flush_seconds=0.01,
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
output_after_input += final.output
|
|
531
|
+
|
|
532
|
+
assert ready_window.done is False
|
|
533
|
+
assert final.done is True
|
|
534
|
+
assert final.status is JobStatusName.EXITED
|
|
535
|
+
assert final.exit_code == 0
|
|
536
|
+
assert output_after_input.endswith("got:hello from stdin\n")
|
|
537
|
+
finally:
|
|
538
|
+
await service.shutdown()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@pytest.mark.anyio
|
|
542
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
543
|
+
async def test_terminated_job_does_not_emit_python_wrapper_traceback(
|
|
544
|
+
tmp_path: Path,
|
|
545
|
+
) -> None:
|
|
546
|
+
service = await _create_real_service(tmp_path)
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
initial = await service.run_job(
|
|
550
|
+
RunJobRequest(
|
|
551
|
+
script=(
|
|
552
|
+
"trap 'exit 130' INT\n"
|
|
553
|
+
"printf 'ready\\n'\n"
|
|
554
|
+
"while :; do sleep 1; done\n"
|
|
555
|
+
),
|
|
556
|
+
cwd=str(tmp_path),
|
|
557
|
+
terminal=TerminalSize(),
|
|
558
|
+
timeout=5,
|
|
559
|
+
output_limit=8192,
|
|
560
|
+
idle_flush_seconds=0.01,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
ready_window = initial
|
|
565
|
+
if ready_window.output == "":
|
|
566
|
+
ready_window = await service.wait_job(
|
|
567
|
+
initial.job_id,
|
|
568
|
+
WaitJobRequest(
|
|
569
|
+
offset=initial.offset,
|
|
570
|
+
timeout=1,
|
|
571
|
+
output_limit=8192,
|
|
572
|
+
idle_flush_seconds=0.01,
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
assert ready_window.done is False
|
|
576
|
+
|
|
577
|
+
terminated = await service.terminate_job(
|
|
578
|
+
initial.job_id,
|
|
579
|
+
TerminateJobRequest(grace_seconds=0.2),
|
|
580
|
+
)
|
|
581
|
+
final = await service.wait_job(
|
|
582
|
+
initial.job_id,
|
|
583
|
+
WaitJobRequest(
|
|
584
|
+
offset=ready_window.offset,
|
|
585
|
+
timeout=1,
|
|
586
|
+
output_limit=8192,
|
|
587
|
+
idle_flush_seconds=0.01,
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
assert terminated.status is JobStatusName.TERMINATED
|
|
592
|
+
assert final.done is True
|
|
593
|
+
assert "KeyboardInterrupt" not in final.output
|
|
594
|
+
assert "Traceback (most recent call last)" not in final.output
|
|
595
|
+
finally:
|
|
596
|
+
await service.shutdown()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@pytest.mark.anyio
|
|
600
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
601
|
+
async def test_run_job_returns_flushed_output_on_first_terminal_result(
|
|
602
|
+
tmp_path: Path,
|
|
603
|
+
) -> None:
|
|
604
|
+
shellctl_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
|
|
605
|
+
service = await _create_real_service(
|
|
606
|
+
tmp_path,
|
|
607
|
+
shellctl_command=shellctl_command,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
result = await service.run_job(
|
|
612
|
+
RunJobRequest(
|
|
613
|
+
script="printf 'delayed-flush\\n'\n",
|
|
614
|
+
cwd=str(tmp_path),
|
|
615
|
+
terminal=TerminalSize(),
|
|
616
|
+
timeout=3,
|
|
617
|
+
output_limit=8192,
|
|
618
|
+
idle_flush_seconds=1,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
reread = await service.wait_job(
|
|
623
|
+
result.job_id,
|
|
624
|
+
WaitJobRequest(
|
|
625
|
+
offset=0,
|
|
626
|
+
timeout=0.001,
|
|
627
|
+
output_limit=8192,
|
|
628
|
+
idle_flush_seconds=0.01,
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
assert result.done is True
|
|
633
|
+
assert result.status is JobStatusName.EXITED
|
|
634
|
+
assert result.exit_code == 0
|
|
635
|
+
assert result.output == "delayed-flush\n"
|
|
636
|
+
assert reread.output == "delayed-flush\n"
|
|
637
|
+
assert reread.offset == result.offset
|
|
638
|
+
finally:
|
|
639
|
+
await service.shutdown()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@pytest.mark.anyio
|
|
643
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
644
|
+
async def test_sanitize_failure_does_not_commit_normal_exit(
|
|
645
|
+
tmp_path: Path,
|
|
646
|
+
) -> None:
|
|
647
|
+
shellctl_command = _write_delayed_sanitize_wrapper(
|
|
648
|
+
tmp_path,
|
|
649
|
+
delay_seconds=0.2,
|
|
650
|
+
sanitize_exit_code=7,
|
|
651
|
+
)
|
|
652
|
+
service = await _create_real_service(
|
|
653
|
+
tmp_path,
|
|
654
|
+
shellctl_command=shellctl_command,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
result = await service.run_job(
|
|
659
|
+
RunJobRequest(
|
|
660
|
+
script="printf 'missing-output\\n'\n",
|
|
661
|
+
cwd=str(tmp_path),
|
|
662
|
+
terminal=TerminalSize(),
|
|
663
|
+
timeout=3,
|
|
664
|
+
output_limit=8192,
|
|
665
|
+
idle_flush_seconds=1,
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
reread = await service.wait_job(
|
|
670
|
+
result.job_id,
|
|
671
|
+
WaitJobRequest(
|
|
672
|
+
offset=0,
|
|
673
|
+
timeout=0.001,
|
|
674
|
+
output_limit=8192,
|
|
675
|
+
idle_flush_seconds=0.01,
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
job_dir = service.config.jobs_dir / result.job_id
|
|
679
|
+
|
|
680
|
+
assert result.done is True
|
|
681
|
+
assert result.status is not JobStatusName.EXITED
|
|
682
|
+
assert result.exit_code is None
|
|
683
|
+
assert result.output == ""
|
|
684
|
+
assert reread.output == ""
|
|
685
|
+
assert not (job_dir / PIPE_DRAINED_FILENAME).exists()
|
|
686
|
+
assert (job_dir / PIPE_FAILED_FILENAME).exists()
|
|
687
|
+
finally:
|
|
688
|
+
await service.shutdown()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@pytest.mark.anyio
|
|
692
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
693
|
+
@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is required")
|
|
694
|
+
async def test_uv_quiet_shebang_returns_output_in_first_terminal_result(
|
|
695
|
+
tmp_path: Path,
|
|
696
|
+
) -> None:
|
|
697
|
+
service = await _create_real_service(tmp_path)
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
result = await service.run_job(
|
|
701
|
+
RunJobRequest(
|
|
702
|
+
script=(
|
|
703
|
+
"#!/usr/bin/env -S uv run --script --quiet\n"
|
|
704
|
+
"# /// script\n"
|
|
705
|
+
'# requires-python = ">=3.12"\n'
|
|
706
|
+
"# dependencies = []\n"
|
|
707
|
+
"# ///\n"
|
|
708
|
+
'print("hello")\n'
|
|
709
|
+
),
|
|
710
|
+
cwd=str(tmp_path),
|
|
711
|
+
terminal=TerminalSize(),
|
|
712
|
+
timeout=10,
|
|
713
|
+
output_limit=8192,
|
|
714
|
+
idle_flush_seconds=1,
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
assert result.done is True
|
|
719
|
+
assert result.status is JobStatusName.EXITED
|
|
720
|
+
assert result.exit_code == 0
|
|
721
|
+
assert result.output == "hello\n"
|
|
722
|
+
finally:
|
|
723
|
+
await service.shutdown()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@pytest.mark.anyio
|
|
727
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
728
|
+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
|
|
729
|
+
@pytest.mark.parametrize(
|
|
730
|
+
("script", "expected_output"),
|
|
731
|
+
[
|
|
732
|
+
("printf 'no-shebang\\n'\n", "no-shebang\n"),
|
|
733
|
+
("#!/bin/sh\nprintf 'sh-shebang\\n'\n", "sh-shebang\n"),
|
|
734
|
+
(
|
|
735
|
+
"#!/usr/bin/env bash\nprintf 'bash-shebang\\n'\n",
|
|
736
|
+
"bash-shebang\n",
|
|
737
|
+
),
|
|
738
|
+
],
|
|
739
|
+
)
|
|
740
|
+
async def test_existing_script_modes_still_return_output(
|
|
741
|
+
tmp_path: Path,
|
|
742
|
+
script: str,
|
|
743
|
+
expected_output: str,
|
|
744
|
+
) -> None:
|
|
745
|
+
service = await _create_real_service(tmp_path)
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
result = await service.run_job(
|
|
749
|
+
RunJobRequest(
|
|
750
|
+
script=script,
|
|
751
|
+
cwd=str(tmp_path),
|
|
752
|
+
terminal=TerminalSize(),
|
|
753
|
+
timeout=5,
|
|
754
|
+
output_limit=8192,
|
|
755
|
+
idle_flush_seconds=1,
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
assert result.done is True
|
|
760
|
+
assert result.status is JobStatusName.EXITED
|
|
761
|
+
assert result.exit_code == 0
|
|
762
|
+
assert result.output == expected_output
|
|
763
|
+
finally:
|
|
764
|
+
await service.shutdown()
|
|
765
|
+
|
|
766
|
+
|
|
327
767
|
@pytest.mark.anyio
|
|
328
768
|
async def test_run_job_retries_after_sqlite_insert_conflict_and_cleans_artifacts(
|
|
329
769
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
@@ -557,6 +997,32 @@ async def test_session_disappearing_between_probes_materializes_lost_not_pipe_fa
|
|
|
557
997
|
await service.shutdown()
|
|
558
998
|
|
|
559
999
|
|
|
1000
|
+
@pytest.mark.anyio
|
|
1001
|
+
async def test_drained_uncommitted_normal_exit_self_commits_to_exited(
|
|
1002
|
+
tmp_path: Path,
|
|
1003
|
+
) -> None:
|
|
1004
|
+
service, _fake_tmux = await _create_service(tmp_path)
|
|
1005
|
+
job_id = "05211530-k7p"
|
|
1006
|
+
await _seed_job(service, job_id=job_id, status=JobStatusName.RUNNING)
|
|
1007
|
+
job_dir = service.config.jobs_dir / job_id
|
|
1008
|
+
(job_dir / RUNNER_EXIT_CODE_FILENAME).write_text("0\n", encoding="utf-8")
|
|
1009
|
+
(job_dir / RUNNER_ENDED_AT_FILENAME).write_text(
|
|
1010
|
+
"2026-05-21T15:30:20Z\n", encoding="utf-8"
|
|
1011
|
+
)
|
|
1012
|
+
(job_dir / PIPE_DRAINED_FILENAME).touch()
|
|
1013
|
+
|
|
1014
|
+
view = await service.get_job_status(job_id)
|
|
1015
|
+
row = await service._get_job_row(job_id)
|
|
1016
|
+
|
|
1017
|
+
assert view.status is JobStatusName.EXITED
|
|
1018
|
+
assert view.done is True
|
|
1019
|
+
assert view.exit_code == 0
|
|
1020
|
+
assert row.status == JobStatusName.EXITED.value
|
|
1021
|
+
assert row.exit_code == 0
|
|
1022
|
+
assert row.ended_at == "2026-05-21T15:30:20Z"
|
|
1023
|
+
await service.shutdown()
|
|
1024
|
+
|
|
1025
|
+
|
|
560
1026
|
@pytest.mark.anyio
|
|
561
1027
|
async def test_list_jobs_ignores_half_created_directory_and_uses_db_rows(
|
|
562
1028
|
tmp_path: Path,
|
|
@@ -1074,6 +1540,45 @@ def test_serve_cli_reads_auth_token_from_environment(
|
|
|
1074
1540
|
assert config.auth_token == "env-token"
|
|
1075
1541
|
|
|
1076
1542
|
|
|
1543
|
+
def test_runner_script_records_completion_metadata_without_direct_runner_exit(
|
|
1544
|
+
tmp_path: Path,
|
|
1545
|
+
) -> None:
|
|
1546
|
+
service = ShellctlService(
|
|
1547
|
+
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
|
|
1548
|
+
tmux=FakeTmuxController(),
|
|
1549
|
+
)
|
|
1550
|
+
source = service._runner_script_source()
|
|
1551
|
+
|
|
1552
|
+
assert JOB_ENV_FILENAME in source
|
|
1553
|
+
assert RUNNER_EXIT_CODE_FILENAME in source
|
|
1554
|
+
assert RUNNER_ENDED_AT_FILENAME in source
|
|
1555
|
+
assert "write_atomic" in source
|
|
1556
|
+
assert 'mv "$tmp" "$dest"' in source
|
|
1557
|
+
assert re.search(r"\brunner-exit\s+--state-dir\b", source) is None
|
|
1558
|
+
anyio.run(service.shutdown)
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
|
|
1562
|
+
controller = TmuxController(
|
|
1563
|
+
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
1564
|
+
)
|
|
1565
|
+
source = controller._pipe_command_source(
|
|
1566
|
+
job_id="05211530-k7p",
|
|
1567
|
+
job_dir=tmp_path / "state" / "jobs" / "05211530-k7p",
|
|
1568
|
+
ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
assert "sanitize-pty" in source
|
|
1572
|
+
assert PIPE_DRAINED_FILENAME in source
|
|
1573
|
+
assert PIPE_FAILED_FILENAME in source
|
|
1574
|
+
assert RUNNER_EXIT_CODE_FILENAME in source
|
|
1575
|
+
assert RUNNER_ENDED_AT_FILENAME in source
|
|
1576
|
+
assert re.search(r"\brunner-exit\b", source) is not None
|
|
1577
|
+
assert 'if [ "$sanitize_status" -eq 0 ]' in source
|
|
1578
|
+
assert source.index("sanitize-pty") < source.index(PIPE_DRAINED_FILENAME)
|
|
1579
|
+
assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
|
|
1580
|
+
|
|
1581
|
+
|
|
1077
1582
|
def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
|
|
1078
1583
|
async def setup_running_job() -> ShellctlConfig:
|
|
1079
1584
|
service, _fake_tmux = await _create_service(tmp_path)
|
|
@@ -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.0 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|