ms-tau-sdk 1.3.0.dev27__tar.gz → 1.4.0.dev28__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 (80) hide show
  1. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/CHANGELOG.md +31 -0
  2. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/PKG-INFO +6 -1
  3. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/README.md +5 -0
  4. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/pyproject.toml +1 -1
  5. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/auth.py +187 -19
  6. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/models.py +6 -2
  7. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/settings.py +26 -7
  8. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/.gitignore +0 -0
  9. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  10. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  11. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  12. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  13. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  14. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  15. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  16. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  17. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  18. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  19. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  20. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  21. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  22. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/__init__.py +0 -0
  23. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  24. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  25. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
  26. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  27. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/__init__.py +0 -0
  28. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/a2a.py +0 -0
  29. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/chat.py +0 -0
  30. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/conversations.py +0 -0
  31. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/dependencies.py +0 -0
  32. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/health.py +0 -0
  33. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/inspection.py +0 -0
  34. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/local_chat.py +0 -0
  35. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/models.py +0 -0
  36. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/sessions.py +0 -0
  37. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/app.py +0 -0
  38. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/application.py +0 -0
  39. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/__init__.py +0 -0
  40. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/client.py +0 -0
  41. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/local.py +0 -0
  42. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/mcp.py +0 -0
  43. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/routes.py +0 -0
  44. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/cli.py +0 -0
  45. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/errors.py +0 -0
  46. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/logging.py +0 -0
  47. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  48. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  49. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  50. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  51. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  52. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  53. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  54. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/__init__.py +0 -0
  55. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/definitions.py +0 -0
  56. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/factory.py +0 -0
  57. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  58. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  59. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/__init__.py +0 -0
  60. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/loader.py +0 -0
  61. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  62. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  63. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  64. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/events.py +0 -0
  65. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  66. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/failures.py +0 -0
  67. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  68. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/manager.py +0 -0
  69. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/observability.py +0 -0
  70. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  71. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/session.py +0 -0
  72. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  73. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  74. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  75. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/sessions/storage.py +0 -0
  76. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/skills.py +0 -0
  77. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/__init__.py +0 -0
  78. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  79. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  80. {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 — 2026-10-05
4
+
5
+ - A managed runtime can prove its runtime credential with a projected workload identity token
6
+ instead of the bootstrap secret. With the new `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` setting,
7
+ the runtime credential exchange reads that file for every exchange, because the token in it is
8
+ rotated, and sends `credential_id` with `workload_identity_token`. In this mode the SDK never
9
+ reads or sends `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET`, and the configuration check no longer
10
+ requires it. A missing, unreadable, or empty token file is a configuration error that names the
11
+ file, and the exchange never falls back to the secret. The token stays inside the exchange: it is
12
+ not kept in the settings, the environment, or a file, and never appears in a log or an error
13
+ message. Without the setting, the exchange sends the secret as before. In both modes, an
14
+ exchange answered with HTTP 429 or 503 is sent again up to three times, after waiting as long as
15
+ `Retry-After` asks (at most 60 seconds) or 1, 2, then 4 seconds without it; a longer
16
+ `Retry-After` fails at once. HTTP 401 fails at once, without a retry or another proof. The
17
+ `runtime.auth.exchange.completed` log event names the proof in `proof` and now also reports
18
+ failed exchanges. See the 2026-10-05 amendment of ADR 0002
19
+ ([#56](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/56)).
20
+ - Printing or logging the settings no longer shows the runtime credential secret. `repr()` and
21
+ `str()` of `TauSDKSettings` showed `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET` in plain text, and so
22
+ did every log event that carried the settings object. Three other objects showed a credential
23
+ the same way, and now leave it out of `repr()` and `str()`:
24
+ - `AccessToken`: the Main Sequence access token;
25
+ - `ProviderCredential`: the key that an `organization_custom` credential carries in `headers`;
26
+ - `TauRuntimeBootstrap`: the hydrated provider credentials in `provider_credentials`.
27
+
28
+ A settings validation error no longer repeats the configured values. When the secret or a
29
+ local token was the last value configured, the error printed its last characters. The error
30
+ still names the variable and the problem. The SDK reads and sends every value as before, and
31
+ `runtime_credential_secret` is still a `str`
32
+ ([#57](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/57)).
33
+
3
34
  ## 1.3.0 — 2026-10-05
4
35
 
5
36
  - Project skills stay available with `TAU_EXCLUDE_BASE_TOOLS=true`. Tau lists skills in the system
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 1.3.0.dev27
3
+ Version: 1.4.0.dev28
4
4
  Summary: Workspace-bound Tau application primitives for Main Sequence projects
5
5
  Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
6
6
  Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
@@ -90,6 +90,11 @@ export MAINSEQUENCE_RUNTIME_CREDENTIAL_ID="<runtime-credential-id>"
90
90
  export MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET="<runtime-credential-secret>"
91
91
  ```
92
92
 
93
+ A runtime that Main Sequence deploys with a projected workload identity token gets
94
+ `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` instead of the secret. The SDK then reads the token from
95
+ that file for every exchange and never uses a secret. See
96
+ [Settings and credentials](docs/reference/settings.md#managed-authenticated-startup).
97
+
93
98
  Start the service from the project workspace:
94
99
 
95
100
  ```bash
@@ -59,6 +59,11 @@ export MAINSEQUENCE_RUNTIME_CREDENTIAL_ID="<runtime-credential-id>"
59
59
  export MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET="<runtime-credential-secret>"
60
60
  ```
61
61
 
62
+ A runtime that Main Sequence deploys with a projected workload identity token gets
63
+ `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` instead of the secret. The SDK then reads the token from
64
+ that file for every exchange and never uses a secret. See
65
+ [Settings and credentials](docs/reference/settings.md#managed-authenticated-startup).
66
+
62
67
  Start the service from the project workspace:
63
68
 
64
69
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "1.3.0.dev27"
7
+ version = "1.4.0.dev28"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -9,9 +9,11 @@ import math
9
9
  import os
10
10
  import subprocess
11
11
  import time
12
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from email.utils import parsedate_to_datetime
13
15
  from pathlib import Path
14
- from typing import Protocol
16
+ from typing import Literal, Protocol
15
17
  from urllib.parse import urlsplit
16
18
 
17
19
  import httpx
@@ -22,6 +24,8 @@ from ms_tau_sdk.settings import (
22
24
  ACCESS_TOKEN_ENV,
23
25
  ENV_FILE,
24
26
  REFRESH_TOKEN_ENV,
27
+ RUNTIME_CREDENTIAL_SECRET_ENV,
28
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV,
25
29
  TauSDKSettings,
26
30
  )
27
31
 
@@ -32,6 +36,16 @@ JWT_REFRESH_PATH = "/auth/jwt-token/token/refresh/"
32
36
  CLI_TOKEN_COMMAND = ("auth", "token", "--json")
33
37
  CLI_TOKEN_TIMEOUT_SECONDS = 15.0
34
38
  CLI_TOKEN_REUSE_MARGIN_SECONDS = 60
39
+ # The runtime credential exchange retries the answers that mean "not now": 429 (throttled) and
40
+ # 503 (verification temporarily unavailable). A 401 is final.
41
+ EXCHANGE_RETRY_STATUS_CODES = frozenset({429, 503})
42
+ EXCHANGE_MAX_RETRIES = 3
43
+ EXCHANGE_RETRY_BASE_DELAY_SECONDS = 1.0
44
+ EXCHANGE_MAX_RETRY_DELAY_SECONDS = 60.0
45
+ # A projected token is a JWT of a few kilobytes. A larger file is not a token file.
46
+ IDENTITY_TOKEN_MAX_BYTES = 64 * 1024
47
+
48
+ type RuntimeCredentialProof = Literal["workload_identity_token", "credential_secret"]
35
49
 
36
50
 
37
51
  class BackendAuth(Protocol):
@@ -46,7 +60,7 @@ class BackendAuth(Protocol):
46
60
 
47
61
  @dataclass(slots=True)
48
62
  class AccessToken:
49
- value: str
63
+ value: str = field(repr=False) # the bearer token itself never appears in repr() or str()
50
64
  token_type: str
51
65
  expires_at: float | None
52
66
 
@@ -54,7 +68,94 @@ class AccessToken:
54
68
  return self.expires_at is not None and self.expires_at <= time.time() + skew_seconds
55
69
 
56
70
 
71
+ def _read_workload_identity_token(path: Path) -> str:
72
+ """Read the projected workload identity token for one exchange.
73
+
74
+ The token file is rotated, so it is read for every exchange and the token is never cached.
75
+ The token leaves this function only as its return value. An error names the file and the
76
+ reason, never any of its content, and no error falls back to the runtime credential secret.
77
+ """
78
+
79
+ def unusable(reason: str) -> ConfigurationError:
80
+ return ConfigurationError(
81
+ f"{RUNTIME_IDENTITY_TOKEN_FILE_ENV} names {path}, which {reason}. The runtime "
82
+ f"credential exchange does not fall back to {RUNTIME_CREDENTIAL_SECRET_ENV}."
83
+ )
84
+
85
+ try:
86
+ with path.open("rb") as handle:
87
+ content = handle.read(IDENTITY_TOKEN_MAX_BYTES + 1)
88
+ except OSError as error:
89
+ raise unusable(f"cannot be read ({error.strerror or type(error).__name__})") from None
90
+ if len(content) > IDENTITY_TOKEN_MAX_BYTES:
91
+ raise unusable(
92
+ f"is larger than {IDENTITY_TOKEN_MAX_BYTES // 1024} KiB, too large for a token"
93
+ )
94
+ try:
95
+ token: str | None = content.decode("utf-8").strip()
96
+ except UnicodeDecodeError:
97
+ # Raised outside this handler, so the decoding error and the bytes it holds stay behind.
98
+ token = None
99
+ if token is None:
100
+ raise unusable("does not hold text")
101
+ if not token:
102
+ raise unusable("is empty")
103
+ return token
104
+
105
+
106
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
107
+ """Return the wait a ``Retry-After`` header asks for, or None when there is no usable one."""
108
+
109
+ value = response.headers.get("Retry-After", "").strip()
110
+ if not value:
111
+ return None
112
+ if value.isascii() and value.isdigit():
113
+ return float(value)
114
+ try:
115
+ when = parsedate_to_datetime(value)
116
+ except (TypeError, ValueError, OverflowError):
117
+ return None
118
+ if when.tzinfo is None:
119
+ when = when.replace(tzinfo=UTC)
120
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
121
+
122
+
123
+ def _exchange_retry_delay(response: httpx.Response, *, retry: int) -> float:
124
+ """Wait as long as the platform asks, or back off exponentially when it does not say."""
125
+
126
+ asked = _retry_after_seconds(response)
127
+ if asked is not None:
128
+ return asked
129
+ return EXCHANGE_RETRY_BASE_DELAY_SECONDS * 2.0 ** (retry - 1)
130
+
131
+
132
+ def _rejection_message(proof: RuntimeCredentialProof) -> str:
133
+ """Explain a 401 from the exchange by the names of the settings involved, never a value."""
134
+
135
+ if proof == "workload_identity_token":
136
+ return (
137
+ "Runtime credential exchange was rejected (HTTP 401): the platform did not accept "
138
+ f"the workload identity token read from {RUNTIME_IDENTITY_TOKEN_FILE_ENV}. A rejected "
139
+ "token is not retried, and the exchange does not fall back to "
140
+ f"{RUNTIME_CREDENTIAL_SECRET_ENV}."
141
+ )
142
+ return (
143
+ "Runtime credential exchange was rejected (HTTP 401): the platform did not accept "
144
+ f"{RUNTIME_CREDENTIAL_SECRET_ENV}. A rejected credential is not retried."
145
+ )
146
+
147
+
57
148
  class RuntimeCredentialAuth:
149
+ """Exchange the managed runtime credential for short-lived Main Sequence access tokens.
150
+
151
+ The exchange proves the credential in one of two ways. With
152
+ ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE`` set, it sends the projected workload identity
153
+ token read from that file for every exchange, and never the secret. Without it, it sends
154
+ ``MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET``. The token stays inside the exchange: it is not
155
+ kept on this object, in the settings, or in the environment, and it is never logged or put
156
+ in an error.
157
+ """
158
+
58
159
  def __init__(
59
160
  self,
60
161
  settings: TauSDKSettings,
@@ -79,6 +180,12 @@ class RuntimeCredentialAuth:
79
180
  """Authenticate during process startup so readiness is truthful."""
80
181
  await self._access_token(force=False)
81
182
 
183
+ def _proof(self) -> RuntimeCredentialProof:
184
+ """Name the proof the exchange sends. It is a field name, never a value."""
185
+ if self.settings.runtime_identity_token_file is not None:
186
+ return "workload_identity_token"
187
+ return "credential_secret"
188
+
82
189
  async def _access_token(self, *, force: bool) -> AccessToken:
83
190
  if not force and self._token is not None and not self._token.needs_refresh():
84
191
  return self._token
@@ -86,30 +193,25 @@ class RuntimeCredentialAuth:
86
193
  if not force and self._token is not None and not self._token.needs_refresh():
87
194
  return self._token
88
195
  self.settings.validate_runtime_auth()
89
- if (
90
- not self.settings.runtime_credential_id
91
- or not self.settings.runtime_credential_secret
92
- ):
93
- raise ConfigurationError("Runtime credentials are not configured")
196
+ proof = self._proof()
94
197
  client = self._client or httpx.AsyncClient(timeout=10)
95
198
  close_client = self._client is None
96
199
  started_at = time.monotonic()
97
200
  try:
98
- response = await client.post(
99
- f"{self.settings.backend_url.rstrip('/')}{RUNTIME_CREDENTIAL_TOKEN}",
100
- json={
101
- "credential_id": self.settings.runtime_credential_id,
102
- "credential_secret": self.settings.runtime_credential_secret,
103
- },
201
+ response, attempts = await self._exchange(client, proof)
202
+ except Exception as failure:
203
+ logger.warning(
204
+ "runtime.auth.exchange.completed",
205
+ duration_ms=round((time.monotonic() - started_at) * 1000, 3),
206
+ outcome="failed",
207
+ proof=proof,
208
+ error_type=type(failure).__name__,
209
+ status_code=getattr(failure, "backend_status", None),
104
210
  )
211
+ raise
105
212
  finally:
106
213
  if close_client:
107
214
  await client.aclose()
108
- if not response.is_success:
109
- raise BackendError(
110
- "Runtime credential exchange failed",
111
- status_code=response.status_code,
112
- )
113
215
  data = response.json()
114
216
  value = str(data.get("access") or "").strip()
115
217
  if not value:
@@ -129,9 +231,75 @@ class RuntimeCredentialAuth:
129
231
  "runtime.auth.exchange.completed",
130
232
  duration_ms=round((time.monotonic() - started_at) * 1000, 3),
131
233
  outcome="success",
234
+ proof=proof,
235
+ attempts=attempts,
132
236
  )
133
237
  return self._token
134
238
 
239
+ async def _exchange(
240
+ self,
241
+ client: httpx.AsyncClient,
242
+ proof: RuntimeCredentialProof,
243
+ ) -> tuple[httpx.Response, int]:
244
+ """Send the exchange, and send it again a bounded number of times after 429 or 503."""
245
+ url = f"{self.settings.backend_url.rstrip('/')}{RUNTIME_CREDENTIAL_TOKEN}"
246
+ attempt = 1
247
+ while True:
248
+ # Each attempt builds its request again, so a rotated token file is read again.
249
+ response = await client.post(url, json=await self._exchange_body())
250
+ status = response.status_code
251
+ if response.is_success:
252
+ return response, attempt
253
+ if status == 401:
254
+ raise BackendError(_rejection_message(proof), status_code=status)
255
+ if status not in EXCHANGE_RETRY_STATUS_CODES:
256
+ raise BackendError(
257
+ f"Runtime credential exchange failed (HTTP {status})", status_code=status
258
+ )
259
+ meaning = (
260
+ "the platform throttled the exchange"
261
+ if status == 429
262
+ else "the platform could not verify the runtime credential"
263
+ )
264
+ if attempt > EXCHANGE_MAX_RETRIES:
265
+ raise BackendError(
266
+ f"Runtime credential exchange failed (HTTP {status}) after {attempt} "
267
+ f"attempts: {meaning}. Try again later.",
268
+ status_code=status,
269
+ )
270
+ delay = _exchange_retry_delay(response, retry=attempt)
271
+ if delay > EXCHANGE_MAX_RETRY_DELAY_SECONDS:
272
+ raise BackendError(
273
+ f"Runtime credential exchange failed (HTTP {status}): {meaning} and asked "
274
+ f"to retry after {delay:.0f} seconds, longer than the "
275
+ f"{EXCHANGE_MAX_RETRY_DELAY_SECONDS:.0f} seconds the SDK waits.",
276
+ status_code=status,
277
+ )
278
+ logger.warning(
279
+ "runtime.auth.exchange.retrying",
280
+ status_code=status,
281
+ attempt=attempt,
282
+ max_attempts=EXCHANGE_MAX_RETRIES + 1,
283
+ delay_seconds=delay,
284
+ proof=proof,
285
+ )
286
+ await asyncio.sleep(delay)
287
+ attempt += 1
288
+
289
+ async def _exchange_body(self) -> dict[str, str]:
290
+ """Build one exchange request. It carries exactly one proof of the credential."""
291
+ credential_id = self.settings.runtime_credential_id
292
+ if not credential_id:
293
+ raise ConfigurationError("Runtime credentials are not configured")
294
+ token_file = self.settings.runtime_identity_token_file
295
+ if token_file is not None:
296
+ token = await asyncio.to_thread(_read_workload_identity_token, token_file)
297
+ return {"credential_id": credential_id, "workload_identity_token": token}
298
+ secret = self.settings.runtime_credential_secret
299
+ if not secret:
300
+ raise ConfigurationError("Runtime credentials are not configured")
301
+ return {"credential_id": credential_id, "credential_secret": secret}
302
+
135
303
 
136
304
  def _jwt_expiry(token: str) -> int | None:
137
305
  """Read an unverified expiry only to decide when authenticated refresh is due."""
@@ -355,7 +355,9 @@ class TauRuntimeBootstrap(BackendModel):
355
355
  runtime_state: RuntimeState
356
356
  history: SessionEntryList
357
357
  resume_snapshot: TauResumeSnapshot | None = None
358
- provider_credentials: dict[str, Any]
358
+ # The hydrated provider credentials hold their keys in plain text, so they never appear in
359
+ # repr() or str().
360
+ provider_credentials: dict[str, Any] = Field(repr=False)
359
361
  provider_control: ProviderControl
360
362
  runtime_capabilities: dict[str, str]
361
363
  bootstrap_replayed: bool = False
@@ -394,7 +396,9 @@ class ProviderCredential(BackendModel):
394
396
  expires_at: datetime | None = None
395
397
  account_id: str | None = None
396
398
  base_url: str | None = None
397
- headers: dict[str, str] = Field(default_factory=dict)
399
+ # An organization_custom credential carries its key in a header, so the headers never appear
400
+ # in repr() or str().
401
+ headers: dict[str, str] = Field(default_factory=dict, repr=False)
398
402
  metadata: dict[str, Any] = Field(default_factory=dict)
399
403
 
400
404
  def secret(self) -> str:
@@ -18,6 +18,9 @@ from .errors import ConfigurationError
18
18
  ENV_FILE = ".env"
19
19
  ACCESS_TOKEN_ENV = "MAINSEQUENCE_ACCESS_TOKEN"
20
20
  REFRESH_TOKEN_ENV = "MAINSEQUENCE_REFRESH_TOKEN"
21
+ RUNTIME_CREDENTIAL_ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
22
+ RUNTIME_CREDENTIAL_SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
23
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
21
24
  MAINSEQUENCE_CLI_ENV = "MAINSEQUENCE_CLI"
22
25
  MAINSEQUENCE_CLI_NAME = "mainsequence"
23
26
  LocalAuthSource = Literal["environment", "env_file", "cli"]
@@ -60,6 +63,9 @@ class TauSDKSettings(BaseSettings):
60
63
  case_sensitive=True,
61
64
  extra="ignore",
62
65
  populate_by_name=True,
66
+ # The input holds credentials, so a validation error names the setting and the problem
67
+ # but never echoes the configured values.
68
+ hide_input_in_errors=True,
63
69
  )
64
70
 
65
71
  backend_url: str = Field(
@@ -72,11 +78,21 @@ class TauSDKSettings(BaseSettings):
72
78
  )
73
79
  runtime_credential_id: str | None = Field(
74
80
  default=None,
75
- validation_alias="MAINSEQUENCE_RUNTIME_CREDENTIAL_ID",
81
+ validation_alias=RUNTIME_CREDENTIAL_ID_ENV,
76
82
  )
83
+ # A plain string, read by the exchange that sends it. It never appears in repr() or str(), so
84
+ # printing or logging the settings does not show it.
77
85
  runtime_credential_secret: str | None = Field(
78
86
  default=None,
79
- validation_alias="MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET",
87
+ validation_alias=RUNTIME_CREDENTIAL_SECRET_ENV,
88
+ repr=False,
89
+ )
90
+ # The file that holds the runtime's projected workload identity token. When it is set, the
91
+ # runtime credential exchange proves the credential with that token instead of the secret.
92
+ # Only the path is a setting: the token is read by each exchange and never kept here.
93
+ runtime_identity_token_file: Path | None = Field(
94
+ default=None,
95
+ validation_alias=RUNTIME_IDENTITY_TOKEN_FILE_ENV,
80
96
  )
81
97
  access_token: SecretStr | None = Field(
82
98
  default=None,
@@ -325,9 +341,9 @@ class TauSDKSettings(BaseSettings):
325
341
  normalized = str(value or "").strip()
326
342
  return normalized or None
327
343
 
328
- @field_validator("mainsequence_cli", mode="before")
344
+ @field_validator("mainsequence_cli", "runtime_identity_token_file", mode="before")
329
345
  @classmethod
330
- def ignore_blank_mainsequence_cli(cls, value: object) -> object:
346
+ def ignore_blank_paths(cls, value: object) -> object:
331
347
  if isinstance(value, str) and not value.strip():
332
348
  return None
333
349
  return value
@@ -438,9 +454,12 @@ class TauSDKSettings(BaseSettings):
438
454
  return
439
455
  missing = []
440
456
  if not self.runtime_credential_id:
441
- missing.append("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID")
442
- if not self.runtime_credential_secret:
443
- missing.append("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET")
457
+ missing.append(RUNTIME_CREDENTIAL_ID_ENV)
458
+ # The credential is proven with the projected workload identity token or with the
459
+ # secret. With a token file the secret is not needed. The file itself is read, and a
460
+ # missing or empty file reported, by each exchange, because the token is rotated.
461
+ if self.runtime_identity_token_file is None and not self.runtime_credential_secret:
462
+ missing.append(f"{RUNTIME_CREDENTIAL_SECRET_ENV} or {RUNTIME_IDENTITY_TOKEN_FILE_ENV}")
444
463
  if missing:
445
464
  raise ConfigurationError("Missing runtime credential settings: " + ", ".join(missing))
446
465