mainsequence 9.0.8.dev44__tar.gz → 9.0.9__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 (104) hide show
  1. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/PKG-INFO +1 -1
  2. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/browser_auth.py +3 -2
  3. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/config.py +3 -7
  4. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/agent_runtime_models.py +3 -1
  5. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_foundry.py +5 -1
  6. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_helpers.py +6 -2
  7. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/utils.py +69 -24
  8. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/code_repository_context.py +3 -3
  9. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/logconf.py +29 -10
  10. mainsequence-9.0.9/mainsequence/runtime_credential_exchange.py +168 -0
  11. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/PKG-INFO +1 -1
  12. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/SOURCES.txt +3 -0
  13. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/pyproject.toml +1 -1
  14. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_auth_precedence.py +1 -0
  15. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_secure_storage.py +17 -0
  16. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_runtime_context.py +13 -2
  17. mainsequence-9.0.9/tests/test_credential_repr.py +172 -0
  18. mainsequence-9.0.9/tests/test_runtime_credential_exchange.py +383 -0
  19. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/LICENSE +0 -0
  20. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/README.md +0 -0
  21. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/AGENTS.md +0 -0
  22. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  23. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  24. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  25. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  26. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  27. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  28. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  29. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  30. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  31. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/__init__.py +0 -0
  32. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/__main__.py +0 -0
  33. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/_request_identity.py +0 -0
  34. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/bootstrap.py +0 -0
  35. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/__init__.py +0 -0
  36. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/api.py +0 -0
  37. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/cli.py +0 -0
  38. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/code_repository_status.py +0 -0
  39. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/docker_utils.py +0 -0
  40. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/doctor.py +0 -0
  41. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/local_ops.py +0 -0
  42. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/model_filters.py +0 -0
  43. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/pydantic_cli.py +0 -0
  44. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/sdk_utils.py +0 -0
  45. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/ssh_utils.py +0 -0
  46. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/ui.py +0 -0
  47. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/__init__.py +0 -0
  48. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/_transport.py +0 -0
  49. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/base.py +0 -0
  50. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/client.py +0 -0
  51. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/compute_validation.py +0 -0
  52. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/exceptions.py +0 -0
  53. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/github_issues.py +0 -0
  54. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/inference.py +0 -0
  55. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_user.py +0 -0
  56. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/observability.py +0 -0
  57. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/value_sets.py +0 -0
  58. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/code_repository_skills.py +0 -0
  59. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/defaults.py +0 -0
  60. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/instrumentation/__init__.py +0 -0
  61. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/instrumentation/utils.py +0 -0
  62. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/repository_identity_security.py +0 -0
  63. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/runtime_flags.py +0 -0
  64. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/scaffold_skills.py +0 -0
  65. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/__init__.py +0 -0
  66. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/caller_assertions.py +0 -0
  67. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/fastapi.py +0 -0
  68. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/dependency_links.txt +0 -0
  69. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/entry_points.txt +0 -0
  70. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/requires.txt +0 -0
  71. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/top_level.txt +0 -0
  72. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/setup.cfg +0 -0
  73. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_auth_bootstrap.py +0 -0
  74. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_caller_assertions.py +0 -0
  75. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_canonical_api_route_contract.py +0 -0
  76. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_auth_contract.py +0 -0
  77. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_browser_auth.py +0 -0
  78. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_client.py +0 -0
  79. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_branch_contract.py +0 -0
  80. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_documentation_contract.py +0 -0
  81. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_skills.py +0 -0
  82. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_dependency_extras.py +0 -0
  83. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_dev_release_version.py +0 -0
  84. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_environment_resource_context.py +0 -0
  85. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_environment_response_contracts.py +0 -0
  86. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_fastapi_request_identity.py +0 -0
  87. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_github_issues_client.py +0 -0
  88. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_http_retry_budget.py +0 -0
  89. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_independent_context.py +0 -0
  90. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_inference_client.py +0 -0
  91. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_instrumentation.py +0 -0
  92. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_job_client.py +0 -0
  93. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_local_ops.py +0 -0
  94. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_logconf.py +0 -0
  95. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_models_user_request_bound_auth.py +0 -0
  96. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_notification_uid_contract.py +0 -0
  97. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_observability_client.py +0 -0
  98. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_scaffold_skills.py +0 -0
  99. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_secret_client_model.py +0 -0
  100. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_source_ownership.py +0 -0
  101. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_ssh_utils.py +0 -0
  102. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_tolerant_response_reading.py +0 -0
  103. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_tolerant_value_sets.py +0 -0
  104. {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.8.dev44
3
+ Version: 9.0.9
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -15,7 +15,7 @@ import threading
15
15
  import time
16
16
  import webbrowser
17
17
  from collections.abc import Callable
18
- from dataclasses import dataclass
18
+ from dataclasses import dataclass, field
19
19
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
20
  from urllib.parse import parse_qs, urlparse
21
21
 
@@ -37,7 +37,8 @@ class BrowserAuthError(RuntimeError):
37
37
  class BrowserAuthCallback:
38
38
  """Authorization callback payload captured from the local loopback receiver."""
39
39
 
40
- code: str | None = None
40
+ # The authorization code is a bearer credential: it stays out of repr() and str().
41
+ code: str | None = field(default=None, repr=False)
41
42
  state: str | None = None
42
43
  error: str | None = None
43
44
  error_description: str | None = None
@@ -29,6 +29,7 @@ from keyring import core as keyring_core
29
29
  from keyring import errors as keyring_errors
30
30
 
31
31
  from mainsequence.defaults import CANONICAL_BACKEND_ENV, STANDARD_BACKEND_URL
32
+ from mainsequence.runtime_credential_exchange import runtime_credential_configured
32
33
 
33
34
  APP_NAME = "MainSequenceCLI"
34
35
 
@@ -804,13 +805,8 @@ def session_report() -> dict:
804
805
  refresh_expires_at = token_expiry(refresh)
805
806
 
806
807
  if auth_mode == "runtime_credential":
807
- authenticated = bool(
808
- access
809
- or (
810
- (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
811
- and (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
812
- )
813
- )
808
+ # The credential is an ID with a bootstrap secret or an identity token file.
809
+ authenticated = bool(access or runtime_credential_configured())
814
810
  session_expires_at = None
815
811
  elif refresh:
816
812
  authenticated = refresh_expires_at is None or refresh_expires_at > now
@@ -353,7 +353,8 @@ class AgentRuntimePresence(BasePydanticModel):
353
353
 
354
354
 
355
355
  class AgentSessionRuntimeAccess(BasePydanticModel):
356
- model_config = ConfigDict(extra="allow")
356
+ # A validation error must not echo the input: it holds the bearer token.
357
+ model_config = ConfigDict(extra="allow", hide_input_in_errors=True)
357
358
 
358
359
  coding_agent_service_uid: str | None = Field(
359
360
  None,
@@ -373,6 +374,7 @@ class AgentSessionRuntimeAccess(BasePydanticModel):
373
374
  token: str | None = Field(
374
375
  None,
375
376
  description="Bearer token that authorizes calls to the coding-agent gateway.",
377
+ repr=False,
376
378
  )
377
379
  expires_at: str | None = Field(
378
380
  None,
@@ -7,7 +7,7 @@ from uuid import UUID
7
7
 
8
8
  import yaml
9
9
  from cachetools import TTLCache, cachedmethod
10
- from pydantic import Field, SecretStr
10
+ from pydantic import ConfigDict, Field, SecretStr
11
11
 
12
12
  from .base import (
13
13
  BaseObjectOrm,
@@ -891,6 +891,10 @@ class Secret(
891
891
  BasePydanticModel,
892
892
  BaseObjectOrm,
893
893
  ):
894
+ # `value` is a SecretStr once read, but a validation error would echo the
895
+ # plaintext it was given.
896
+ model_config = ConfigDict(hide_input_in_errors=True)
897
+
894
898
  HYDRATE_FILTER_GET_FROM_DETAIL: ClassVar[bool] = True
895
899
  FILTERSET_FIELDS: ClassVar[dict[str, list[str]]] = {
896
900
  "uid": ["in", "exact"],
@@ -6,7 +6,7 @@ from decimal import Decimal
6
6
  from typing import Any, ClassVar, Literal
7
7
  from uuid import UUID
8
8
 
9
- from pydantic import BaseModel, Field, PositiveInt
9
+ from pydantic import BaseModel, ConfigDict, Field, PositiveInt
10
10
 
11
11
  from .base import (
12
12
  BaseObjectOrm,
@@ -798,6 +798,10 @@ class ResourceReleaseRuntimePresence(BasePydanticModel):
798
798
 
799
799
 
800
800
  class ResourceReleaseRuntimeAccess(BasePydanticModel):
801
+ # `access` holds the runtime's bearer token, so neither repr() nor a
802
+ # validation error shows it.
803
+ model_config = ConfigDict(hide_input_in_errors=True)
804
+
801
805
  resource_release_uid: str
802
806
  # The canonical vocabulary ResourceRelease.release_kind reads (#119): this
803
807
  # payload names a release kind, so it names it the same way.
@@ -805,7 +809,7 @@ class ResourceReleaseRuntimeAccess(BasePydanticModel):
805
809
  routing: ResourceReleaseRuntimeRouting
806
810
  runtime_access: ResourceReleaseRuntimeAdmission
807
811
  runtime_presence: ResourceReleaseRuntimePresence
808
- access: dict[str, Any] | None = None
812
+ access: dict[str, Any] | None = Field(default=None, repr=False)
809
813
 
810
814
 
811
815
  class ResourceRelease(
@@ -21,6 +21,14 @@ from requests.structures import CaseInsensitiveDict
21
21
 
22
22
  from mainsequence.defaults import resolve_backend_endpoint
23
23
  from mainsequence.logconf import logger
24
+ from mainsequence.runtime_credential_exchange import (
25
+ RUNTIME_CREDENTIAL_ID_ENV,
26
+ RUNTIME_CREDENTIAL_SECRET_ENV,
27
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV,
28
+ RuntimeIdentityTokenError,
29
+ exchange_runtime_credential,
30
+ identity_token_file_from_environment,
31
+ )
24
32
 
25
33
  from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
26
34
  from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
@@ -270,8 +278,9 @@ class BaseAuthProvider:
270
278
 
271
279
  @dataclass
272
280
  class SessionJWTAuthProvider(BaseAuthProvider):
273
- access_token: str | None = None
274
- refresh_token: str | None = None
281
+ # Tokens stay out of repr() and str(), so logging the provider never shows them.
282
+ access_token: str | None = field(default=None, repr=False)
283
+ refresh_token: str | None = field(default=None, repr=False)
275
284
  header_keyword: str = "Bearer"
276
285
 
277
286
  def __post_init__(self):
@@ -309,20 +318,37 @@ class SessionJWTAuthProvider(BaseAuthProvider):
309
318
 
310
319
  @dataclass
311
320
  class RuntimeCredentialAuthProvider(BaseAuthProvider):
321
+ """
322
+ Exchange the runtime credential the platform injected for short-lived access tokens.
323
+
324
+ The proof is the projected workload identity token when
325
+ `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` is set: the file is read for every
326
+ exchange, and `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET` is never read or sent.
327
+ Otherwise the proof is that bootstrap secret. The token is never kept on the
328
+ provider, and the secret is left out of `repr()` and `str()`. Throttled
329
+ (`429`) and temporarily unavailable (`503`) exchanges are retried within the
330
+ timeout budget; a rejected one (`401`) fails at once.
331
+ """
332
+
312
333
  credential_id: str | None = None
313
- credential_secret: str | None = None
334
+ credential_secret: str | None = field(default=None, repr=False)
314
335
  token_url: str | None = None
315
336
  token_type: str = "Bearer"
316
337
  refresh_skew_seconds: int = 30
317
338
  timeout: tuple[float, float] = DEFAULT_TIMEOUT
318
339
  expires_at: float | None = None
340
+ identity_token_file: str | None = None
319
341
  _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
320
342
 
321
343
  def __post_init__(self):
322
344
  if self.credential_id is None:
323
- self.credential_id = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID")
324
- if self.credential_secret is None:
325
- self.credential_secret = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET")
345
+ self.credential_id = os.getenv(RUNTIME_CREDENTIAL_ID_ENV)
346
+ if self.identity_token_file is None:
347
+ self.identity_token_file = identity_token_file_from_environment()
348
+ else:
349
+ self.identity_token_file = str(self.identity_token_file).strip() or None
350
+ if self.credential_secret is None and not self.identity_token_file:
351
+ self.credential_secret = os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV)
326
352
  if not self.token_url:
327
353
  self.token_url = f"{API_ENDPOINT}/runtime-credentials/token/"
328
354
 
@@ -344,20 +370,48 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
344
370
 
345
371
  return exp <= int(time.time()) + self.refresh_skew_seconds
346
372
 
347
- def _require_credentials(self) -> tuple[str, str]:
373
+ @staticmethod
374
+ def _log_exchange_retry(status: int, delay: float) -> None:
375
+ logger.warning(f"Runtime credential exchange returned {status}; retrying in {delay:.1f} s.")
376
+
377
+ def _exchange(self) -> requests.Response:
348
378
  credential_id = (self.credential_id or "").strip()
349
- credential_secret = (self.credential_secret or "").strip()
379
+ identity_token_file = self.identity_token_file
380
+ credential_secret = None if identity_token_file else (self.credential_secret or "").strip()
350
381
  if not credential_id:
351
382
  raise AuthError(
352
383
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID is required when "
353
384
  "MAINSEQUENCE_AUTH_MODE=runtime_credential."
354
385
  )
355
- if not credential_secret:
386
+ if not identity_token_file and not credential_secret:
356
387
  raise AuthError(
357
388
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET is required when "
358
- "MAINSEQUENCE_AUTH_MODE=runtime_credential."
389
+ "MAINSEQUENCE_AUTH_MODE=runtime_credential and "
390
+ f"{RUNTIME_IDENTITY_TOKEN_FILE_ENV} is not set."
359
391
  )
360
- return credential_id, credential_secret
392
+ try:
393
+ # One budget for every attempt and wait, inside the caller's budget if any.
394
+ with request_budget(self.timeout) as budget:
395
+
396
+ def post(body: dict[str, str]) -> requests.Response:
397
+ return requests.post(
398
+ self.token_url,
399
+ json=body,
400
+ headers={"Content-Type": "application/json"},
401
+ timeout=budget.timeout(),
402
+ allow_redirects=False,
403
+ )
404
+
405
+ return exchange_runtime_credential(
406
+ post,
407
+ credential_id=credential_id,
408
+ credential_secret=credential_secret,
409
+ identity_token_file=identity_token_file,
410
+ deadline=budget.deadline,
411
+ on_retry=self._log_exchange_retry,
412
+ )
413
+ except RuntimeIdentityTokenError as exc:
414
+ raise AuthError(str(exc)) from None
361
415
 
362
416
  def refresh(
363
417
  self,
@@ -370,17 +424,7 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
370
424
  if not force and not self._needs_exchange():
371
425
  return
372
426
 
373
- credential_id, credential_secret = self._require_credentials()
374
- response = requests.post(
375
- self.token_url,
376
- json={
377
- "credential_id": credential_id,
378
- "credential_secret": credential_secret,
379
- },
380
- headers={"Content-Type": "application/json"},
381
- timeout=remaining_timeout(self.timeout),
382
- allow_redirects=False,
383
- )
427
+ response = self._exchange()
384
428
  if response.status_code < 200 or response.status_code >= 300:
385
429
  raise AuthError(
386
430
  f"Runtime credential exchange failed with status {response.status_code}."
@@ -439,8 +483,9 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
439
483
 
440
484
  @dataclass
441
485
  class JWTAuthProvider(BaseAuthProvider):
442
- access_token: str | None = None
443
- refresh_token: str | None = None
486
+ # Tokens stay out of repr() and str(), so logging the provider never shows them.
487
+ access_token: str | None = field(default=None, repr=False)
488
+ refresh_token: str | None = field(default=None, repr=False)
444
489
  refresh_url: str | None = None
445
490
  obtain_url: str | None = None
446
491
  header_keyword: str = "Bearer"
@@ -10,6 +10,8 @@ from dataclasses import dataclass, replace
10
10
  from typing import Any, Literal
11
11
  from urllib.parse import urlsplit
12
12
 
13
+ from mainsequence.runtime_credential_exchange import runtime_credential_configured
14
+
13
15
  CodeRepositoryContextStatus = Literal[
14
16
  "resolved",
15
17
  "code_repository_branch_not_registered",
@@ -204,9 +206,7 @@ def _exchange_authenticated_runtime_context_if_configured() -> None:
204
206
  return
205
207
  if (os.getenv("MAINSEQUENCE_AUTH_MODE") or "").strip().lower() != "runtime_credential":
206
208
  return
207
- if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip():
208
- return
209
- if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip():
209
+ if not runtime_credential_configured():
210
210
  return
211
211
 
212
212
  from mainsequence.client.utils import AuthError, loaders
@@ -5,6 +5,7 @@ import logging
5
5
  import logging.config
6
6
  import os
7
7
  import sys
8
+ import time
8
9
  import traceback
9
10
  from collections.abc import Mapping
10
11
  from importlib.metadata import PackageNotFoundError
@@ -22,6 +23,13 @@ from structlog.stdlib import BoundLogger
22
23
  from .defaults import resolve_backend_endpoint
23
24
  from .instrumentation import OTelJSONRenderer
24
25
  from .repository_identity_security import UNSUPPORTED_SOURCE_IDENTITY_ENV_NAMES
26
+ from .runtime_credential_exchange import (
27
+ RUNTIME_CREDENTIAL_ID_ENV,
28
+ RUNTIME_CREDENTIAL_SECRET_ENV,
29
+ exchange_runtime_credential,
30
+ identity_token_file_from_environment,
31
+ runtime_credential_configured,
32
+ )
25
33
  from .runtime_flags import is_running_in_pod
26
34
 
27
35
  logger = None
@@ -94,20 +102,31 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
94
102
  return headers, False
95
103
 
96
104
  def _exchange_runtime_credential() -> bool:
97
- credential_id = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
98
- credential_secret = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
99
- if not credential_id or not credential_secret:
105
+ if not runtime_credential_configured():
100
106
  return False
107
+ identity_token_file = identity_token_file_from_environment()
108
+ token_url = f"{_backend_base_url()}/api/v1/runtime-credentials/token/"
101
109
 
102
- try:
103
- token_resp = requests.post(
104
- f"{_backend_base_url()}/api/v1/runtime-credentials/token/",
110
+ def _post(body: dict[str, str]) -> requests.Response:
111
+ return requests.post(
112
+ token_url,
105
113
  headers={"Content-Type": "application/json"},
106
- json={
107
- "credential_id": credential_id,
108
- "credential_secret": credential_secret,
109
- },
114
+ json=body,
110
115
  timeout=timeout_s,
116
+ allow_redirects=False,
117
+ )
118
+
119
+ try:
120
+ token_resp = exchange_runtime_credential(
121
+ _post,
122
+ credential_id=(os.getenv(RUNTIME_CREDENTIAL_ID_ENV) or "").strip(),
123
+ credential_secret=(
124
+ None
125
+ if identity_token_file
126
+ else (os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV) or "").strip()
127
+ ),
128
+ identity_token_file=identity_token_file,
129
+ deadline=time.monotonic() + timeout_s,
111
130
  )
112
131
  except Exception:
113
132
  return False
@@ -0,0 +1,168 @@
1
+ """
2
+ The runtime credential exchange, shared by the auth provider and the logger.
3
+
4
+ A runtime started with ``MAINSEQUENCE_AUTH_MODE=runtime_credential`` exchanges its
5
+ credential at ``POST /api/v1/runtime-credentials/token/`` for a short-lived access
6
+ token. The request carries ``credential_id`` and exactly one proof:
7
+
8
+ * ``workload_identity_token`` when ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE`` is
9
+ set: the projected token read from that file for every exchange, because the
10
+ file is rotated. The bootstrap secret is then never read or sent, and a missing,
11
+ unreadable or empty file is an error, never a fallback to the secret.
12
+ * ``credential_secret`` otherwise: ``MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET``.
13
+
14
+ The token exists only inside one exchange request. It is not kept on any object,
15
+ written to the environment or a file, logged, or included in an error message.
16
+
17
+ The logger exchanges a runtime credential while it is being configured, so this
18
+ module imports nothing from the SDK.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import time
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ from urllib3.exceptions import InvalidHeader
29
+ from urllib3.util import Retry
30
+
31
+ RUNTIME_CREDENTIAL_ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
32
+ RUNTIME_CREDENTIAL_SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
33
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
34
+
35
+ # 429: the exchange is throttled. 503: verification is temporarily unavailable.
36
+ # Neither exchanged anything, so the same request may be sent again.
37
+ RETRYABLE_EXCHANGE_STATUSES = frozenset({429, 503})
38
+ EXCHANGE_RETRIES = 3
39
+ EXCHANGE_BACKOFF_FACTOR = 0.5
40
+
41
+ _RETRY_AFTER = Retry(total=0)
42
+
43
+
44
+ class RuntimeIdentityTokenError(Exception):
45
+ """The runtime identity token file cannot be used. The message never holds its content."""
46
+
47
+
48
+ def _environment_value(name: str) -> str:
49
+ return (os.getenv(name) or "").strip()
50
+
51
+
52
+ def identity_token_file_from_environment() -> str | None:
53
+ """Return the path in ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE``, or None when unset."""
54
+ return _environment_value(RUNTIME_IDENTITY_TOKEN_FILE_ENV) or None
55
+
56
+
57
+ def runtime_credential_configured() -> bool:
58
+ """
59
+ Say whether the environment holds a runtime credential to exchange.
60
+
61
+ That is a credential ID and one proof: the identity token file, or else the
62
+ bootstrap secret. Only the presence of the setting is checked. The token file
63
+ is read by the exchange itself, which reports a missing or empty file.
64
+ """
65
+ if not _environment_value(RUNTIME_CREDENTIAL_ID_ENV):
66
+ return False
67
+ if identity_token_file_from_environment():
68
+ return True
69
+ return bool(_environment_value(RUNTIME_CREDENTIAL_SECRET_ENV))
70
+
71
+
72
+ def _read_identity_token(path: str) -> str:
73
+ source = f"The runtime identity token file {path} ({RUNTIME_IDENTITY_TOKEN_FILE_ENV})"
74
+ try:
75
+ with open(path, "rb") as token_file:
76
+ raw = token_file.read()
77
+ except FileNotFoundError:
78
+ raise RuntimeIdentityTokenError(f"{source} does not exist.") from None
79
+ except OSError as exc:
80
+ raise RuntimeIdentityTokenError(
81
+ f"{source} could not be read ({type(exc).__name__})."
82
+ ) from None
83
+ try:
84
+ token = raw.decode("utf-8").strip()
85
+ except UnicodeDecodeError:
86
+ # Raised below, outside this handler: the decode error holds the bytes.
87
+ token = None
88
+ if token is None:
89
+ raise RuntimeIdentityTokenError(f"{source} does not hold a token.")
90
+ if not token:
91
+ raise RuntimeIdentityTokenError(f"{source} is empty.")
92
+ return token
93
+
94
+
95
+ def _request_body(
96
+ credential_id: str,
97
+ credential_secret: str | None,
98
+ identity_token_file: str | None,
99
+ ) -> dict[str, str]:
100
+ if identity_token_file:
101
+ return {
102
+ "credential_id": credential_id,
103
+ "workload_identity_token": _read_identity_token(identity_token_file),
104
+ }
105
+ return {"credential_id": credential_id, "credential_secret": credential_secret or ""}
106
+
107
+
108
+ def _retry_delay(response: Any, attempt: int) -> float:
109
+ delay = min(EXCHANGE_BACKOFF_FACTOR * 2**attempt, Retry.DEFAULT_BACKOFF_MAX)
110
+ retry_after = (getattr(response, "headers", None) or {}).get("Retry-After")
111
+ if retry_after is not None:
112
+ try:
113
+ delay = max(delay, _RETRY_AFTER.parse_retry_after(str(retry_after)))
114
+ except InvalidHeader:
115
+ pass
116
+ return delay
117
+
118
+
119
+ def exchange_runtime_credential(
120
+ send: Callable[[dict[str, str]], Any],
121
+ *,
122
+ credential_id: str,
123
+ credential_secret: str | None,
124
+ identity_token_file: str | None,
125
+ deadline: float,
126
+ on_retry: Callable[[int, float], None] | None = None,
127
+ ) -> Any:
128
+ """
129
+ Send one runtime credential exchange and return the last response.
130
+
131
+ Parameters
132
+ ----------
133
+ send:
134
+ Posts the JSON body it is given to the exchange route and returns the
135
+ response. It is called once per attempt.
136
+ credential_id:
137
+ The runtime credential ID.
138
+ credential_secret:
139
+ The bootstrap secret. Ignored when ``identity_token_file`` is set.
140
+ identity_token_file:
141
+ The projected token file. It is read again for every attempt.
142
+ deadline:
143
+ ``time.monotonic()`` value after which no retry starts.
144
+ on_retry:
145
+ Called with the status and the delay before each retry.
146
+
147
+ A ``429`` or ``503`` answer is retried up to ``EXCHANGE_RETRIES`` times. The
148
+ wait is the exponential backoff or the answer's ``Retry-After``, whichever is
149
+ longer; a wait that would not end before ``deadline`` ends the retries. Any
150
+ other answer, ``401`` included, is returned at once and the proof is never
151
+ switched. ``RuntimeIdentityTokenError`` is raised when the token file cannot
152
+ be used.
153
+ """
154
+ for attempt in range(EXCHANGE_RETRIES + 1):
155
+ response = send(_request_body(credential_id, credential_secret, identity_token_file))
156
+ status = response.status_code
157
+ if status not in RETRYABLE_EXCHANGE_STATUSES or attempt == EXCHANGE_RETRIES:
158
+ return response
159
+ delay = _retry_delay(response, attempt)
160
+ if delay >= deadline - time.monotonic():
161
+ return response
162
+ if on_retry is not None:
163
+ on_retry(status, delay)
164
+ close = getattr(response, "close", None)
165
+ if callable(close):
166
+ close()
167
+ time.sleep(delay)
168
+ raise AssertionError("Unreachable retry state")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.8.dev44
3
+ Version: 9.0.9
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -20,6 +20,7 @@ mainsequence/code_repository_skills.py
20
20
  mainsequence/defaults.py
21
21
  mainsequence/logconf.py
22
22
  mainsequence/repository_identity_security.py
23
+ mainsequence/runtime_credential_exchange.py
23
24
  mainsequence/runtime_flags.py
24
25
  mainsequence/scaffold_skills.py
25
26
  mainsequence.egg-info/PKG-INFO
@@ -74,6 +75,7 @@ tests/test_code_repository_branch_contract.py
74
75
  tests/test_code_repository_documentation_contract.py
75
76
  tests/test_code_repository_runtime_context.py
76
77
  tests/test_code_repository_skills.py
78
+ tests/test_credential_repr.py
77
79
  tests/test_dependency_extras.py
78
80
  tests/test_dev_release_version.py
79
81
  tests/test_environment_resource_context.py
@@ -90,6 +92,7 @@ tests/test_logconf.py
90
92
  tests/test_models_user_request_bound_auth.py
91
93
  tests/test_notification_uid_contract.py
92
94
  tests/test_observability_client.py
95
+ tests/test_runtime_credential_exchange.py
93
96
  tests/test_scaffold_skills.py
94
97
  tests/test_secret_client_model.py
95
98
  tests/test_source_ownership.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.8.dev44"
7
+ version = "9.0.9"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -150,6 +150,7 @@ def test_logconf_runtime_credential_exchanges_before_startup_state_request(monke
150
150
  "credential_secret": "cred-secret",
151
151
  },
152
152
  "timeout": 10.0,
153
+ "allow_redirects": False,
153
154
  }
154
155
  ]
155
156
  assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "runtime-access"
@@ -1021,3 +1021,20 @@ def test_session_report_for_a_runtime_credential_has_no_session_expiry(monkeypat
1021
1021
  assert report["session_expires_at"] is None
1022
1022
  assert report["auth_mode"] == "runtime_credential"
1023
1023
  assert "cred-secret" not in json.dumps(report)
1024
+
1025
+
1026
+ def test_session_report_accepts_a_runtime_identity_token_file(monkeypatch, tmp_path):
1027
+ _isolate_auth(monkeypatch, tmp_path, None)
1028
+ monkeypatch.setattr(config, "auth_persistence_label", lambda: "test store")
1029
+ token_file = tmp_path / "token"
1030
+ token_file.write_text("projected-token", encoding="utf-8")
1031
+ monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
1032
+ monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "cred-id")
1033
+ monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
1034
+ monkeypatch.setenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", str(token_file))
1035
+
1036
+ report = config.session_report()
1037
+
1038
+ assert report["authenticated"] is True
1039
+ assert report["session_expires_at"] is None
1040
+ assert "projected-token" not in json.dumps(report)
@@ -368,10 +368,21 @@ def test_authenticated_runtime_context_detects_git_drift(monkeypatch):
368
368
  code_repository_context.validate_code_repository_source_context(context=context)
369
369
 
370
370
 
371
- def test_configured_runtime_credential_exchanges_context_before_resolution(monkeypatch):
371
+ @pytest.mark.parametrize(
372
+ ("proof_variable", "proof_value"),
373
+ [
374
+ ("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", "credential-secret"),
375
+ ("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", "/var/run/secrets/runtime-identity/token"),
376
+ ],
377
+ )
378
+ def test_configured_runtime_credential_exchanges_context_before_resolution(
379
+ monkeypatch, proof_variable, proof_value
380
+ ):
372
381
  monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
373
382
  monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "credential-id")
374
- monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", "credential-secret")
383
+ monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
384
+ monkeypatch.delenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", raising=False)
385
+ monkeypatch.setenv(proof_variable, proof_value)
375
386
  events = []
376
387
  monkeypatch.setattr(
377
388
  code_repository_context,