shell-session-manager 2.1.1__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.1 → shell_session_manager-2.2.0}/PKG-INFO +1 -1
  2. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/pyproject.toml +1 -1
  3. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
  4. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/artifacts.py +11 -2
  5. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/service.py +76 -13
  6. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
  7. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_client.py +7 -1
  8. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_service.py +208 -0
  9. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_shared.py +20 -0
  10. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/LICENSE +0 -0
  11. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/README.md +0 -0
  12. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py +0 -0
  13. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed +0 -0
  14. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/session.py +0 -0
  15. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
  16. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  17. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
  18. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  19. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/api.py +0 -0
  20. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/cli.py +0 -0
  21. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/config.py +0 -0
  22. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
  23. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  24. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/tmux.py +0 -0
  25. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  26. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  27. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  28. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  29. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  30. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json +0 -0
  31. {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py +0 -0
  32. {shell_session_manager-2.1.1 → 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.1
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.1"
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,
@@ -3,8 +3,10 @@
3
3
  Normal job completion is coordinated through small marker files inside each
4
4
  `jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
5
5
  SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
6
- drained into `output.log`. A separate failure marker is used so an unsuccessful
7
- sanitizer run does not masquerade as a drained normal exit.
6
+ drained into `output.log`. The same artifact directory also stores the request's
7
+ environment overlay so the runner can merge arbitrary key/value pairs without
8
+ shell-escaping them into the generated script. A separate failure marker is used
9
+ so an unsuccessful sanitizer run does not masquerade as a drained normal exit.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -13,6 +15,7 @@ from pathlib import Path
13
15
 
14
16
  RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
15
17
  RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
18
+ JOB_ENV_FILENAME = ".job-env.json"
16
19
  PIPE_DRAINED_FILENAME = ".pipe-drained"
17
20
  PIPE_FAILED_FILENAME = ".pipe-failed"
18
21
 
@@ -25,6 +28,10 @@ def runner_ended_at_path(job_dir: Path) -> Path:
25
28
  return job_dir / RUNNER_ENDED_AT_FILENAME
26
29
 
27
30
 
31
+ def job_env_path(job_dir: Path) -> Path:
32
+ return job_dir / JOB_ENV_FILENAME
33
+
34
+
28
35
  def pipe_drained_path(job_dir: Path) -> Path:
29
36
  return job_dir / PIPE_DRAINED_FILENAME
30
37
 
@@ -34,10 +41,12 @@ def pipe_failed_path(job_dir: Path) -> Path:
34
41
 
35
42
 
36
43
  __all__ = [
44
+ "JOB_ENV_FILENAME",
37
45
  "PIPE_DRAINED_FILENAME",
38
46
  "PIPE_FAILED_FILENAME",
39
47
  "RUNNER_ENDED_AT_FILENAME",
40
48
  "RUNNER_EXIT_CODE_FILENAME",
49
+ "job_env_path",
41
50
  "pipe_drained_path",
42
51
  "pipe_failed_path",
43
52
  "runner_ended_at_path",
@@ -9,6 +9,7 @@ This module owns the long-lived job lifecycle rules: artifact creation,
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import json
12
13
  import shlex
13
14
  import shutil
14
15
  import stat
@@ -24,8 +25,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
24
25
  from sqlmodel import SQLModel
25
26
 
26
27
  from shell_session_manager.shellctl.server.artifacts import (
28
+ JOB_ENV_FILENAME,
27
29
  RUNNER_ENDED_AT_FILENAME,
28
30
  RUNNER_EXIT_CODE_FILENAME,
31
+ job_env_path,
29
32
  pipe_drained_path,
30
33
  pipe_failed_path,
31
34
  runner_ended_at_path,
@@ -148,7 +151,8 @@ class ShellctlService:
148
151
  """Create a tmux-backed job and wait for its initial result window.
149
152
 
150
153
  Side effects:
151
- - allocates `jobs/<job_id>/` and writes `script` / `output.log`
154
+ - allocates `jobs/<job_id>/` and writes `script`, `.job-env.json`, and
155
+ `output.log`
152
156
  - inserts a `jobs` row into SQLite, then conditionally transitions it
153
157
  through `created -> starting -> running`
154
158
  - creates a dedicated tmux session, installs `pipe-pane`, waits for the
@@ -168,6 +172,7 @@ class ShellctlService:
168
172
  """
169
173
 
170
174
  cwd = self._resolve_cwd(request.cwd)
175
+ env = self._resolve_env(request.env)
171
176
  terminal = request.terminal or TerminalSize(
172
177
  cols=self.config.default_terminal_cols,
173
178
  rows=self.config.default_terminal_rows,
@@ -183,6 +188,10 @@ class ShellctlService:
183
188
  script_path = candidate_job_dir / "script"
184
189
  output_path = candidate_job_dir / "output.log"
185
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
+ )
186
195
  output_path.touch()
187
196
  row = JobRow(
188
197
  job_id=candidate_job_id,
@@ -1000,6 +1009,17 @@ class ShellctlService:
1000
1009
  )
1001
1010
  return cwd
1002
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
+
1003
1023
  def _validate_offset(self, output_path: Path, offset: int) -> None:
1004
1024
  size = output_path.stat().st_size if output_path.exists() else 0
1005
1025
  if offset > size:
@@ -1022,7 +1042,59 @@ class ShellctlService:
1022
1042
  self.config.runner_path.chmod(mode | stat.S_IXUSR)
1023
1043
 
1024
1044
  def _runner_script_source(self) -> str:
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
+
1025
1057
  auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
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()
1097
+ )
1026
1098
  return f"""#!/usr/bin/env bash
1027
1099
  set -uo pipefail
1028
1100
 
@@ -1030,6 +1102,7 @@ JOB_DIR="$1"
1030
1102
  JOB_ID="$2"
1031
1103
  CWD="$3"
1032
1104
  SCRIPT_PATH="$JOB_DIR/script"
1105
+ ENV_PATH="$JOB_DIR/{JOB_ENV_FILENAME}"
1033
1106
  START_GATE="$JOB_DIR/start-gate"
1034
1107
  RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
1035
1108
  RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
@@ -1053,18 +1126,8 @@ unset SHELLCTL_TMUX_SOCKET
1053
1126
  unset SHELLCTL_RUNNER
1054
1127
  unset {auth_env}
1055
1128
 
1056
- if cd "$CWD"; then
1057
- if IFS= read -r FIRST_LINE < "$SCRIPT_PATH" && [[ "$FIRST_LINE" == '#!'* ]]; then
1058
- chmod +x "$SCRIPT_PATH"
1059
- "$SCRIPT_PATH"
1060
- EXIT_CODE=$?
1061
- else
1062
- sh "$SCRIPT_PATH"
1063
- EXIT_CODE=$?
1064
- fi
1065
- else
1066
- EXIT_CODE=111
1067
- fi
1129
+ {shlex.quote(sys.executable)} -c {bootstrap_source} "$SCRIPT_PATH" "$CWD" "$ENV_PATH"
1130
+ EXIT_CODE=$?
1068
1131
 
1069
1132
  ENDED_AT="$({shlex.quote(sys.executable)} - <<'PY'
1070
1133
  from datetime import UTC, datetime
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from enum import StrEnum
6
6
 
7
- from pydantic import BaseModel, ConfigDict, Field
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
8
 
9
9
  from shell_session_manager.shellctl.shared.constants import (
10
10
  DEFAULT_HEALTH_STATUS,
@@ -128,10 +128,16 @@ class ErrorResponse(ShellctlModel):
128
128
 
129
129
 
130
130
  class RunJobRequest(ShellctlModel):
131
- """HTTP request body for `POST /v1/jobs/run`."""
131
+ """HTTP request body for `POST /v1/jobs/run`.
132
+
133
+ `env` augments the runner's inherited process environment instead of
134
+ replacing it, so callers can preset script-local variables without losing
135
+ ambient values such as `PATH`.
136
+ """
132
137
 
133
138
  script: str
134
139
  cwd: str | None = None
140
+ env: dict[str, str] | None = None
135
141
  terminal: TerminalSize | None = None
136
142
  timeout: float = Field(
137
143
  default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS
@@ -141,6 +147,30 @@ class RunJobRequest(ShellctlModel):
141
147
  )
142
148
  idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
143
149
 
150
+ @field_validator("env")
151
+ @classmethod
152
+ def _validate_env(cls, env: dict[str, str] | None) -> dict[str, str] | None:
153
+ """Reject env entries that cannot be represented in `execve`.
154
+
155
+ shellctl applies `env` as a process environment overlay, so validation
156
+ follows the low-level `NAME=value` constraints instead of shell variable
157
+ naming rules: names must be non-empty and cannot contain `=` or NUL,
158
+ while values cannot contain NUL.
159
+ """
160
+
161
+ if env is None:
162
+ return None
163
+ for name, value in env.items():
164
+ if not name:
165
+ raise ValueError("env names must be non-empty")
166
+ if "=" in name:
167
+ raise ValueError(f"env name must not contain '=': {name!r}")
168
+ if "\x00" in name:
169
+ raise ValueError(f"env name must not contain NUL: {name!r}")
170
+ if "\x00" in value:
171
+ raise ValueError(f"env value must not contain NUL: {name!r}")
172
+ return env
173
+
144
174
 
145
175
  class WaitJobRequest(ShellctlModel):
146
176
  """HTTP request body for `POST /v1/jobs/{job_id}/wait`."""
@@ -39,7 +39,12 @@ async def test_shellctl_client_run_injects_headers_and_instance_defaults(
39
39
  idle_flush_seconds=0.25,
40
40
  transport=transport,
41
41
  ) as client:
42
- await client.run("printf ready\\n", cwd="/tmp", timeout=12)
42
+ await client.run(
43
+ "printf ready\\n",
44
+ cwd="/tmp",
45
+ env={"HELLO": "world"},
46
+ timeout=12,
47
+ )
43
48
 
44
49
  assert captured["method"] == "POST"
45
50
  assert captured["path"] == "/v1/jobs/run"
@@ -47,6 +52,7 @@ async def test_shellctl_client_run_injects_headers_and_instance_defaults(
47
52
  assert captured["json"] == {
48
53
  "script": "printf ready\\n",
49
54
  "cwd": "/tmp",
55
+ "env": {"HELLO": "world"},
50
56
  "timeout": 12.0,
51
57
  "output_limit": 4096,
52
58
  "idle_flush_seconds": 0.25,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib
4
+ import json
4
5
  import os
5
6
  import re
6
7
  import shutil
@@ -26,10 +27,12 @@ from shell_session_manager.shellctl.server import (
26
27
  create_app,
27
28
  )
28
29
  from shell_session_manager.shellctl.server.artifacts import (
30
+ JOB_ENV_FILENAME,
29
31
  PIPE_DRAINED_FILENAME,
30
32
  PIPE_FAILED_FILENAME,
31
33
  RUNNER_ENDED_AT_FILENAME,
32
34
  RUNNER_EXIT_CODE_FILENAME,
35
+ job_env_path,
33
36
  )
34
37
  from shell_session_manager.shellctl.server.tmux import TmuxController
35
38
  from shell_session_manager.shellctl.shared import (
@@ -352,6 +355,7 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
352
355
  RunJobRequest(
353
356
  script="printf ready\n",
354
357
  cwd=str(tmp_path),
358
+ env={"HELLO": "world", "UNICODE": "盐粒"},
355
359
  terminal=TerminalSize(cols=100, rows=40),
356
360
  timeout=0.01,
357
361
  output_limit=8192,
@@ -372,6 +376,10 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
372
376
  assert row.output_path == f"jobs/{result.job_id}/output.log"
373
377
  assert row.started_at is not None
374
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
+ }
375
383
  assert (job_dir / "output.log").exists()
376
384
  assert (job_dir / "start-gate").exists()
377
385
  assert job_session_name(result.job_id) in fake_tmux.sessions
@@ -389,6 +397,205 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
389
397
  await service.shutdown()
390
398
 
391
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
+
392
599
  @pytest.mark.anyio
393
600
  @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
394
601
  async def test_run_job_returns_flushed_output_on_first_terminal_result(
@@ -1342,6 +1549,7 @@ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1342
1549
  )
1343
1550
  source = service._runner_script_source()
1344
1551
 
1552
+ assert JOB_ENV_FILENAME in source
1345
1553
  assert RUNNER_EXIT_CODE_FILENAME in source
1346
1554
  assert RUNNER_ENDED_AT_FILENAME in source
1347
1555
  assert "write_atomic" in source
@@ -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)