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.
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/PKG-INFO +14 -1
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/README.md +13 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/pyproject.toml +1 -1
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/api.py +4 -1
- shell_session_manager-2.1.1/src/shell_session_manager/shellctl/server/artifacts.py +45 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/cli.py +10 -2
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/config.py +11 -10
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/service.py +77 -13
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shellctl_service.py +561 -5
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/LICENSE +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shellctl_client.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
551
|
+
"""Persist a drained normal-exit fact into SQLite.
|
|
540
552
|
|
|
541
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"--ready-file",
|
|
156
|
-
str(ready_file),
|
|
157
|
-
)
|
|
158
|
-
)
|
|
159
|
-
output_command = (
|
|
160
|
-
f"{sanitize_command} >> {shlex.quote(str(job_dir / 'output.log'))}"
|
|
157
|
+
output_command = self._pipe_command_source(
|
|
158
|
+
job_id=job_id,
|
|
159
|
+
job_dir=job_dir,
|
|
160
|
+
ready_file=ready_file,
|
|
161
161
|
)
|
|
162
162
|
result = await self._run_tmux(
|
|
163
163
|
"pipe-pane",
|
|
@@ -175,6 +175,55 @@ class TmuxController:
|
|
|
175
175
|
or f"Failed to attach output pipe for {job_id}",
|
|
176
176
|
)
|
|
177
177
|
|
|
178
|
+
def _pipe_command_source(
|
|
179
|
+
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Build the tmux `pipe-pane` command that drains and finalizes output.
|
|
182
|
+
|
|
183
|
+
For normal exits, the runner now records completion metadata into job
|
|
184
|
+
artifacts and the pipe finalizer commits `runner-exit` only after
|
|
185
|
+
`sanitize-pty` reaches EOF and flushes `output.log` successfully.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
sanitize_command = self._shell_join(
|
|
189
|
+
(
|
|
190
|
+
*self._config.shellctl_command,
|
|
191
|
+
"sanitize-pty",
|
|
192
|
+
"--ready-file",
|
|
193
|
+
str(ready_file),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
runner_exit_command = self._shell_join(
|
|
197
|
+
(
|
|
198
|
+
*self._config.shellctl_command,
|
|
199
|
+
"runner-exit",
|
|
200
|
+
"--state-dir",
|
|
201
|
+
str(self._config.state_dir),
|
|
202
|
+
"--job-id",
|
|
203
|
+
job_id,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
output_path = shlex.quote(str(job_dir / "output.log"))
|
|
207
|
+
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
|
208
|
+
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
|
209
|
+
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
|
210
|
+
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
|
211
|
+
return " ; ".join(
|
|
212
|
+
[
|
|
213
|
+
f"{sanitize_command} >> {output_path}",
|
|
214
|
+
"sanitize_status=$?",
|
|
215
|
+
(
|
|
216
|
+
'if [ "$sanitize_status" -eq 0 ]; then '
|
|
217
|
+
f": > {drained_path}; "
|
|
218
|
+
f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
|
|
219
|
+
f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
|
|
220
|
+
f'--ended-at "$(cat {ended_at_path})"; fi; '
|
|
221
|
+
f"else : > {failed_path}; fi"
|
|
222
|
+
),
|
|
223
|
+
'exit "$sanitize_status"',
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
178
227
|
async def send_input(self, *, job_id: str, text: str) -> None:
|
|
179
228
|
buffer_name = f"shellctl-in-{job_id}"
|
|
180
229
|
runtime_dir = cast(Path, self._config.runtime_dir)
|
|
@@ -286,6 +335,7 @@ def _tmux_target_missing(stderr: str) -> bool:
|
|
|
286
335
|
or "can't find session" in normalized
|
|
287
336
|
or "no server running" in normalized
|
|
288
337
|
or "failed to connect" in normalized
|
|
338
|
+
or "server exited unexpectedly" in normalized
|
|
289
339
|
)
|
|
290
340
|
|
|
291
341
|
|
|
@@ -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(
|
|
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(
|
|
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,
|
|
1095
|
+
tmp_path: Path,
|
|
794
1096
|
) -> None:
|
|
795
|
-
|
|
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)
|
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/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
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|