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.
Files changed (32) hide show
  1. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/PKG-INFO +1 -1
  2. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/pyproject.toml +1 -1
  3. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
  4. shell_session_manager-2.2.0/src/shell_session_manager/shellctl/server/artifacts.py +54 -0
  5. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/service.py +145 -22
  6. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
  7. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
  8. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_client.py +7 -1
  9. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_service.py +505 -0
  10. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shellctl_shared.py +20 -0
  11. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/LICENSE +0 -0
  12. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/README.md +0 -0
  13. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py +0 -0
  14. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed +0 -0
  15. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/session.py +0 -0
  16. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
  17. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  18. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
  19. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  20. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/api.py +0 -0
  21. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/cli.py +0 -0
  22. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/config.py +0 -0
  23. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
  24. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  25. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  26. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  27. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  28. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  29. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  30. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json +0 -0
  31. {shell_session_manager-2.1.0 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py +0 -0
  32. {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.1.0
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.1.0"
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` / `output.log`
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 the runner's exit fact into SQLite.
560
+ """Persist a drained normal-exit fact into SQLite.
544
561
 
545
- This is the source-of-truth replacement for the old `exit.json`. The
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 not (
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
- tmux_socket = shlex.quote(str(self.config.tmux_socket))
972
- state_dir = shlex.quote(str(self.config.state_dir))
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
- shellctl_command = " ".join(
975
- shlex.quote(part) for part in self.config.shellctl_command
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
- if cd "$CWD"; then
998
- if IFS= read -r FIRST_LINE < "$SCRIPT_PATH" && [[ "$FIRST_LINE" == '#!'* ]]; then
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
- {shellctl_command} runner-exit --state-dir {state_dir} --job-id "$JOB_ID" --exit-code "$EXIT_CODE" --ended-at "$ENDED_AT"
1017
- env -u TMUX tmux -S {tmux_socket} kill-session -t "shellctl-job-$JOB_ID" >/dev/null 2>&1 || true
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
- sanitize_command = self._shell_join(
152
- (
153
- *self._config.shellctl_command,
154
- "sanitize-pty",
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("printf ready\\n", cwd="/tmp", timeout=12)
42
+ await client.run(
43
+ "printf ready\\n",
44
+ cwd="/tmp",
45
+ env={"HELLO": "world"},
46
+ timeout=12,
47
+ )
43
48
 
44
49
  assert captured["method"] == "POST"
45
50
  assert captured["path"] == "/v1/jobs/run"
@@ -47,6 +52,7 @@ async def test_shellctl_client_run_injects_headers_and_instance_defaults(
47
52
  assert captured["json"] == {
48
53
  "script": "printf ready\\n",
49
54
  "cwd": "/tmp",
55
+ "env": {"HELLO": "world"},
50
56
  "timeout": 12.0,
51
57
  "output_limit": 4096,
52
58
  "idle_flush_seconds": 0.25,
@@ -1,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)