mainsequence 9.0.1.dev30__tar.gz → 9.0.1.dev31__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.
- {mainsequence-9.0.1.dev30/mainsequence.egg-info → mainsequence-9.0.1.dev31}/PKG-INFO +1 -1
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/config.py +18 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/utils.py +149 -37
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31/mainsequence.egg-info}/PKG-INFO +1 -1
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/pyproject.toml +1 -1
- mainsequence-9.0.1.dev31/tests/test_auth_bootstrap.py +339 -0
- mainsequence-9.0.1.dev30/tests/test_auth_bootstrap.py +0 -182
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/LICENSE +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/README.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/AGENTS.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/__init__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/__main__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/_request_identity.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/bootstrap.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/__init__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/api.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/browser_auth.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/cli.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/code_repository_status.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/docker_utils.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/doctor.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/local_ops.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/model_filters.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/pydantic_cli.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/sdk_utils.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/ssh_utils.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/ui.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/__init__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/agent_runtime_models.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/base.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/client.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/compute_validation.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/exceptions.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/github_issues.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/models_foundry.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/models_helpers.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/models_user.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/observability.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/value_sets.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/code_repository_context.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/code_repository_skills.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/defaults.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/instrumentation/__init__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/instrumentation/utils.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/logconf.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/repository_identity_security.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/runtime_flags.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/scaffold_skills.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/server/__init__.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/server/caller_assertions.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/server/fastapi.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/SOURCES.txt +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/dependency_links.txt +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/entry_points.txt +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/requires.txt +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/top_level.txt +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/setup.cfg +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_auth_precedence.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_caller_assertions.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_canonical_api_route_contract.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_cli.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_cli_auth_contract.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_cli_browser_auth.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_cli_secure_storage.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_client.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_branch_contract.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_documentation_contract.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_runtime_context.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_skills.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_dependency_extras.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_dev_release_version.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_environment_resource_context.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_environment_response_contracts.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_fastapi_request_identity.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_filter_normalization.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_github_issues_client.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_independent_context.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_instrumentation.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_job_client.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_local_ops.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_logconf.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_models_user_request_bound_auth.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_notification_uid_contract.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_observability_client.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_scaffold_skills.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_secret_client_model.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_source_ownership.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_ssh_utils.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_tolerant_response_reading.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_tolerant_value_sets.py +0 -0
- {mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_user_application_facts.py +0 -0
|
@@ -756,6 +756,24 @@ def secure_store_available() -> bool:
|
|
|
756
756
|
return _secure_keyring_backend() is not None
|
|
757
757
|
|
|
758
758
|
|
|
759
|
+
def saved_session_summary(backend: str | None = None) -> dict:
|
|
760
|
+
"""
|
|
761
|
+
Describe the saved session of a backend without any token value.
|
|
762
|
+
|
|
763
|
+
`usable` says whether its refresh token has not expired by its own claim,
|
|
764
|
+
`expires_at` is that claim in epoch seconds (None when absent) and
|
|
765
|
+
`username` is the recorded user. The process environment is not consulted.
|
|
766
|
+
"""
|
|
767
|
+
tokens = _read_secure_tokens(backend)
|
|
768
|
+
refresh = (tokens.get("refresh") or "").strip()
|
|
769
|
+
expires_at = token_expiry(refresh)
|
|
770
|
+
return {
|
|
771
|
+
"username": str(tokens.get("username") or ""),
|
|
772
|
+
"expires_at": expires_at,
|
|
773
|
+
"usable": bool(refresh) and (expires_at is None or expires_at > int(time.time())),
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
759
777
|
def stored_session_available(backend: str | None = None) -> bool:
|
|
760
778
|
"""
|
|
761
779
|
Return whether the credential store holds a renewable session for a backend.
|
|
@@ -70,42 +70,145 @@ def set_mainsequence_endpoint(endpoint: str) -> None:
|
|
|
70
70
|
AUTH_ENDPOINT = normalized
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
_SESSION_TOKEN_VARIABLES = ("MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN")
|
|
74
|
+
# Renewal answers that refuse the credentials, as opposed to a backend that failed.
|
|
75
|
+
_REFUSED_RENEWAL_STATUSES = frozenset({400, 401, 403})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _format_utc(epoch: int) -> str:
|
|
79
|
+
return datetime.datetime.fromtimestamp(epoch, tz=datetime.UTC).strftime(
|
|
80
|
+
"%Y-%m-%d %H:%M UTC"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _session_backend() -> str:
|
|
85
|
+
return (AUTH_ENDPOINT or "").rstrip("/")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _login_command(backend: str) -> str:
|
|
74
89
|
"""
|
|
75
|
-
|
|
90
|
+
Return the CLI command that signs in to `backend`.
|
|
76
91
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
pair may belong to another user or backend; the message names it instead.
|
|
92
|
+
`mainsequence login` alone signs in to the configured backend. Another
|
|
93
|
+
backend needs its address and the folder that holds its repositories.
|
|
80
94
|
"""
|
|
81
95
|
try:
|
|
82
|
-
from mainsequence import bootstrap
|
|
83
96
|
from mainsequence.cli import config as cli_config
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
configured = cli_config.normalize_backend_url(
|
|
99
|
+
cli_config.get_persistent_config().get("backend_url")
|
|
100
|
+
)
|
|
87
101
|
except Exception:
|
|
88
|
-
|
|
102
|
+
configured = ""
|
|
103
|
+
if not backend or backend == configured:
|
|
104
|
+
return "`mainsequence login`"
|
|
105
|
+
return f"`mainsequence login {backend} <base folder>`"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_file_holding_process_credentials() -> pathlib.Path | None:
|
|
109
|
+
"""
|
|
110
|
+
Return the working directory's `.env` when it holds this process's tokens.
|
|
111
|
+
|
|
112
|
+
IDE run configurations, launchers and dotenv calls load that file into a
|
|
113
|
+
process's environment. Values are compared here and never shown.
|
|
114
|
+
"""
|
|
115
|
+
from mainsequence.cli import config as cli_config
|
|
116
|
+
|
|
117
|
+
env_path = pathlib.Path.cwd() / ".env"
|
|
118
|
+
try:
|
|
119
|
+
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
120
|
+
except (OSError, UnicodeError):
|
|
121
|
+
return None
|
|
122
|
+
for line in lines:
|
|
123
|
+
key = cli_config.env_line_key(line)
|
|
124
|
+
if key not in _SESSION_TOKEN_VARIABLES:
|
|
125
|
+
continue
|
|
126
|
+
value = line.split("=", 1)[1].split(" #", 1)[0].strip().strip("\"'")
|
|
127
|
+
if value and value == (os.environ.get(key) or "").strip():
|
|
128
|
+
return env_path
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _jwt_reauth_hint(refresh_token: str | None = None) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Say why the backend refused this process's credentials and how to repair them.
|
|
135
|
+
|
|
136
|
+
The facts come from this process and this machine: the refresh token's own
|
|
137
|
+
expiry, whether the credentials were already in the environment when the SDK
|
|
138
|
+
started (and which `.env` holds them, when one does), and the saved session
|
|
139
|
+
of the backend. A pair from the environment wins over the saved session, so
|
|
140
|
+
the saved session is named and not used: the pair may belong to another user
|
|
141
|
+
or backend. No token value is included.
|
|
142
|
+
"""
|
|
143
|
+
backend = _session_backend()
|
|
144
|
+
refresh = refresh_token if refresh_token is not None else os.getenv("MAINSEQUENCE_REFRESH_TOKEN")
|
|
145
|
+
expiry = _decode_jwt_exp(refresh)
|
|
146
|
+
try:
|
|
147
|
+
from mainsequence import bootstrap
|
|
148
|
+
|
|
149
|
+
source = bootstrap.credential_source()
|
|
150
|
+
from_environment = source == bootstrap.CREDENTIALS_FROM_ENVIRONMENT
|
|
151
|
+
from_store = source == bootstrap.CREDENTIALS_FROM_STORE
|
|
152
|
+
except Exception:
|
|
153
|
+
from_environment = from_store = False
|
|
154
|
+
login = _login_command(backend)
|
|
155
|
+
|
|
156
|
+
parts = []
|
|
157
|
+
if expiry is not None and expiry <= int(time.time()):
|
|
158
|
+
parts.append(f" The refresh token expired on {_format_utc(expiry)}.")
|
|
159
|
+
elif expiry is not None:
|
|
160
|
+
parts.append(
|
|
161
|
+
" The refresh token has not expired: the backend revoked it, or it was "
|
|
162
|
+
"issued by another backend."
|
|
163
|
+
)
|
|
89
164
|
|
|
90
165
|
if from_environment:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
166
|
+
try:
|
|
167
|
+
env_file = _env_file_holding_process_credentials()
|
|
168
|
+
except Exception:
|
|
169
|
+
env_file = None
|
|
170
|
+
try:
|
|
171
|
+
from mainsequence.cli import config as cli_config
|
|
172
|
+
|
|
173
|
+
saved = cli_config.saved_session_summary(backend)
|
|
174
|
+
except Exception:
|
|
175
|
+
saved = {"usable": False, "username": "", "expires_at": None}
|
|
176
|
+
if env_file is not None:
|
|
177
|
+
parts.append(
|
|
178
|
+
f" These credentials come from the token lines of {env_file}, not from "
|
|
179
|
+
"your saved session."
|
|
180
|
+
)
|
|
181
|
+
cleanup = f"run `mainsequence refresh-token` in {env_file.parent} to remove those lines"
|
|
182
|
+
else:
|
|
183
|
+
parts.append(
|
|
184
|
+
" These credentials were in this process's environment "
|
|
185
|
+
"(MAINSEQUENCE_ACCESS_TOKEN / MAINSEQUENCE_REFRESH_TOKEN) when it started, "
|
|
186
|
+
"set by the program that started it, not taken from your saved session."
|
|
187
|
+
)
|
|
188
|
+
cleanup = "start the process without those two variables"
|
|
189
|
+
if saved["usable"]:
|
|
190
|
+
who = f" ({saved['username']})" if saved["username"] else ""
|
|
191
|
+
until = (
|
|
192
|
+
f", valid until {_format_utc(saved['expires_at'])}"
|
|
193
|
+
if saved["expires_at"] is not None
|
|
194
|
+
else ""
|
|
195
|
+
)
|
|
196
|
+
parts.append(
|
|
197
|
+
f" Your saved session for {backend}{who} is usable{until}: "
|
|
198
|
+
f"{cleanup}, and the next start uses it."
|
|
102
199
|
)
|
|
200
|
+
else:
|
|
201
|
+
parts.append(
|
|
202
|
+
f" There is no usable saved session for {backend}. Sign in with {login}, "
|
|
203
|
+
f"then {cleanup}."
|
|
204
|
+
)
|
|
205
|
+
elif from_store:
|
|
206
|
+
parts.append(
|
|
207
|
+
f" These credentials are your saved session for {backend}. Sign in again with {login}."
|
|
103
208
|
)
|
|
104
|
-
|
|
105
|
-
"
|
|
106
|
-
|
|
107
|
-
"use `mainsequence login --export` and load the exported env vars there."
|
|
108
|
-
)
|
|
209
|
+
else:
|
|
210
|
+
parts.append(f" Sign in with {login}.")
|
|
211
|
+
return "".join(parts)
|
|
109
212
|
|
|
110
213
|
|
|
111
214
|
def _env_has_value(name: str) -> bool:
|
|
@@ -397,10 +500,14 @@ class JWTAuthProvider(BaseAuthProvider):
|
|
|
397
500
|
if not force and not self._needs_refresh():
|
|
398
501
|
return
|
|
399
502
|
|
|
503
|
+
backend = _session_backend()
|
|
400
504
|
if not self.refresh_token:
|
|
401
505
|
if self.access_token and not force:
|
|
402
506
|
return
|
|
403
|
-
raise AuthError(
|
|
507
|
+
raise AuthError(
|
|
508
|
+
f"Main Sequence cannot renew the session of this process for {backend}: "
|
|
509
|
+
"it has no refresh token." + _jwt_reauth_hint(self.refresh_token)
|
|
510
|
+
)
|
|
404
511
|
|
|
405
512
|
http_client = session or requests
|
|
406
513
|
|
|
@@ -411,16 +518,24 @@ class JWTAuthProvider(BaseAuthProvider):
|
|
|
411
518
|
timeout=self.timeout,
|
|
412
519
|
)
|
|
413
520
|
|
|
521
|
+
if r.status_code in _REFUSED_RENEWAL_STATUSES:
|
|
522
|
+
raise AuthError(
|
|
523
|
+
f"Main Sequence refused to renew the session of this process for {backend} "
|
|
524
|
+
f"(HTTP {r.status_code})." + _jwt_reauth_hint(self.refresh_token)
|
|
525
|
+
)
|
|
414
526
|
if r.status_code != 200:
|
|
527
|
+
# The backend failed; the credentials were not judged.
|
|
415
528
|
raise AuthError(
|
|
416
|
-
f"
|
|
529
|
+
f"Main Sequence could not renew the session of this process for {backend}: "
|
|
530
|
+
f"the backend answered HTTP {r.status_code}. Try again when it is available."
|
|
417
531
|
)
|
|
418
532
|
|
|
419
533
|
data = r.json()
|
|
420
534
|
access = data.get("access")
|
|
421
535
|
if not access:
|
|
422
536
|
raise AuthError(
|
|
423
|
-
"
|
|
537
|
+
f"Main Sequence renewed the session for {backend} without an access token."
|
|
538
|
+
+ _jwt_reauth_hint(self.refresh_token)
|
|
424
539
|
)
|
|
425
540
|
|
|
426
541
|
# Important if ROTATE_REFRESH_TOKENS=True
|
|
@@ -604,15 +719,12 @@ def make_request(
|
|
|
604
719
|
|
|
605
720
|
if r.status_code == 401 and loaders is not None and not auth_retried:
|
|
606
721
|
logger.warning(f"Error {r.status_code}; forcing auth refresh once")
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
logger.exception("Auth refresh failed")
|
|
614
|
-
keep_request = False
|
|
615
|
-
break
|
|
722
|
+
# A refused renewal raises AuthError: it is reported below with the
|
|
723
|
+
# reason and the repair, not as a traceback.
|
|
724
|
+
s.headers.update(loaders.refresh_headers(force=True, session=s))
|
|
725
|
+
req = get_req(session=s)
|
|
726
|
+
auth_retried = True
|
|
727
|
+
continue
|
|
616
728
|
|
|
617
729
|
keep_request = False
|
|
618
730
|
break
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Authentication compatibility for normal CLI-backed SDK processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from mainsequence.bootstrap import prime_runtime_env
|
|
17
|
+
from mainsequence.cli import config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def bootstrap(monkeypatch, tmp_path):
|
|
22
|
+
monkeypatch.chdir(tmp_path)
|
|
23
|
+
for key in ("MAINSEQUENCE_ENDPOINT", "MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN"):
|
|
24
|
+
monkeypatch.delenv(key, raising=False)
|
|
25
|
+
monkeypatch.setattr(config, "backend_url", lambda: "https://saved.example.test")
|
|
26
|
+
monkeypatch.setattr(
|
|
27
|
+
config, "get_tokens", lambda: {"access": "saved-access", "refresh": "saved-refresh"}
|
|
28
|
+
)
|
|
29
|
+
return tmp_path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_saved_cli_session_is_available_to_sdk_process(bootstrap):
|
|
33
|
+
prime_runtime_env()
|
|
34
|
+
assert os.environ["MAINSEQUENCE_ENDPOINT"] == "https://saved.example.test"
|
|
35
|
+
assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "saved-access"
|
|
36
|
+
assert os.environ["MAINSEQUENCE_REFRESH_TOKEN"] == "saved-refresh"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_saved_session_brings_its_user_name_with_its_tokens(bootstrap, monkeypatch):
|
|
40
|
+
monkeypatch.setenv("MAINSEQUENCE_USERNAME", "")
|
|
41
|
+
monkeypatch.setattr(
|
|
42
|
+
config,
|
|
43
|
+
"get_tokens",
|
|
44
|
+
lambda: {
|
|
45
|
+
"username": "ada@example.com",
|
|
46
|
+
"access": "saved-access",
|
|
47
|
+
"refresh": "saved-refresh",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
prime_runtime_env()
|
|
51
|
+
assert os.environ["MAINSEQUENCE_USERNAME"] == "ada@example.com"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_explicit_process_credentials_are_not_given_the_saved_user_name(bootstrap, monkeypatch):
|
|
55
|
+
monkeypatch.setenv("MAINSEQUENCE_USERNAME", "")
|
|
56
|
+
monkeypatch.setenv("MAINSEQUENCE_ACCESS_TOKEN", "explicit")
|
|
57
|
+
monkeypatch.setattr(
|
|
58
|
+
config,
|
|
59
|
+
"get_tokens",
|
|
60
|
+
lambda: {"username": "ada@example.com", "access": "explicit", "refresh": ""},
|
|
61
|
+
)
|
|
62
|
+
prime_runtime_env()
|
|
63
|
+
# Whose token the environment carries is not known.
|
|
64
|
+
assert os.environ["MAINSEQUENCE_USERNAME"] == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_bootstrap_preserves_explicit_process_credentials_and_endpoint(bootstrap, monkeypatch):
|
|
68
|
+
for key in ("MAINSEQUENCE_ENDPOINT", "MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN"):
|
|
69
|
+
monkeypatch.setenv(key, "explicit")
|
|
70
|
+
prime_runtime_env()
|
|
71
|
+
assert all(
|
|
72
|
+
os.environ[key] == "explicit"
|
|
73
|
+
for key in (
|
|
74
|
+
"MAINSEQUENCE_ENDPOINT",
|
|
75
|
+
"MAINSEQUENCE_ACCESS_TOKEN",
|
|
76
|
+
"MAINSEQUENCE_REFRESH_TOKEN",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_checkout_endpoint_precedes_saved_backend(bootstrap):
|
|
82
|
+
(bootstrap / ".env").write_text("MAINSEQUENCE_ENDPOINT=https://checkout.example.test\n")
|
|
83
|
+
prime_runtime_env()
|
|
84
|
+
assert os.environ["MAINSEQUENCE_ENDPOINT"] == "https://checkout.example.test"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_unavailable_login_store_does_not_block_unauthenticated_source_import(
|
|
88
|
+
bootstrap, monkeypatch
|
|
89
|
+
):
|
|
90
|
+
def unavailable():
|
|
91
|
+
raise RuntimeError("store unavailable")
|
|
92
|
+
|
|
93
|
+
monkeypatch.setattr(config, "get_tokens", unavailable)
|
|
94
|
+
prime_runtime_env()
|
|
95
|
+
assert "MAINSEQUENCE_ACCESS_TOKEN" not in os.environ
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.fixture
|
|
99
|
+
def credential_source(bootstrap, monkeypatch):
|
|
100
|
+
from mainsequence import bootstrap as bootstrap_module
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr(bootstrap_module, "_credential_source", None)
|
|
103
|
+
for key in ("MAIN_SEQUENCE_USER_TOKEN", "MAIN_SEQUENCE_REFRESH_TOKEN"):
|
|
104
|
+
monkeypatch.delenv(key, raising=False)
|
|
105
|
+
return bootstrap_module.credential_source
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_bootstrap_records_credentials_taken_from_the_saved_session(credential_source):
|
|
109
|
+
prime_runtime_env()
|
|
110
|
+
assert credential_source() == "store"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_bootstrap_records_credentials_already_in_the_environment(credential_source, monkeypatch):
|
|
114
|
+
monkeypatch.setenv("MAINSEQUENCE_REFRESH_TOKEN", "explicit")
|
|
115
|
+
prime_runtime_env()
|
|
116
|
+
assert credential_source() == "environment"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_bootstrap_records_that_no_credentials_were_found(credential_source, monkeypatch):
|
|
120
|
+
monkeypatch.setattr(config, "get_tokens", lambda: {})
|
|
121
|
+
prime_runtime_env()
|
|
122
|
+
assert credential_source() is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _jwt(expires_at: int) -> str:
|
|
126
|
+
def part(data: dict) -> str:
|
|
127
|
+
return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=")
|
|
128
|
+
|
|
129
|
+
return f"{part({'alg': 'none'})}.{part({'exp': expires_at, 'token_type': 'refresh'})}.signature"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.fixture
|
|
133
|
+
def refused(monkeypatch, tmp_path):
|
|
134
|
+
"""A process whose environment pair the backend refused, on a machine with a saved session."""
|
|
135
|
+
from mainsequence import bootstrap as bootstrap_module
|
|
136
|
+
from mainsequence.client import utils
|
|
137
|
+
|
|
138
|
+
now = int(time.time())
|
|
139
|
+
expired_at, saved_until = now - 13 * 3600, now + 4 * 86400
|
|
140
|
+
refresh = _jwt(expired_at)
|
|
141
|
+
monkeypatch.chdir(tmp_path)
|
|
142
|
+
monkeypatch.setattr(utils, "AUTH_ENDPOINT", "http://127.0.0.1:8000")
|
|
143
|
+
monkeypatch.setattr(bootstrap_module, "_credential_source", "environment")
|
|
144
|
+
monkeypatch.setattr(
|
|
145
|
+
config, "get_persistent_config", lambda: {"backend_url": "http://127.0.0.1:8000"}
|
|
146
|
+
)
|
|
147
|
+
monkeypatch.setattr(
|
|
148
|
+
config,
|
|
149
|
+
"saved_session_summary",
|
|
150
|
+
lambda backend=None: {
|
|
151
|
+
"usable": backend == "http://127.0.0.1:8000",
|
|
152
|
+
"username": "dev@example.test",
|
|
153
|
+
"expires_at": saved_until,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
monkeypatch.setenv("MAINSEQUENCE_ACCESS_TOKEN", "copied-access")
|
|
157
|
+
monkeypatch.setenv("MAINSEQUENCE_REFRESH_TOKEN", refresh)
|
|
158
|
+
return SimpleNamespace(
|
|
159
|
+
utils=utils, directory=tmp_path, refresh=refresh, expired_at=expired_at, saved_until=saved_until
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_refused_pair_from_a_dotenv_names_the_file_the_expiry_and_the_repair(refused):
|
|
164
|
+
utils = refused.utils
|
|
165
|
+
env_file = refused.directory / ".env"
|
|
166
|
+
env_file.write_text(
|
|
167
|
+
"MAINSEQUENCE_ENDPOINT=http://127.0.0.1:8000\n"
|
|
168
|
+
f"export MAINSEQUENCE_REFRESH_TOKEN='{refused.refresh}' # exported by a tool\n"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
hint = utils._jwt_reauth_hint()
|
|
172
|
+
|
|
173
|
+
assert f" The refresh token expired on {utils._format_utc(refused.expired_at)}." in hint
|
|
174
|
+
assert f" These credentials come from the token lines of {env_file}, not from your saved session." in hint
|
|
175
|
+
assert (
|
|
176
|
+
" Your saved session for http://127.0.0.1:8000 (dev@example.test) is usable, valid until "
|
|
177
|
+
f"{utils._format_utc(refused.saved_until)}: run `mainsequence refresh-token` in "
|
|
178
|
+
f"{refused.directory} to remove those lines, and the next start uses it."
|
|
179
|
+
) in hint
|
|
180
|
+
assert refused.refresh not in hint
|
|
181
|
+
assert "copied-access" not in hint
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_refused_pair_set_by_the_starting_program_says_to_start_without_it(refused):
|
|
185
|
+
(refused.directory / ".env").write_text("MAINSEQUENCE_REFRESH_TOKEN=another-value\n")
|
|
186
|
+
|
|
187
|
+
hint = refused.utils._jwt_reauth_hint()
|
|
188
|
+
|
|
189
|
+
assert "set by the program that started it, not taken from your saved session." in hint
|
|
190
|
+
assert ": start the process without those two variables, and the next start uses it." in hint
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_refused_pair_without_a_usable_saved_session_says_to_sign_in_first(refused, monkeypatch):
|
|
194
|
+
monkeypatch.setattr(
|
|
195
|
+
config,
|
|
196
|
+
"saved_session_summary",
|
|
197
|
+
lambda backend=None: {"usable": False, "username": "", "expires_at": None},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
assert (
|
|
201
|
+
" There is no usable saved session for http://127.0.0.1:8000. Sign in with "
|
|
202
|
+
"`mainsequence login`, then start the process without those two variables."
|
|
203
|
+
) in refused.utils._jwt_reauth_hint()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_signing_in_to_another_backend_names_it_and_its_base_folder(refused, monkeypatch):
|
|
207
|
+
monkeypatch.setattr(
|
|
208
|
+
config, "get_persistent_config", lambda: {"backend_url": "https://api.main-sequence.app/"}
|
|
209
|
+
)
|
|
210
|
+
monkeypatch.setattr(
|
|
211
|
+
config,
|
|
212
|
+
"saved_session_summary",
|
|
213
|
+
lambda backend=None: {"usable": False, "username": "", "expires_at": None},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
assert (
|
|
217
|
+
"Sign in with `mainsequence login http://127.0.0.1:8000 <base folder>`"
|
|
218
|
+
in refused.utils._jwt_reauth_hint()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_refused_token_that_has_not_expired_is_called_revoked(refused, monkeypatch):
|
|
223
|
+
monkeypatch.setenv("MAINSEQUENCE_REFRESH_TOKEN", _jwt(int(time.time()) + 3600))
|
|
224
|
+
|
|
225
|
+
assert (
|
|
226
|
+
" The refresh token has not expired: the backend revoked it, or it was issued by "
|
|
227
|
+
"another backend."
|
|
228
|
+
) in refused.utils._jwt_reauth_hint()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_refused_saved_session_says_to_sign_in_again_and_does_not_read_the_store(refused, monkeypatch):
|
|
232
|
+
from mainsequence import bootstrap as bootstrap_module
|
|
233
|
+
|
|
234
|
+
monkeypatch.setattr(bootstrap_module, "_credential_source", "store")
|
|
235
|
+
monkeypatch.setattr(
|
|
236
|
+
config, "saved_session_summary", lambda backend=None: pytest.fail("The store was read")
|
|
237
|
+
)
|
|
238
|
+
monkeypatch.setattr(
|
|
239
|
+
config, "stored_session_available", lambda backend=None: pytest.fail("The store was read")
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
hint = refused.utils._jwt_reauth_hint()
|
|
243
|
+
|
|
244
|
+
assert hint.endswith(
|
|
245
|
+
" These credentials are your saved session for http://127.0.0.1:8000. "
|
|
246
|
+
"Sign in again with `mainsequence login`."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_renewal_refusal_and_backend_failure_read_differently(refused, monkeypatch):
|
|
251
|
+
utils = refused.utils
|
|
252
|
+
|
|
253
|
+
class Answer:
|
|
254
|
+
status_code = 401
|
|
255
|
+
|
|
256
|
+
def json(self):
|
|
257
|
+
return {}
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(utils.requests, "post", lambda *args, **kwargs: Answer())
|
|
260
|
+
provider = utils.JWTAuthProvider(
|
|
261
|
+
access_token="copied-access",
|
|
262
|
+
refresh_token=refused.refresh,
|
|
263
|
+
refresh_url="http://127.0.0.1:8000/auth/jwt-token/token/refresh/",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
with pytest.raises(utils.AuthError) as refusal:
|
|
267
|
+
provider.refresh(force=True)
|
|
268
|
+
assert str(refusal.value).startswith(
|
|
269
|
+
"Main Sequence refused to renew the session of this process for "
|
|
270
|
+
"http://127.0.0.1:8000 (HTTP 401). The refresh token expired on "
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
Answer.status_code = 503
|
|
274
|
+
with pytest.raises(utils.AuthError) as failure:
|
|
275
|
+
provider.refresh(force=True)
|
|
276
|
+
assert str(failure.value) == (
|
|
277
|
+
"Main Sequence could not renew the session of this process for "
|
|
278
|
+
"http://127.0.0.1:8000: the backend answered HTTP 503. Try again when it is available."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_request_reports_a_refused_renewal_with_its_reason(refused):
|
|
283
|
+
utils = refused.utils
|
|
284
|
+
|
|
285
|
+
class Answer:
|
|
286
|
+
status_code = 401
|
|
287
|
+
text = '{"detail": "Given token not valid for any token type"}'
|
|
288
|
+
|
|
289
|
+
class Session:
|
|
290
|
+
headers: dict = {}
|
|
291
|
+
|
|
292
|
+
def get(self, url, **kwargs):
|
|
293
|
+
return Answer()
|
|
294
|
+
|
|
295
|
+
class Loaders:
|
|
296
|
+
def refresh_headers(self, force=False, session=None):
|
|
297
|
+
if force:
|
|
298
|
+
raise utils.AuthError("Main Sequence refused to renew the session." + utils._jwt_reauth_hint())
|
|
299
|
+
return {"Authorization": "Bearer copied-access"}
|
|
300
|
+
|
|
301
|
+
response = utils.make_request(Session(), "GET", "http://127.0.0.1:8000/api/v1/users/me/", Loaders())
|
|
302
|
+
|
|
303
|
+
assert response.status_code == 401
|
|
304
|
+
assert response.code == "auth_error"
|
|
305
|
+
assert response.text.startswith("Main Sequence refused to renew the session.")
|
|
306
|
+
assert "start the process without those two variables" in response.text
|
|
307
|
+
assert "Given token not valid" not in response.text
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_package_import_bootstraps_before_client_endpoint_and_provider_initialization(tmp_path):
|
|
311
|
+
script = """
|
|
312
|
+
import sys
|
|
313
|
+
import types
|
|
314
|
+
config = types.ModuleType("mainsequence.cli.config")
|
|
315
|
+
config.backend_url = lambda: "https://saved.example.test"
|
|
316
|
+
config.get_tokens = lambda: {"access": "saved-access", "refresh": "saved-refresh"}
|
|
317
|
+
cli = types.ModuleType("mainsequence.cli")
|
|
318
|
+
cli.__path__ = []
|
|
319
|
+
cli.config = config
|
|
320
|
+
sys.modules[cli.__name__] = cli
|
|
321
|
+
sys.modules[config.__name__] = config
|
|
322
|
+
from mainsequence.client import utils
|
|
323
|
+
assert utils.MAINSEQUENCE_ENDPOINT == "https://saved.example.test"
|
|
324
|
+
assert utils.loaders.auth_headers["Authorization"] == "Bearer saved-access"
|
|
325
|
+
"""
|
|
326
|
+
environment = {
|
|
327
|
+
key: value
|
|
328
|
+
for key, value in os.environ.items()
|
|
329
|
+
if not key.startswith(("MAINSEQUENCE_", "MAIN_SEQUENCE_"))
|
|
330
|
+
}
|
|
331
|
+
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
|
|
332
|
+
result = subprocess.run(
|
|
333
|
+
[sys.executable, "-c", script],
|
|
334
|
+
cwd=tmp_path,
|
|
335
|
+
env=environment,
|
|
336
|
+
capture_output=True,
|
|
337
|
+
text=True,
|
|
338
|
+
)
|
|
339
|
+
assert result.returncode == 0, result.stderr
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
"""Authentication compatibility for normal CLI-backed SDK processes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
import pytest
|
|
11
|
-
|
|
12
|
-
from mainsequence.bootstrap import prime_runtime_env
|
|
13
|
-
from mainsequence.cli import config
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.fixture
|
|
17
|
-
def bootstrap(monkeypatch, tmp_path):
|
|
18
|
-
monkeypatch.chdir(tmp_path)
|
|
19
|
-
for key in ("MAINSEQUENCE_ENDPOINT", "MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN"):
|
|
20
|
-
monkeypatch.delenv(key, raising=False)
|
|
21
|
-
monkeypatch.setattr(config, "backend_url", lambda: "https://saved.example.test")
|
|
22
|
-
monkeypatch.setattr(
|
|
23
|
-
config, "get_tokens", lambda: {"access": "saved-access", "refresh": "saved-refresh"}
|
|
24
|
-
)
|
|
25
|
-
return tmp_path
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_saved_cli_session_is_available_to_sdk_process(bootstrap):
|
|
29
|
-
prime_runtime_env()
|
|
30
|
-
assert os.environ["MAINSEQUENCE_ENDPOINT"] == "https://saved.example.test"
|
|
31
|
-
assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "saved-access"
|
|
32
|
-
assert os.environ["MAINSEQUENCE_REFRESH_TOKEN"] == "saved-refresh"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_saved_session_brings_its_user_name_with_its_tokens(bootstrap, monkeypatch):
|
|
36
|
-
monkeypatch.setenv("MAINSEQUENCE_USERNAME", "")
|
|
37
|
-
monkeypatch.setattr(
|
|
38
|
-
config,
|
|
39
|
-
"get_tokens",
|
|
40
|
-
lambda: {
|
|
41
|
-
"username": "ada@example.com",
|
|
42
|
-
"access": "saved-access",
|
|
43
|
-
"refresh": "saved-refresh",
|
|
44
|
-
},
|
|
45
|
-
)
|
|
46
|
-
prime_runtime_env()
|
|
47
|
-
assert os.environ["MAINSEQUENCE_USERNAME"] == "ada@example.com"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_explicit_process_credentials_are_not_given_the_saved_user_name(bootstrap, monkeypatch):
|
|
51
|
-
monkeypatch.setenv("MAINSEQUENCE_USERNAME", "")
|
|
52
|
-
monkeypatch.setenv("MAINSEQUENCE_ACCESS_TOKEN", "explicit")
|
|
53
|
-
monkeypatch.setattr(
|
|
54
|
-
config,
|
|
55
|
-
"get_tokens",
|
|
56
|
-
lambda: {"username": "ada@example.com", "access": "explicit", "refresh": ""},
|
|
57
|
-
)
|
|
58
|
-
prime_runtime_env()
|
|
59
|
-
# Whose token the environment carries is not known.
|
|
60
|
-
assert os.environ["MAINSEQUENCE_USERNAME"] == ""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_bootstrap_preserves_explicit_process_credentials_and_endpoint(bootstrap, monkeypatch):
|
|
64
|
-
for key in ("MAINSEQUENCE_ENDPOINT", "MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN"):
|
|
65
|
-
monkeypatch.setenv(key, "explicit")
|
|
66
|
-
prime_runtime_env()
|
|
67
|
-
assert all(
|
|
68
|
-
os.environ[key] == "explicit"
|
|
69
|
-
for key in (
|
|
70
|
-
"MAINSEQUENCE_ENDPOINT",
|
|
71
|
-
"MAINSEQUENCE_ACCESS_TOKEN",
|
|
72
|
-
"MAINSEQUENCE_REFRESH_TOKEN",
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_checkout_endpoint_precedes_saved_backend(bootstrap):
|
|
78
|
-
(bootstrap / ".env").write_text("MAINSEQUENCE_ENDPOINT=https://checkout.example.test\n")
|
|
79
|
-
prime_runtime_env()
|
|
80
|
-
assert os.environ["MAINSEQUENCE_ENDPOINT"] == "https://checkout.example.test"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_unavailable_login_store_does_not_block_unauthenticated_source_import(
|
|
84
|
-
bootstrap, monkeypatch
|
|
85
|
-
):
|
|
86
|
-
def unavailable():
|
|
87
|
-
raise RuntimeError("store unavailable")
|
|
88
|
-
|
|
89
|
-
monkeypatch.setattr(config, "get_tokens", unavailable)
|
|
90
|
-
prime_runtime_env()
|
|
91
|
-
assert "MAINSEQUENCE_ACCESS_TOKEN" not in os.environ
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@pytest.fixture
|
|
95
|
-
def credential_source(bootstrap, monkeypatch):
|
|
96
|
-
from mainsequence import bootstrap as bootstrap_module
|
|
97
|
-
|
|
98
|
-
monkeypatch.setattr(bootstrap_module, "_credential_source", None)
|
|
99
|
-
for key in ("MAIN_SEQUENCE_USER_TOKEN", "MAIN_SEQUENCE_REFRESH_TOKEN"):
|
|
100
|
-
monkeypatch.delenv(key, raising=False)
|
|
101
|
-
return bootstrap_module.credential_source
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_bootstrap_records_credentials_taken_from_the_saved_session(credential_source):
|
|
105
|
-
prime_runtime_env()
|
|
106
|
-
assert credential_source() == "store"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def test_bootstrap_records_credentials_already_in_the_environment(credential_source, monkeypatch):
|
|
110
|
-
monkeypatch.setenv("MAINSEQUENCE_REFRESH_TOKEN", "explicit")
|
|
111
|
-
prime_runtime_env()
|
|
112
|
-
assert credential_source() == "environment"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_bootstrap_records_that_no_credentials_were_found(credential_source, monkeypatch):
|
|
116
|
-
monkeypatch.setattr(config, "get_tokens", lambda: {})
|
|
117
|
-
prime_runtime_env()
|
|
118
|
-
assert credential_source() is None
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_rejected_environment_pair_is_named_instead_of_the_saved_session(monkeypatch):
|
|
122
|
-
from mainsequence import bootstrap as bootstrap_module
|
|
123
|
-
from mainsequence.client import utils
|
|
124
|
-
|
|
125
|
-
monkeypatch.setattr(bootstrap_module, "_credential_source", "environment")
|
|
126
|
-
monkeypatch.setattr(config, "stored_session_available", lambda backend=None: True)
|
|
127
|
-
hint = utils._jwt_reauth_hint()
|
|
128
|
-
assert "MAINSEQUENCE_ACCESS_TOKEN / MAINSEQUENCE_REFRESH_TOKEN" in hint
|
|
129
|
-
assert "A saved CLI session exists for this backend and was not used." in hint
|
|
130
|
-
|
|
131
|
-
monkeypatch.setattr(config, "stored_session_available", lambda backend=None: False)
|
|
132
|
-
hint = utils._jwt_reauth_hint()
|
|
133
|
-
assert "No saved CLI session exists for this backend." in hint
|
|
134
|
-
assert "mainsequence login" in hint
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def test_rejected_saved_session_keeps_the_login_hint_and_does_not_read_the_store(monkeypatch):
|
|
138
|
-
from mainsequence import bootstrap as bootstrap_module
|
|
139
|
-
from mainsequence.client import utils
|
|
140
|
-
|
|
141
|
-
store_reads = []
|
|
142
|
-
monkeypatch.setattr(bootstrap_module, "_credential_source", "store")
|
|
143
|
-
monkeypatch.setattr(
|
|
144
|
-
config, "stored_session_available", lambda backend=None: store_reads.append(1) or True
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
hint = utils._jwt_reauth_hint()
|
|
148
|
-
|
|
149
|
-
assert "`mainsequence logout` and `mainsequence login`" in hint
|
|
150
|
-
assert store_reads == []
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def test_package_import_bootstraps_before_client_endpoint_and_provider_initialization(tmp_path):
|
|
154
|
-
script = """
|
|
155
|
-
import sys
|
|
156
|
-
import types
|
|
157
|
-
config = types.ModuleType("mainsequence.cli.config")
|
|
158
|
-
config.backend_url = lambda: "https://saved.example.test"
|
|
159
|
-
config.get_tokens = lambda: {"access": "saved-access", "refresh": "saved-refresh"}
|
|
160
|
-
cli = types.ModuleType("mainsequence.cli")
|
|
161
|
-
cli.__path__ = []
|
|
162
|
-
cli.config = config
|
|
163
|
-
sys.modules[cli.__name__] = cli
|
|
164
|
-
sys.modules[config.__name__] = config
|
|
165
|
-
from mainsequence.client import utils
|
|
166
|
-
assert utils.MAINSEQUENCE_ENDPOINT == "https://saved.example.test"
|
|
167
|
-
assert utils.loaders.auth_headers["Authorization"] == "Bearer saved-access"
|
|
168
|
-
"""
|
|
169
|
-
environment = {
|
|
170
|
-
key: value
|
|
171
|
-
for key, value in os.environ.items()
|
|
172
|
-
if not key.startswith(("MAINSEQUENCE_", "MAIN_SEQUENCE_"))
|
|
173
|
-
}
|
|
174
|
-
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
|
|
175
|
-
result = subprocess.run(
|
|
176
|
-
[sys.executable, "-c", script],
|
|
177
|
-
cwd=tmp_path,
|
|
178
|
-
env=environment,
|
|
179
|
-
capture_output=True,
|
|
180
|
-
text=True,
|
|
181
|
-
)
|
|
182
|
-
assert result.returncode == 0, result.stderr
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/cli/code_repository_status.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
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/agent_runtime_models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/client/compute_validation.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
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/code_repository_context.py
RENAMED
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/code_repository_skills.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/instrumentation/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/repository_identity_security.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence/server/caller_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/mainsequence.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_canonical_api_route_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_branch_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_code_repository_runtime_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_environment_resource_context.py
RENAMED
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_environment_response_contracts.py
RENAMED
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_fastapi_request_identity.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
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_models_user_request_bound_auth.py
RENAMED
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_notification_uid_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.1.dev30 → mainsequence-9.0.1.dev31}/tests/test_tolerant_response_reading.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|