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.
Files changed (31) hide show
  1. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/PKG-INFO +14 -1
  2. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/README.md +13 -0
  3. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/pyproject.toml +1 -1
  4. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/api.py +4 -1
  5. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/cli.py +10 -2
  6. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/config.py +11 -10
  7. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/service.py +7 -3
  8. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shellctl_service.py +264 -5
  9. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/LICENSE +0 -0
  10. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/__init__.py +0 -0
  11. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/py.typed +0 -0
  12. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/session.py +0 -0
  13. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/__init__.py +0 -0
  14. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  15. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
  16. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
  17. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  18. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/db.py +0 -0
  19. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  20. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/server/tmux.py +0 -0
  21. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  22. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  23. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  24. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  25. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  26. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
  27. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/golden_shellctl_sanitize.json +0 -0
  28. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_autoclose.py +0 -0
  29. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shell_session_manager.py +0 -0
  30. {shell_session_manager-2.0.0 → shell_session_manager-2.1.0}/tests/test_shellctl_client.py +0 -0
  31. {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.0.0
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.0.0"
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
- 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"
@@ -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"]
@@ -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(self.config.auth_token_env)
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(tmp_path: Path) -> tuple[ShellctlService, FakeTmuxController]:
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(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
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, monkeypatch: pytest.MonkeyPatch
836
+ tmp_path: Path,
794
837
  ) -> None:
795
- monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "route-token")
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)