shell-session-manager 2.0.0__tar.gz → 2.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/PKG-INFO +14 -1
  2. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/README.md +13 -0
  3. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/pyproject.toml +1 -1
  4. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/api.py +4 -1
  5. shell_session_manager-2.1.1/src/shell_session_manager/shellctl/server/artifacts.py +45 -0
  6. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/cli.py +10 -2
  7. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/config.py +11 -10
  8. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/service.py +77 -13
  9. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
  10. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shellctl_service.py +561 -5
  11. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/LICENSE +0 -0
  12. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py +0 -0
  13. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed +0 -0
  14. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/session.py +0 -0
  15. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
  16. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  17. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
  18. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
  19. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  20. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
  21. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  22. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  23. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  24. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  25. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  26. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  27. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
  28. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json +0 -0
  29. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py +0 -0
  30. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py +0 -0
  31. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shellctl_client.py +0 -0
  32. {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shellctl_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shell-session-manager
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
5
5
  Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
6
6
  License-Expression: Apache-2.0
@@ -67,3 +67,16 @@ async def main() -> None:
67
67
 
68
68
  anyio.run(main)
69
69
  ```
70
+
71
+ ## shellctl server
72
+
73
+ Run the HTTP API locally with:
74
+
75
+ ```bash
76
+ pdm run shellctl serve --listen 127.0.0.1:8765
77
+ ```
78
+
79
+ Pass `--auth-token your-token` when you want bearer auth enforced. `shellctl
80
+ serve` also reads `SHELLCTL_AUTH_TOKEN`, so you can export the token instead of
81
+ passing the flag. Leave the flag/env var unset or empty to start without
82
+ requiring an Authorization header.
@@ -49,3 +49,16 @@ async def main() -> None:
49
49
 
50
50
  anyio.run(main)
51
51
  ```
52
+
53
+ ## shellctl server
54
+
55
+ Run the HTTP API locally with:
56
+
57
+ ```bash
58
+ pdm run shellctl serve --listen 127.0.0.1:8765
59
+ ```
60
+
61
+ Pass `--auth-token your-token` when you want bearer auth enforced. `shellctl
62
+ serve` also reads `SHELLCTL_AUTH_TOKEN`, so you can export the token instead of
63
+ passing the flag. Leave the flag/env var unset or empty to start without
64
+ requiring an Authorization header.
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "shell-session-manager"
9
- version = "2.0.0"
9
+ version = "2.1.1"
10
10
  description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
11
11
  authors = [
12
12
  { name = "Yanli 盐粒", email = "yanli@mail.one" },
@@ -88,7 +88,10 @@ def create_app(
88
88
  def verify_auth(
89
89
  authorization: Annotated[str | None, Header()] = None,
90
90
  ) -> None:
91
- expected = f"Bearer {resolved_config.auth_token}"
91
+ token = resolved_config.auth_token
92
+ if token is None:
93
+ return
94
+ expected = f"Bearer {token}"
92
95
  if authorization != expected:
93
96
  raise ShellctlServerError(
94
97
  401, "unauthorized", "Missing or invalid bearer token"
@@ -0,0 +1,45 @@
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`. A separate failure marker is used so an unsuccessful
7
+ sanitizer run does not masquerade as a drained normal exit.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
15
+ RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
16
+ PIPE_DRAINED_FILENAME = ".pipe-drained"
17
+ PIPE_FAILED_FILENAME = ".pipe-failed"
18
+
19
+
20
+ def runner_exit_code_path(job_dir: Path) -> Path:
21
+ return job_dir / RUNNER_EXIT_CODE_FILENAME
22
+
23
+
24
+ def runner_ended_at_path(job_dir: Path) -> Path:
25
+ return job_dir / RUNNER_ENDED_AT_FILENAME
26
+
27
+
28
+ def pipe_drained_path(job_dir: Path) -> Path:
29
+ return job_dir / PIPE_DRAINED_FILENAME
30
+
31
+
32
+ def pipe_failed_path(job_dir: Path) -> Path:
33
+ return job_dir / PIPE_FAILED_FILENAME
34
+
35
+
36
+ __all__ = [
37
+ "PIPE_DRAINED_FILENAME",
38
+ "PIPE_FAILED_FILENAME",
39
+ "RUNNER_ENDED_AT_FILENAME",
40
+ "RUNNER_EXIT_CODE_FILENAME",
41
+ "pipe_drained_path",
42
+ "pipe_failed_path",
43
+ "runner_ended_at_path",
44
+ "runner_exit_code_path",
45
+ ]
@@ -26,7 +26,15 @@ cli = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
26
26
  @cli.command("serve")
27
27
  def serve_command(
28
28
  listen: str = "127.0.0.1:8765",
29
- auth_token_env: str = DEFAULT_AUTH_TOKEN_ENV,
29
+ auth_token: str | None = typer.Option(
30
+ None,
31
+ "--auth-token",
32
+ envvar=DEFAULT_AUTH_TOKEN_ENV,
33
+ help=(
34
+ "Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
35
+ "Leave it unset or empty to disable HTTP bearer auth."
36
+ ),
37
+ ),
30
38
  state_dir: Path | None = None,
31
39
  runtime_dir: Path | None = None,
32
40
  gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS,
@@ -37,7 +45,7 @@ def serve_command(
37
45
  host, port = _parse_listen(listen)
38
46
  config = ShellctlConfig(
39
47
  listen=listen,
40
- auth_token_env=auth_token_env,
48
+ auth_token=auth_token,
41
49
  state_dir=state_dir or default_state_dir(),
42
50
  runtime_dir=runtime_dir,
43
51
  gc_interval_seconds=gc_interval_seconds,
@@ -34,10 +34,14 @@ class ShellctlConfig:
34
34
  `shellctl_command` deliberately defaults to `python -m ...server` so the
35
35
  tmux-side commands stay pinned to the same interpreter environment that
36
36
  launched the API server.
37
+
38
+ Bearer auth is opt-in: if the explicit `auth_token` and the fallback
39
+ `SHELLCTL_AUTH_TOKEN` environment variable are both missing or empty,
40
+ `shellctl serve` accepts requests without checking an Authorization header.
37
41
  """
38
42
 
39
43
  listen: str = "127.0.0.1:8765"
40
- auth_token_env: str = DEFAULT_AUTH_TOKEN_ENV
44
+ auth_token: str | None = None
41
45
  state_dir: Path = field(default_factory=default_state_dir)
42
46
  runtime_dir: Path | None = None
43
47
  gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS
@@ -67,6 +71,12 @@ class ShellctlConfig:
67
71
  def __post_init__(self) -> None:
68
72
  if self.runtime_dir is None:
69
73
  object.__setattr__(self, "runtime_dir", default_runtime_dir(self.state_dir))
74
+ token = self.auth_token
75
+ if token is None:
76
+ token = os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
77
+ if not token:
78
+ token = None
79
+ object.__setattr__(self, "auth_token", token)
70
80
 
71
81
  @property
72
82
  def jobs_dir(self) -> Path:
@@ -90,14 +100,5 @@ class ShellctlConfig:
90
100
  runtime_dir = cast(Path, self.runtime_dir)
91
101
  return runtime_dir / "bin" / "shellctl-runner"
92
102
 
93
- @property
94
- def auth_token(self) -> str:
95
- token = os.environ.get(self.auth_token_env)
96
- if not token:
97
- raise RuntimeError(
98
- f"Missing bearer token in environment variable {self.auth_token_env}"
99
- )
100
- return token
101
-
102
103
 
103
104
  __all__ = ["ShellctlConfig"]
@@ -23,6 +23,14 @@ from sqlalchemy.exc import IntegrityError
23
23
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
24
24
  from sqlmodel import SQLModel
25
25
 
26
+ from shell_session_manager.shellctl.server.artifacts import (
27
+ RUNNER_ENDED_AT_FILENAME,
28
+ RUNNER_EXIT_CODE_FILENAME,
29
+ pipe_drained_path,
30
+ pipe_failed_path,
31
+ runner_ended_at_path,
32
+ runner_exit_code_path,
33
+ )
26
34
  from shell_session_manager.shellctl.server.config import ShellctlConfig
27
35
  from shell_session_manager.shellctl.server.db import JobRow, configure_sqlite_engine
28
36
  from shell_session_manager.shellctl.server.errors import ShellctlServerError
@@ -31,6 +39,7 @@ from shell_session_manager.shellctl.server.tmux import (
31
39
  TmuxControllerProtocol,
32
40
  )
33
41
  from shell_session_manager.shellctl.shared import (
42
+ DEFAULT_AUTH_TOKEN_ENV,
34
43
  DeleteJobResponse,
35
44
  InputJobRequest,
36
45
  JobInfo,
@@ -97,9 +106,12 @@ class ShellctlService:
97
106
  await connection.run_sync(SQLModel.metadata.create_all)
98
107
 
99
108
  async def initialize(self) -> None:
100
- """Prepare directories, database, runner script, and tmux state."""
109
+ """Prepare directories, database, runner script, and tmux state.
110
+
111
+ Auth is configured at the API layer, so service startup must also work
112
+ when `shellctl serve` is intentionally running without a bearer token.
113
+ """
101
114
 
102
- _ = self.config.auth_token
103
115
  await self.initialize_database()
104
116
  self._ensure_dir(cast(Path, self.config.runtime_dir))
105
117
  self._ensure_dir(self.config.jobs_dir)
@@ -536,9 +548,12 @@ class ShellctlService:
536
548
  async def record_runner_exit(
537
549
  self, job_id: str, exit_code: int, ended_at: str
538
550
  ) -> None:
539
- """Persist the runner's exit fact into SQLite.
551
+ """Persist a drained normal-exit fact into SQLite.
540
552
 
541
- This is the source-of-truth replacement for the old `exit.json`. The
553
+ The usual caller is the tmux pipe finalizer after `sanitize-pty` has
554
+ reached EOF and flushed `output.log`. `_materialize_status_view()` may
555
+ also call this as a recovery path when `.pipe-drained` plus the normal
556
+ exit metadata files exist but SQLite is still non-terminal. The
542
557
  statement always records `exit_code` and `ended_at`, but it only changes
543
558
  `status` to `exited` when the current row is still non-terminal.
544
559
  """
@@ -656,6 +671,13 @@ class ShellctlService:
656
671
  - terminal SQLite status wins and is preserved
657
672
  - if SQLite already has an `exit_code` on a non-terminal row, materialize
658
673
  `exited`
674
+ - if normal-exit metadata and `.pipe-drained` already exist, recover the
675
+ drained normal exit immediately even when the finalizer has not yet
676
+ committed SQLite state
677
+ - if normal-exit metadata exists and neither `.pipe-drained` nor
678
+ `.pipe-failed` exists, keep the non-terminal row instead of guessing
679
+ `lost` while the pipe finalizer is still responsible for the eventual
680
+ `runner-exit` commit
659
681
  - if a live tmux session exists but the output pipe is known-dead,
660
682
  conditionally materialize `failed(reason=pipe_failed)`
661
683
  - if no live session exists and the row is not protected by the local
@@ -689,6 +711,10 @@ class ShellctlService:
689
711
  target=JobStatusName.EXITED,
690
712
  ended_at=row.ended_at or format_timestamp(),
691
713
  )
714
+ elif (drained_exit := self._drained_normal_exit_metadata(job_id)) is not None:
715
+ exit_code, ended_at = drained_exit
716
+ await self.record_runner_exit(job_id, exit_code, ended_at)
717
+ row = await self._get_job_row(job_id)
692
718
  elif session_exists:
693
719
  if pipe_active is False:
694
720
  if (
@@ -724,7 +750,9 @@ class ShellctlService:
724
750
  require_exit_code_null=True,
725
751
  )
726
752
  else:
727
- if not (
753
+ if self._normal_exit_commit_pending(job_id):
754
+ pass
755
+ elif not (
728
756
  status in {JobStatusName.CREATED, JobStatusName.STARTING}
729
757
  and job_id in self._starting_jobs
730
758
  ):
@@ -907,6 +935,36 @@ class ShellctlService:
907
935
  def _artifact_dir(self, job_id: str) -> Path:
908
936
  return self.config.jobs_dir / job_id
909
937
 
938
+ def _normal_exit_commit_pending(self, job_id: str) -> bool:
939
+ """Check whether a normal exit is still waiting for pipe drain + commit."""
940
+
941
+ job_dir = self._artifact_dir(job_id)
942
+ return (
943
+ runner_exit_code_path(job_dir).exists()
944
+ and runner_ended_at_path(job_dir).exists()
945
+ and not pipe_drained_path(job_dir).exists()
946
+ and not pipe_failed_path(job_dir).exists()
947
+ )
948
+
949
+ def _drained_normal_exit_metadata(self, job_id: str) -> tuple[int, str] | None:
950
+ """Read drained normal-exit metadata after successful sanitize drain."""
951
+
952
+ job_dir = self._artifact_dir(job_id)
953
+ drained_path = pipe_drained_path(job_dir)
954
+ exit_code_path = runner_exit_code_path(job_dir)
955
+ ended_at_path = runner_ended_at_path(job_dir)
956
+ if (
957
+ not drained_path.exists()
958
+ or not exit_code_path.exists()
959
+ or not ended_at_path.exists()
960
+ ):
961
+ return None
962
+ raw_exit_code = exit_code_path.read_text(encoding="utf-8").strip()
963
+ raw_ended_at = ended_at_path.read_text(encoding="utf-8").strip()
964
+ if not raw_exit_code or not raw_ended_at:
965
+ return None
966
+ return int(raw_exit_code), raw_ended_at
967
+
910
968
  def _allocate_job_dir(self) -> tuple[str, Path]:
911
969
  """Atomically allocate a unique artifact directory for a new job.
912
970
 
@@ -964,12 +1022,7 @@ class ShellctlService:
964
1022
  self.config.runner_path.chmod(mode | stat.S_IXUSR)
965
1023
 
966
1024
  def _runner_script_source(self) -> str:
967
- tmux_socket = shlex.quote(str(self.config.tmux_socket))
968
- state_dir = shlex.quote(str(self.config.state_dir))
969
- auth_env = shlex.quote(self.config.auth_token_env)
970
- shellctl_command = " ".join(
971
- shlex.quote(part) for part in self.config.shellctl_command
972
- )
1025
+ auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
973
1026
  return f"""#!/usr/bin/env bash
974
1027
  set -uo pipefail
975
1028
 
@@ -978,6 +1031,16 @@ JOB_ID="$2"
978
1031
  CWD="$3"
979
1032
  SCRIPT_PATH="$JOB_DIR/script"
980
1033
  START_GATE="$JOB_DIR/start-gate"
1034
+ RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
1035
+ RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
1036
+
1037
+ write_atomic() {{
1038
+ local dest="$1"
1039
+ local value="$2"
1040
+ local tmp="${{dest}}.tmp.$$"
1041
+ printf '%s\n' "$value" > "$tmp"
1042
+ mv "$tmp" "$dest"
1043
+ }}
981
1044
 
982
1045
  while [ ! -e "$START_GATE" ]; do
983
1046
  sleep 0.05
@@ -1009,8 +1072,9 @@ print(datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'
1009
1072
  PY
1010
1073
  )"
1011
1074
 
1012
- {shellctl_command} runner-exit --state-dir {state_dir} --job-id "$JOB_ID" --exit-code "$EXIT_CODE" --ended-at "$ENDED_AT"
1013
- env -u TMUX tmux -S {tmux_socket} kill-session -t "shellctl-job-$JOB_ID" >/dev/null 2>&1 || true
1075
+ write_atomic "$RUNNER_EXIT_CODE_PATH" "$EXIT_CODE"
1076
+ write_atomic "$RUNNER_ENDED_AT_PATH" "$ENDED_AT"
1077
+
1014
1078
  exit "$EXIT_CODE"
1015
1079
  """
1016
1080
 
@@ -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
 
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import importlib
3
4
  import os
5
+ import re
6
+ import shutil
4
7
  import sqlite3
5
8
  import subprocess
6
9
  import sys
@@ -22,7 +25,15 @@ from shell_session_manager.shellctl.server import (
22
25
  cli,
23
26
  create_app,
24
27
  )
28
+ from shell_session_manager.shellctl.server.artifacts import (
29
+ PIPE_DRAINED_FILENAME,
30
+ PIPE_FAILED_FILENAME,
31
+ RUNNER_ENDED_AT_FILENAME,
32
+ RUNNER_EXIT_CODE_FILENAME,
33
+ )
34
+ from shell_session_manager.shellctl.server.tmux import TmuxController
25
35
  from shell_session_manager.shellctl.shared import (
36
+ DEFAULT_AUTH_TOKEN_ENV,
26
37
  InputJobRequest,
27
38
  JobStatusName,
28
39
  JobStatusView,
@@ -34,6 +45,8 @@ from shell_session_manager.shellctl.shared import (
34
45
  job_session_name,
35
46
  )
36
47
 
48
+ server_cli_module = importlib.import_module("shell_session_manager.shellctl.server.cli")
49
+
37
50
 
38
51
  class FakeTmuxController:
39
52
  def __init__(self) -> None:
@@ -85,10 +98,16 @@ class FakeTmuxController:
85
98
  self.pipe_active.pop(job_id, None)
86
99
 
87
100
 
88
- async def _create_service(tmp_path: Path) -> tuple[ShellctlService, FakeTmuxController]:
101
+ async def _create_service(
102
+ tmp_path: Path, *, auth_token: str | None = None
103
+ ) -> tuple[ShellctlService, FakeTmuxController]:
89
104
  fake_tmux = FakeTmuxController()
90
105
  service = ShellctlService(
91
- ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
106
+ ShellctlConfig(
107
+ auth_token=auth_token,
108
+ state_dir=tmp_path / "state",
109
+ runtime_dir=tmp_path / "run",
110
+ ),
92
111
  tmux=fake_tmux,
93
112
  )
94
113
  await service.initialize_database()
@@ -96,6 +115,82 @@ async def _create_service(tmp_path: Path) -> tuple[ShellctlService, FakeTmuxCont
96
115
  return service, fake_tmux
97
116
 
98
117
 
118
+ async def _create_real_service(
119
+ tmp_path: Path,
120
+ *,
121
+ shellctl_command: tuple[str, ...] | None = None,
122
+ ) -> ShellctlService:
123
+ config = ShellctlConfig(
124
+ state_dir=tmp_path / "state",
125
+ runtime_dir=tmp_path / "run",
126
+ shellctl_command=shellctl_command
127
+ or ShellctlConfig(
128
+ state_dir=tmp_path / "default-state",
129
+ runtime_dir=tmp_path / "default-run",
130
+ ).shellctl_command,
131
+ )
132
+ service = ShellctlService(config)
133
+ await service.initialize()
134
+ return service
135
+
136
+
137
+ def _write_delayed_sanitize_wrapper(
138
+ tmp_path: Path,
139
+ *,
140
+ delay_seconds: float,
141
+ sanitize_exit_code: int | None = None,
142
+ ) -> tuple[str, ...]:
143
+ wrapper_path = tmp_path / "delayed-sanitize-wrapper.py"
144
+ wrapper_path.write_text(
145
+ "\n".join(
146
+ [
147
+ "from __future__ import annotations",
148
+ "",
149
+ "import subprocess",
150
+ "import sys",
151
+ "import time",
152
+ "from pathlib import Path",
153
+ "",
154
+ f"REAL = [{sys.executable!r}, '-m', 'shell_session_manager.shellctl.server']",
155
+ "args = sys.argv[1:]",
156
+ "if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
157
+ " ready_file = Path(args[args.index('--ready-file') + 1])",
158
+ " ready_file.touch()",
159
+ f" time.sleep({delay_seconds!r})",
160
+ (
161
+ f" raise SystemExit({sanitize_exit_code!r})"
162
+ if sanitize_exit_code is not None
163
+ else ""
164
+ ),
165
+ "raise SystemExit(subprocess.run(REAL + args, check=False).returncode)",
166
+ "",
167
+ ]
168
+ ),
169
+ encoding="utf-8",
170
+ )
171
+ return (sys.executable, str(wrapper_path))
172
+
173
+
174
+ def _capture_serve_config(
175
+ monkeypatch: pytest.MonkeyPatch,
176
+ ) -> tuple[dict[str, object], CliRunner]:
177
+ captured: dict[str, object] = {}
178
+
179
+ def fake_create_app(config: ShellctlConfig):
180
+ captured["config"] = config
181
+ return object()
182
+
183
+ def fake_run(app: object, *, host: str, port: int, log_level: str) -> None:
184
+ captured["app"] = app
185
+ captured["host"] = host
186
+ captured["port"] = port
187
+ captured["log_level"] = log_level
188
+
189
+ monkeypatch.setattr(server_cli_module, "create_app", fake_create_app)
190
+ monkeypatch.setattr(server_cli_module.uvicorn, "run", fake_run)
191
+ return captured, CliRunner()
192
+
193
+
99
194
  async def _seed_job(
100
195
  service: ShellctlService,
101
196
  *,
@@ -294,6 +389,174 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
294
389
  await service.shutdown()
295
390
 
296
391
 
392
+ @pytest.mark.anyio
393
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
394
+ async def test_run_job_returns_flushed_output_on_first_terminal_result(
395
+ tmp_path: Path,
396
+ ) -> None:
397
+ shellctl_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
398
+ service = await _create_real_service(
399
+ tmp_path,
400
+ shellctl_command=shellctl_command,
401
+ )
402
+
403
+ try:
404
+ result = await service.run_job(
405
+ RunJobRequest(
406
+ script="printf 'delayed-flush\\n'\n",
407
+ cwd=str(tmp_path),
408
+ terminal=TerminalSize(),
409
+ timeout=3,
410
+ output_limit=8192,
411
+ idle_flush_seconds=1,
412
+ )
413
+ )
414
+
415
+ reread = await service.wait_job(
416
+ result.job_id,
417
+ WaitJobRequest(
418
+ offset=0,
419
+ timeout=0.001,
420
+ output_limit=8192,
421
+ idle_flush_seconds=0.01,
422
+ ),
423
+ )
424
+
425
+ assert result.done is True
426
+ assert result.status is JobStatusName.EXITED
427
+ assert result.exit_code == 0
428
+ assert result.output == "delayed-flush\n"
429
+ assert reread.output == "delayed-flush\n"
430
+ assert reread.offset == result.offset
431
+ finally:
432
+ await service.shutdown()
433
+
434
+
435
+ @pytest.mark.anyio
436
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
437
+ async def test_sanitize_failure_does_not_commit_normal_exit(
438
+ tmp_path: Path,
439
+ ) -> None:
440
+ shellctl_command = _write_delayed_sanitize_wrapper(
441
+ tmp_path,
442
+ delay_seconds=0.2,
443
+ sanitize_exit_code=7,
444
+ )
445
+ service = await _create_real_service(
446
+ tmp_path,
447
+ shellctl_command=shellctl_command,
448
+ )
449
+
450
+ try:
451
+ result = await service.run_job(
452
+ RunJobRequest(
453
+ script="printf 'missing-output\\n'\n",
454
+ cwd=str(tmp_path),
455
+ terminal=TerminalSize(),
456
+ timeout=3,
457
+ output_limit=8192,
458
+ idle_flush_seconds=1,
459
+ )
460
+ )
461
+
462
+ reread = await service.wait_job(
463
+ result.job_id,
464
+ WaitJobRequest(
465
+ offset=0,
466
+ timeout=0.001,
467
+ output_limit=8192,
468
+ idle_flush_seconds=0.01,
469
+ ),
470
+ )
471
+ job_dir = service.config.jobs_dir / result.job_id
472
+
473
+ assert result.done is True
474
+ assert result.status is not JobStatusName.EXITED
475
+ assert result.exit_code is None
476
+ assert result.output == ""
477
+ assert reread.output == ""
478
+ assert not (job_dir / PIPE_DRAINED_FILENAME).exists()
479
+ assert (job_dir / PIPE_FAILED_FILENAME).exists()
480
+ finally:
481
+ await service.shutdown()
482
+
483
+
484
+ @pytest.mark.anyio
485
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
486
+ @pytest.mark.skipif(shutil.which("uv") is None, reason="uv is required")
487
+ async def test_uv_quiet_shebang_returns_output_in_first_terminal_result(
488
+ tmp_path: Path,
489
+ ) -> None:
490
+ service = await _create_real_service(tmp_path)
491
+
492
+ try:
493
+ result = await service.run_job(
494
+ RunJobRequest(
495
+ script=(
496
+ "#!/usr/bin/env -S uv run --script --quiet\n"
497
+ "# /// script\n"
498
+ '# requires-python = ">=3.12"\n'
499
+ "# dependencies = []\n"
500
+ "# ///\n"
501
+ 'print("hello")\n'
502
+ ),
503
+ cwd=str(tmp_path),
504
+ terminal=TerminalSize(),
505
+ timeout=10,
506
+ output_limit=8192,
507
+ idle_flush_seconds=1,
508
+ )
509
+ )
510
+
511
+ assert result.done is True
512
+ assert result.status is JobStatusName.EXITED
513
+ assert result.exit_code == 0
514
+ assert result.output == "hello\n"
515
+ finally:
516
+ await service.shutdown()
517
+
518
+
519
+ @pytest.mark.anyio
520
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
521
+ @pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
522
+ @pytest.mark.parametrize(
523
+ ("script", "expected_output"),
524
+ [
525
+ ("printf 'no-shebang\\n'\n", "no-shebang\n"),
526
+ ("#!/bin/sh\nprintf 'sh-shebang\\n'\n", "sh-shebang\n"),
527
+ (
528
+ "#!/usr/bin/env bash\nprintf 'bash-shebang\\n'\n",
529
+ "bash-shebang\n",
530
+ ),
531
+ ],
532
+ )
533
+ async def test_existing_script_modes_still_return_output(
534
+ tmp_path: Path,
535
+ script: str,
536
+ expected_output: str,
537
+ ) -> None:
538
+ service = await _create_real_service(tmp_path)
539
+
540
+ try:
541
+ result = await service.run_job(
542
+ RunJobRequest(
543
+ script=script,
544
+ cwd=str(tmp_path),
545
+ terminal=TerminalSize(),
546
+ timeout=5,
547
+ output_limit=8192,
548
+ idle_flush_seconds=1,
549
+ )
550
+ )
551
+
552
+ assert result.done is True
553
+ assert result.status is JobStatusName.EXITED
554
+ assert result.exit_code == 0
555
+ assert result.output == expected_output
556
+ finally:
557
+ await service.shutdown()
558
+
559
+
297
560
  @pytest.mark.anyio
298
561
  async def test_run_job_retries_after_sqlite_insert_conflict_and_cleans_artifacts(
299
562
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -527,6 +790,32 @@ async def test_session_disappearing_between_probes_materializes_lost_not_pipe_fa
527
790
  await service.shutdown()
528
791
 
529
792
 
793
+ @pytest.mark.anyio
794
+ async def test_drained_uncommitted_normal_exit_self_commits_to_exited(
795
+ tmp_path: Path,
796
+ ) -> None:
797
+ service, _fake_tmux = await _create_service(tmp_path)
798
+ job_id = "05211530-k7p"
799
+ await _seed_job(service, job_id=job_id, status=JobStatusName.RUNNING)
800
+ job_dir = service.config.jobs_dir / job_id
801
+ (job_dir / RUNNER_EXIT_CODE_FILENAME).write_text("0\n", encoding="utf-8")
802
+ (job_dir / RUNNER_ENDED_AT_FILENAME).write_text(
803
+ "2026-05-21T15:30:20Z\n", encoding="utf-8"
804
+ )
805
+ (job_dir / PIPE_DRAINED_FILENAME).touch()
806
+
807
+ view = await service.get_job_status(job_id)
808
+ row = await service._get_job_row(job_id)
809
+
810
+ assert view.status is JobStatusName.EXITED
811
+ assert view.done is True
812
+ assert view.exit_code == 0
813
+ assert row.status == JobStatusName.EXITED.value
814
+ assert row.exit_code == 0
815
+ assert row.ended_at == "2026-05-21T15:30:20Z"
816
+ await service.shutdown()
817
+
818
+
530
819
  @pytest.mark.anyio
531
820
  async def test_list_jobs_ignores_half_created_directory_and_uses_db_rows(
532
821
  tmp_path: Path,
@@ -788,12 +1077,24 @@ async def test_allocate_job_dir_retries_on_atomic_mkdir_collision(
788
1077
  await service.shutdown()
789
1078
 
790
1079
 
1080
+ @pytest.mark.anyio
1081
+ async def test_service_initialize_allows_missing_auth_token(tmp_path: Path) -> None:
1082
+ service = ShellctlService(
1083
+ ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
1084
+ tmux=FakeTmuxController(),
1085
+ )
1086
+
1087
+ await service.initialize()
1088
+
1089
+ assert service.config.runner_path.exists()
1090
+ await service.shutdown()
1091
+
1092
+
791
1093
  @pytest.mark.anyio
792
1094
  async def test_http_routes_inject_shellctl_service_dependency(
793
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1095
+ tmp_path: Path,
794
1096
  ) -> None:
795
- monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "route-token")
796
- service, _fake_tmux = await _create_service(tmp_path)
1097
+ service, _fake_tmux = await _create_service(tmp_path, auth_token="route-token")
797
1098
  app = create_app(service.config, service=service)
798
1099
  transport = httpx.ASGITransport(app=app)
799
1100
 
@@ -815,6 +1116,261 @@ async def test_http_routes_inject_shellctl_service_dependency(
815
1116
  await service.shutdown()
816
1117
 
817
1118
 
1119
+ @pytest.mark.anyio
1120
+ async def test_http_routes_enforce_auth_from_environment_fallback(
1121
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1122
+ ) -> None:
1123
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "route-token")
1124
+ service, _fake_tmux = await _create_service(tmp_path)
1125
+ app = create_app(service.config, service=service)
1126
+ transport = httpx.ASGITransport(app=app)
1127
+
1128
+ async with httpx.AsyncClient(
1129
+ transport=transport, base_url="http://shellctl.test"
1130
+ ) as client:
1131
+ unauthenticated = await client.get("/v1/jobs")
1132
+ authenticated = await client.get(
1133
+ "/v1/jobs", headers={"Authorization": "Bearer route-token"}
1134
+ )
1135
+
1136
+ assert unauthenticated.status_code == 401
1137
+ assert authenticated.status_code == 200
1138
+ await service.shutdown()
1139
+
1140
+
1141
+ @pytest.mark.anyio
1142
+ async def test_create_app_without_explicit_config_reads_auth_token_from_environment(
1143
+ monkeypatch: pytest.MonkeyPatch,
1144
+ ) -> None:
1145
+ async def noop_initialize(self: ShellctlService) -> None:
1146
+ return None
1147
+
1148
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "route-token")
1149
+ monkeypatch.setattr(ShellctlService, "initialize", noop_initialize)
1150
+ monkeypatch.setattr(ShellctlService, "start_background_gc", lambda self: None)
1151
+ monkeypatch.setattr(
1152
+ ShellctlService, "start_background_pipe_monitor", lambda self: None
1153
+ )
1154
+
1155
+ app = create_app()
1156
+ transport = httpx.ASGITransport(app=app)
1157
+
1158
+ async with httpx.AsyncClient(
1159
+ transport=transport, base_url="http://shellctl.test"
1160
+ ) as client:
1161
+ response = await client.get("/v1/jobs")
1162
+
1163
+ assert app.state.shellctl_service.config.auth_token == "route-token"
1164
+ assert response.status_code == 401
1165
+ await app.state.shellctl_service.shutdown()
1166
+
1167
+
1168
+ @pytest.mark.anyio
1169
+ async def test_http_routes_skip_auth_when_token_missing(tmp_path: Path) -> None:
1170
+ service, _fake_tmux = await _create_service(tmp_path)
1171
+ app = create_app(service.config, service=service)
1172
+ transport = httpx.ASGITransport(app=app)
1173
+
1174
+ async with httpx.AsyncClient(
1175
+ transport=transport, base_url="http://shellctl.test"
1176
+ ) as client:
1177
+ response = await client.get("/v1/jobs")
1178
+
1179
+ assert response.status_code == 200
1180
+ assert response.json() == {"jobs": []}
1181
+ await service.shutdown()
1182
+
1183
+
1184
+ @pytest.mark.anyio
1185
+ async def test_http_routes_skip_auth_when_token_is_empty(tmp_path: Path) -> None:
1186
+ service, _fake_tmux = await _create_service(tmp_path, auth_token="")
1187
+ app = create_app(service.config, service=service)
1188
+ transport = httpx.ASGITransport(app=app)
1189
+
1190
+ async with httpx.AsyncClient(
1191
+ transport=transport, base_url="http://shellctl.test"
1192
+ ) as client:
1193
+ response = await client.get("/v1/jobs")
1194
+
1195
+ assert response.status_code == 200
1196
+ assert response.json() == {"jobs": []}
1197
+ await service.shutdown()
1198
+
1199
+
1200
+ def test_shellctl_config_reads_auth_token_from_environment(
1201
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1202
+ ) -> None:
1203
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
1204
+
1205
+ config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
1206
+
1207
+ assert config.auth_token == "env-token"
1208
+
1209
+
1210
+ def test_shellctl_config_treats_empty_environment_auth_token_as_disabled(
1211
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1212
+ ) -> None:
1213
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "")
1214
+
1215
+ config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
1216
+
1217
+ assert config.auth_token is None
1218
+
1219
+
1220
+ def test_serve_cli_passes_direct_auth_token_to_config(
1221
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1222
+ ) -> None:
1223
+ captured, runner = _capture_serve_config(monkeypatch)
1224
+
1225
+ result = runner.invoke(
1226
+ cli,
1227
+ [
1228
+ "serve",
1229
+ "--listen",
1230
+ "0.0.0.0:9999",
1231
+ "--auth-token",
1232
+ "direct-token",
1233
+ "--state-dir",
1234
+ str(tmp_path / "state"),
1235
+ ],
1236
+ )
1237
+
1238
+ assert result.exit_code == 0, result.output
1239
+ assert captured["host"] == "0.0.0.0"
1240
+ assert captured["port"] == 9999
1241
+ assert captured["log_level"] == "info"
1242
+ config = captured["config"]
1243
+ assert isinstance(config, ShellctlConfig)
1244
+ assert config.auth_token == "direct-token"
1245
+
1246
+
1247
+ def test_serve_cli_prefers_explicit_auth_token_over_environment(
1248
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1249
+ ) -> None:
1250
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
1251
+ captured, runner = _capture_serve_config(monkeypatch)
1252
+
1253
+ result = runner.invoke(
1254
+ cli,
1255
+ [
1256
+ "serve",
1257
+ "--auth-token",
1258
+ "direct-token",
1259
+ "--state-dir",
1260
+ str(tmp_path / "state"),
1261
+ ],
1262
+ )
1263
+
1264
+ assert result.exit_code == 0, result.output
1265
+ config = captured["config"]
1266
+ assert isinstance(config, ShellctlConfig)
1267
+ assert config.auth_token == "direct-token"
1268
+
1269
+
1270
+ def test_serve_cli_treats_empty_auth_token_as_disabled(
1271
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1272
+ ) -> None:
1273
+ captured, runner = _capture_serve_config(monkeypatch)
1274
+
1275
+ result = runner.invoke(
1276
+ cli,
1277
+ [
1278
+ "serve",
1279
+ "--auth-token",
1280
+ "",
1281
+ "--state-dir",
1282
+ str(tmp_path / "state"),
1283
+ ],
1284
+ )
1285
+
1286
+ assert result.exit_code == 0, result.output
1287
+ config = captured["config"]
1288
+ assert isinstance(config, ShellctlConfig)
1289
+ assert config.auth_token is None
1290
+
1291
+
1292
+ def test_serve_cli_explicit_empty_auth_token_beats_environment(
1293
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1294
+ ) -> None:
1295
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
1296
+ captured, runner = _capture_serve_config(monkeypatch)
1297
+
1298
+ result = runner.invoke(
1299
+ cli,
1300
+ [
1301
+ "serve",
1302
+ "--auth-token",
1303
+ "",
1304
+ "--state-dir",
1305
+ str(tmp_path / "state"),
1306
+ ],
1307
+ )
1308
+
1309
+ assert result.exit_code == 0, result.output
1310
+ config = captured["config"]
1311
+ assert isinstance(config, ShellctlConfig)
1312
+ assert config.auth_token is None
1313
+
1314
+
1315
+ def test_serve_cli_reads_auth_token_from_environment(
1316
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1317
+ ) -> None:
1318
+ monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
1319
+ captured, runner = _capture_serve_config(monkeypatch)
1320
+
1321
+ result = runner.invoke(
1322
+ cli,
1323
+ [
1324
+ "serve",
1325
+ "--state-dir",
1326
+ str(tmp_path / "state"),
1327
+ ],
1328
+ )
1329
+
1330
+ assert result.exit_code == 0, result.output
1331
+ config = captured["config"]
1332
+ assert isinstance(config, ShellctlConfig)
1333
+ assert config.auth_token == "env-token"
1334
+
1335
+
1336
+ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1337
+ tmp_path: Path,
1338
+ ) -> None:
1339
+ service = ShellctlService(
1340
+ ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
1341
+ tmux=FakeTmuxController(),
1342
+ )
1343
+ source = service._runner_script_source()
1344
+
1345
+ assert RUNNER_EXIT_CODE_FILENAME in source
1346
+ assert RUNNER_ENDED_AT_FILENAME in source
1347
+ assert "write_atomic" in source
1348
+ assert 'mv "$tmp" "$dest"' in source
1349
+ assert re.search(r"\brunner-exit\s+--state-dir\b", source) is None
1350
+ anyio.run(service.shutdown)
1351
+
1352
+
1353
+ def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
1354
+ controller = TmuxController(
1355
+ ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
1356
+ )
1357
+ source = controller._pipe_command_source(
1358
+ job_id="05211530-k7p",
1359
+ job_dir=tmp_path / "state" / "jobs" / "05211530-k7p",
1360
+ ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
1361
+ )
1362
+
1363
+ assert "sanitize-pty" in source
1364
+ assert PIPE_DRAINED_FILENAME in source
1365
+ assert PIPE_FAILED_FILENAME in source
1366
+ assert RUNNER_EXIT_CODE_FILENAME in source
1367
+ assert RUNNER_ENDED_AT_FILENAME in source
1368
+ assert re.search(r"\brunner-exit\b", source) is not None
1369
+ assert 'if [ "$sanitize_status" -eq 0 ]' in source
1370
+ assert source.index("sanitize-pty") < source.index(PIPE_DRAINED_FILENAME)
1371
+ assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
1372
+
1373
+
818
1374
  def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
819
1375
  async def setup_running_job() -> ShellctlConfig:
820
1376
  service, _fake_tmux = await _create_service(tmp_path)