shell-session-manager 2.0.0__tar.gz → 2.1.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.0.0 → shell_session_manager-2.1.0}/PKG-INFO +14 -1
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/README.md +13 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/pyproject.toml +1 -1
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/api.py +4 -1
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/cli.py +10 -2
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/config.py +11 -10
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/service.py +7 -3
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shellctl_service.py +264 -5
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/LICENSE +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/tmux.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_manager.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shellctl_client.py +0 -0
- {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/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.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
|
|
@@ -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.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" },
|
|
@@ -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"
|
|
@@ -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"]
|
|
@@ -31,6 +31,7 @@ from shell_session_manager.shellctl.server.tmux import (
|
|
|
31
31
|
TmuxControllerProtocol,
|
|
32
32
|
)
|
|
33
33
|
from shell_session_manager.shellctl.shared import (
|
|
34
|
+
DEFAULT_AUTH_TOKEN_ENV,
|
|
34
35
|
DeleteJobResponse,
|
|
35
36
|
InputJobRequest,
|
|
36
37
|
JobInfo,
|
|
@@ -97,9 +98,12 @@ class ShellctlService:
|
|
|
97
98
|
await connection.run_sync(SQLModel.metadata.create_all)
|
|
98
99
|
|
|
99
100
|
async def initialize(self) -> None:
|
|
100
|
-
"""Prepare directories, database, runner script, and tmux state.
|
|
101
|
+
"""Prepare directories, database, runner script, and tmux state.
|
|
102
|
+
|
|
103
|
+
Auth is configured at the API layer, so service startup must also work
|
|
104
|
+
when `shellctl serve` is intentionally running without a bearer token.
|
|
105
|
+
"""
|
|
101
106
|
|
|
102
|
-
_ = self.config.auth_token
|
|
103
107
|
await self.initialize_database()
|
|
104
108
|
self._ensure_dir(cast(Path, self.config.runtime_dir))
|
|
105
109
|
self._ensure_dir(self.config.jobs_dir)
|
|
@@ -966,7 +970,7 @@ class ShellctlService:
|
|
|
966
970
|
def _runner_script_source(self) -> str:
|
|
967
971
|
tmux_socket = shlex.quote(str(self.config.tmux_socket))
|
|
968
972
|
state_dir = shlex.quote(str(self.config.state_dir))
|
|
969
|
-
auth_env = shlex.quote(
|
|
973
|
+
auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
|
|
970
974
|
shellctl_command = " ".join(
|
|
971
975
|
shlex.quote(part) for part in self.config.shellctl_command
|
|
972
976
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import importlib
|
|
3
4
|
import os
|
|
4
5
|
import sqlite3
|
|
5
6
|
import subprocess
|
|
@@ -23,6 +24,7 @@ from shell_session_manager.shellctl.server import (
|
|
|
23
24
|
create_app,
|
|
24
25
|
)
|
|
25
26
|
from shell_session_manager.shellctl.shared import (
|
|
27
|
+
DEFAULT_AUTH_TOKEN_ENV,
|
|
26
28
|
InputJobRequest,
|
|
27
29
|
JobStatusName,
|
|
28
30
|
JobStatusView,
|
|
@@ -34,6 +36,8 @@ from shell_session_manager.shellctl.shared import (
|
|
|
34
36
|
job_session_name,
|
|
35
37
|
)
|
|
36
38
|
|
|
39
|
+
server_cli_module = importlib.import_module("shell_session_manager.shellctl.server.cli")
|
|
40
|
+
|
|
37
41
|
|
|
38
42
|
class FakeTmuxController:
|
|
39
43
|
def __init__(self) -> None:
|
|
@@ -85,10 +89,16 @@ class FakeTmuxController:
|
|
|
85
89
|
self.pipe_active.pop(job_id, None)
|
|
86
90
|
|
|
87
91
|
|
|
88
|
-
async def _create_service(
|
|
92
|
+
async def _create_service(
|
|
93
|
+
tmp_path: Path, *, auth_token: str | None = None
|
|
94
|
+
) -> tuple[ShellctlService, FakeTmuxController]:
|
|
89
95
|
fake_tmux = FakeTmuxController()
|
|
90
96
|
service = ShellctlService(
|
|
91
|
-
ShellctlConfig(
|
|
97
|
+
ShellctlConfig(
|
|
98
|
+
auth_token=auth_token,
|
|
99
|
+
state_dir=tmp_path / "state",
|
|
100
|
+
runtime_dir=tmp_path / "run",
|
|
101
|
+
),
|
|
92
102
|
tmux=fake_tmux,
|
|
93
103
|
)
|
|
94
104
|
await service.initialize_database()
|
|
@@ -96,6 +106,26 @@ async def _create_service(tmp_path: Path) -> tuple[ShellctlService, FakeTmuxCont
|
|
|
96
106
|
return service, fake_tmux
|
|
97
107
|
|
|
98
108
|
|
|
109
|
+
def _capture_serve_config(
|
|
110
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
111
|
+
) -> tuple[dict[str, object], CliRunner]:
|
|
112
|
+
captured: dict[str, object] = {}
|
|
113
|
+
|
|
114
|
+
def fake_create_app(config: ShellctlConfig):
|
|
115
|
+
captured["config"] = config
|
|
116
|
+
return object()
|
|
117
|
+
|
|
118
|
+
def fake_run(app: object, *, host: str, port: int, log_level: str) -> None:
|
|
119
|
+
captured["app"] = app
|
|
120
|
+
captured["host"] = host
|
|
121
|
+
captured["port"] = port
|
|
122
|
+
captured["log_level"] = log_level
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(server_cli_module, "create_app", fake_create_app)
|
|
125
|
+
monkeypatch.setattr(server_cli_module.uvicorn, "run", fake_run)
|
|
126
|
+
return captured, CliRunner()
|
|
127
|
+
|
|
128
|
+
|
|
99
129
|
async def _seed_job(
|
|
100
130
|
service: ShellctlService,
|
|
101
131
|
*,
|
|
@@ -788,12 +818,24 @@ async def test_allocate_job_dir_retries_on_atomic_mkdir_collision(
|
|
|
788
818
|
await service.shutdown()
|
|
789
819
|
|
|
790
820
|
|
|
821
|
+
@pytest.mark.anyio
|
|
822
|
+
async def test_service_initialize_allows_missing_auth_token(tmp_path: Path) -> None:
|
|
823
|
+
service = ShellctlService(
|
|
824
|
+
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
|
|
825
|
+
tmux=FakeTmuxController(),
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
await service.initialize()
|
|
829
|
+
|
|
830
|
+
assert service.config.runner_path.exists()
|
|
831
|
+
await service.shutdown()
|
|
832
|
+
|
|
833
|
+
|
|
791
834
|
@pytest.mark.anyio
|
|
792
835
|
async def test_http_routes_inject_shellctl_service_dependency(
|
|
793
|
-
tmp_path: Path,
|
|
836
|
+
tmp_path: Path,
|
|
794
837
|
) -> None:
|
|
795
|
-
|
|
796
|
-
service, _fake_tmux = await _create_service(tmp_path)
|
|
838
|
+
service, _fake_tmux = await _create_service(tmp_path, auth_token="route-token")
|
|
797
839
|
app = create_app(service.config, service=service)
|
|
798
840
|
transport = httpx.ASGITransport(app=app)
|
|
799
841
|
|
|
@@ -815,6 +857,223 @@ async def test_http_routes_inject_shellctl_service_dependency(
|
|
|
815
857
|
await service.shutdown()
|
|
816
858
|
|
|
817
859
|
|
|
860
|
+
@pytest.mark.anyio
|
|
861
|
+
async def test_http_routes_enforce_auth_from_environment_fallback(
|
|
862
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
863
|
+
) -> None:
|
|
864
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "route-token")
|
|
865
|
+
service, _fake_tmux = await _create_service(tmp_path)
|
|
866
|
+
app = create_app(service.config, service=service)
|
|
867
|
+
transport = httpx.ASGITransport(app=app)
|
|
868
|
+
|
|
869
|
+
async with httpx.AsyncClient(
|
|
870
|
+
transport=transport, base_url="http://shellctl.test"
|
|
871
|
+
) as client:
|
|
872
|
+
unauthenticated = await client.get("/v1/jobs")
|
|
873
|
+
authenticated = await client.get(
|
|
874
|
+
"/v1/jobs", headers={"Authorization": "Bearer route-token"}
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
assert unauthenticated.status_code == 401
|
|
878
|
+
assert authenticated.status_code == 200
|
|
879
|
+
await service.shutdown()
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@pytest.mark.anyio
|
|
883
|
+
async def test_create_app_without_explicit_config_reads_auth_token_from_environment(
|
|
884
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
885
|
+
) -> None:
|
|
886
|
+
async def noop_initialize(self: ShellctlService) -> None:
|
|
887
|
+
return None
|
|
888
|
+
|
|
889
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "route-token")
|
|
890
|
+
monkeypatch.setattr(ShellctlService, "initialize", noop_initialize)
|
|
891
|
+
monkeypatch.setattr(ShellctlService, "start_background_gc", lambda self: None)
|
|
892
|
+
monkeypatch.setattr(
|
|
893
|
+
ShellctlService, "start_background_pipe_monitor", lambda self: None
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
app = create_app()
|
|
897
|
+
transport = httpx.ASGITransport(app=app)
|
|
898
|
+
|
|
899
|
+
async with httpx.AsyncClient(
|
|
900
|
+
transport=transport, base_url="http://shellctl.test"
|
|
901
|
+
) as client:
|
|
902
|
+
response = await client.get("/v1/jobs")
|
|
903
|
+
|
|
904
|
+
assert app.state.shellctl_service.config.auth_token == "route-token"
|
|
905
|
+
assert response.status_code == 401
|
|
906
|
+
await app.state.shellctl_service.shutdown()
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@pytest.mark.anyio
|
|
910
|
+
async def test_http_routes_skip_auth_when_token_missing(tmp_path: Path) -> None:
|
|
911
|
+
service, _fake_tmux = await _create_service(tmp_path)
|
|
912
|
+
app = create_app(service.config, service=service)
|
|
913
|
+
transport = httpx.ASGITransport(app=app)
|
|
914
|
+
|
|
915
|
+
async with httpx.AsyncClient(
|
|
916
|
+
transport=transport, base_url="http://shellctl.test"
|
|
917
|
+
) as client:
|
|
918
|
+
response = await client.get("/v1/jobs")
|
|
919
|
+
|
|
920
|
+
assert response.status_code == 200
|
|
921
|
+
assert response.json() == {"jobs": []}
|
|
922
|
+
await service.shutdown()
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
@pytest.mark.anyio
|
|
926
|
+
async def test_http_routes_skip_auth_when_token_is_empty(tmp_path: Path) -> None:
|
|
927
|
+
service, _fake_tmux = await _create_service(tmp_path, auth_token="")
|
|
928
|
+
app = create_app(service.config, service=service)
|
|
929
|
+
transport = httpx.ASGITransport(app=app)
|
|
930
|
+
|
|
931
|
+
async with httpx.AsyncClient(
|
|
932
|
+
transport=transport, base_url="http://shellctl.test"
|
|
933
|
+
) as client:
|
|
934
|
+
response = await client.get("/v1/jobs")
|
|
935
|
+
|
|
936
|
+
assert response.status_code == 200
|
|
937
|
+
assert response.json() == {"jobs": []}
|
|
938
|
+
await service.shutdown()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def test_shellctl_config_reads_auth_token_from_environment(
|
|
942
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
943
|
+
) -> None:
|
|
944
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
|
|
945
|
+
|
|
946
|
+
config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
947
|
+
|
|
948
|
+
assert config.auth_token == "env-token"
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def test_shellctl_config_treats_empty_environment_auth_token_as_disabled(
|
|
952
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
953
|
+
) -> None:
|
|
954
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "")
|
|
955
|
+
|
|
956
|
+
config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
957
|
+
|
|
958
|
+
assert config.auth_token is None
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def test_serve_cli_passes_direct_auth_token_to_config(
|
|
962
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
963
|
+
) -> None:
|
|
964
|
+
captured, runner = _capture_serve_config(monkeypatch)
|
|
965
|
+
|
|
966
|
+
result = runner.invoke(
|
|
967
|
+
cli,
|
|
968
|
+
[
|
|
969
|
+
"serve",
|
|
970
|
+
"--listen",
|
|
971
|
+
"0.0.0.0:9999",
|
|
972
|
+
"--auth-token",
|
|
973
|
+
"direct-token",
|
|
974
|
+
"--state-dir",
|
|
975
|
+
str(tmp_path / "state"),
|
|
976
|
+
],
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
assert result.exit_code == 0, result.output
|
|
980
|
+
assert captured["host"] == "0.0.0.0"
|
|
981
|
+
assert captured["port"] == 9999
|
|
982
|
+
assert captured["log_level"] == "info"
|
|
983
|
+
config = captured["config"]
|
|
984
|
+
assert isinstance(config, ShellctlConfig)
|
|
985
|
+
assert config.auth_token == "direct-token"
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def test_serve_cli_prefers_explicit_auth_token_over_environment(
|
|
989
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
990
|
+
) -> None:
|
|
991
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
|
|
992
|
+
captured, runner = _capture_serve_config(monkeypatch)
|
|
993
|
+
|
|
994
|
+
result = runner.invoke(
|
|
995
|
+
cli,
|
|
996
|
+
[
|
|
997
|
+
"serve",
|
|
998
|
+
"--auth-token",
|
|
999
|
+
"direct-token",
|
|
1000
|
+
"--state-dir",
|
|
1001
|
+
str(tmp_path / "state"),
|
|
1002
|
+
],
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
assert result.exit_code == 0, result.output
|
|
1006
|
+
config = captured["config"]
|
|
1007
|
+
assert isinstance(config, ShellctlConfig)
|
|
1008
|
+
assert config.auth_token == "direct-token"
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def test_serve_cli_treats_empty_auth_token_as_disabled(
|
|
1012
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1013
|
+
) -> None:
|
|
1014
|
+
captured, runner = _capture_serve_config(monkeypatch)
|
|
1015
|
+
|
|
1016
|
+
result = runner.invoke(
|
|
1017
|
+
cli,
|
|
1018
|
+
[
|
|
1019
|
+
"serve",
|
|
1020
|
+
"--auth-token",
|
|
1021
|
+
"",
|
|
1022
|
+
"--state-dir",
|
|
1023
|
+
str(tmp_path / "state"),
|
|
1024
|
+
],
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
assert result.exit_code == 0, result.output
|
|
1028
|
+
config = captured["config"]
|
|
1029
|
+
assert isinstance(config, ShellctlConfig)
|
|
1030
|
+
assert config.auth_token is None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def test_serve_cli_explicit_empty_auth_token_beats_environment(
|
|
1034
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1035
|
+
) -> None:
|
|
1036
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
|
|
1037
|
+
captured, runner = _capture_serve_config(monkeypatch)
|
|
1038
|
+
|
|
1039
|
+
result = runner.invoke(
|
|
1040
|
+
cli,
|
|
1041
|
+
[
|
|
1042
|
+
"serve",
|
|
1043
|
+
"--auth-token",
|
|
1044
|
+
"",
|
|
1045
|
+
"--state-dir",
|
|
1046
|
+
str(tmp_path / "state"),
|
|
1047
|
+
],
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
assert result.exit_code == 0, result.output
|
|
1051
|
+
config = captured["config"]
|
|
1052
|
+
assert isinstance(config, ShellctlConfig)
|
|
1053
|
+
assert config.auth_token is None
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def test_serve_cli_reads_auth_token_from_environment(
|
|
1057
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1058
|
+
) -> None:
|
|
1059
|
+
monkeypatch.setenv(DEFAULT_AUTH_TOKEN_ENV, "env-token")
|
|
1060
|
+
captured, runner = _capture_serve_config(monkeypatch)
|
|
1061
|
+
|
|
1062
|
+
result = runner.invoke(
|
|
1063
|
+
cli,
|
|
1064
|
+
[
|
|
1065
|
+
"serve",
|
|
1066
|
+
"--state-dir",
|
|
1067
|
+
str(tmp_path / "state"),
|
|
1068
|
+
],
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
assert result.exit_code == 0, result.output
|
|
1072
|
+
config = captured["config"]
|
|
1073
|
+
assert isinstance(config, ShellctlConfig)
|
|
1074
|
+
assert config.auth_token == "env-token"
|
|
1075
|
+
|
|
1076
|
+
|
|
818
1077
|
def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
|
|
819
1078
|
async def setup_running_job() -> ShellctlConfig:
|
|
820
1079
|
service, _fake_tmux = await _create_service(tmp_path)
|
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.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
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|