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.
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/CHANGELOG.md +31 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/PKG-INFO +6 -1
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/README.md +5 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/pyproject.toml +1 -1
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/auth.py +187 -19
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/models.py +6 -2
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/settings.py +26 -7
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/.gitignore +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/a2a.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/chat.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/conversations.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/dependencies.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/health.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/inspection.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/local_chat.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/models.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/api/sessions.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/app.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/application.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/client.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/local.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/mcp.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/backend/routes.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/cli.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/errors.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/logging.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/definitions.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/factory.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/resources/loader.py +0 -0
- {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
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/events.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/extensions.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/failures.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/manager.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/observability.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/provenance.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/session.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/task_context.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/sessions/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/sessions/storage.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/skills.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/__init__.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
- {ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/tools/skill_read.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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(
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/__init__.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/app.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/cli.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/config.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/env_file.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/logs.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/proxy.py
RENAMED
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/state.py
RENAMED
|
File without changes
|
|
File without changes
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/packages/tau-board/src/ms_tau_board/static/app.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
|
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
|
{ms_tau_sdk-1.3.0.dev27 → ms_tau_sdk-1.4.0.dev28}/src/ms_tau_sdk/runtime/deployment_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|