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.
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/PKG-INFO +1 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/pyproject.toml +1 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/sdk.py +7 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/artifacts.py +11 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/service.py +76 -13
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/schemas.py +32 -2
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_client.py +7 -1
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_service.py +208 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shellctl_shared.py +20 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/LICENSE +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/README.md +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/api.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/cli.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/config.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/server/tmux.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py +0 -0
- {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.
|
|
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,
|
|
@@ -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`.
|
|
7
|
-
|
|
6
|
+
drained into `output.log`. The same artifact directory also stores the request's
|
|
7
|
+
environment overlay so the runner can merge arbitrary key/value pairs without
|
|
8
|
+
shell-escaping them into the generated script. 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
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
chmod +x "$SCRIPT_PATH"
|
|
1059
|
-
"$SCRIPT_PATH"
|
|
1060
|
-
EXIT_CODE=$?
|
|
1061
|
-
else
|
|
1062
|
-
sh "$SCRIPT_PATH"
|
|
1063
|
-
EXIT_CODE=$?
|
|
1064
|
-
fi
|
|
1065
|
-
else
|
|
1066
|
-
EXIT_CODE=111
|
|
1067
|
-
fi
|
|
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(
|
|
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)
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → 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
|
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.1 → shell_session_manager-2.2.0}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|